Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,32 @@ The `/user/updateUser` endpoint now uses `UserProfileUpdateDto`.

---

**Issue: `user_credentials` table not created on MariaDB/MySQL (WebAuthn)**

With `ddl-auto: update` or `create`, Hibernate previously mapped the `attestationObject` and
`attestationClientDataJson` columns to `VARBINARY(65535)`. Two such columns exceed MariaDB's
InnoDB 65,535-byte row-size limit, causing silent table creation failure. Symptoms include 500
errors on `/user/auth-methods` or `/user/webauthn/credentials`.

**Solution (upgrading from a version prior to this fix):**

If the `user_credentials` table was never created, it will be created automatically on next
startup with `ddl-auto: update` once you upgrade to this version.

If the table exists with `VARBINARY` columns (created on a non-MariaDB database), run:

```sql
ALTER TABLE user_credentials
MODIFY COLUMN public_key LONGBLOB NOT NULL,
MODIFY COLUMN attestation_object LONGBLOB,
MODIFY COLUMN attestation_client_data_json LONGBLOB;
```

With `ddl-auto: update`, Hibernate will handle this automatically on MariaDB/MySQL. On
PostgreSQL no schema change is needed — the columns map to `bytea` in both old and new versions.

---

**Issue: Java version incompatibility**

Spring Boot 4.0 requires Java 21.
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ dependencies {

// Additional test dependencies for improved testing
testImplementation 'org.testcontainers:testcontainers:2.0.3'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter:2.0.3'
testImplementation 'org.testcontainers:testcontainers-mariadb:2.0.3'
testImplementation 'org.testcontainers:testcontainers-postgresql:2.0.3'
testImplementation 'com.github.tomakehurst:wiremock:3.0.1'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1'
testImplementation 'org.assertj:assertj-core:3.27.7'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package com.digitalsanctuary.spring.user.persistence.model;

import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
import lombok.Data;
import org.hibernate.Length;

/**
* JPA entity for the {@code user_credentials} table. Stores WebAuthn credentials (public keys) for passkey
Expand All @@ -29,8 +30,16 @@ public class WebAuthnCredential {
@JoinColumn(name = "user_entity_user_id", nullable = false)
private WebAuthnUserEntity userEntity;

/** COSE-encoded public key (typically 77-300 bytes, RSA keys can be larger). */
@Column(name = "public_key", nullable = false, length = 2048)
/**
* COSE-encoded public key (typically 77-300 bytes, RSA keys can be larger).
*
* <p>{@code length = Length.LONG32} is intentional: it forces Hibernate to emit {@code LONGBLOB} on
* MariaDB/MySQL (stored off-page, avoiding the 65,535-byte InnoDB row-size limit) while mapping to
* {@code bytea} on PostgreSQL. Do not reduce this to a smaller value — doing so reintroduces the
* MariaDB DDL failure described in GitHub issue #286. Do not replace with {@code @Lob}, which maps
* to {@code OID} on PostgreSQL.</p>
*/
@Column(name = "public_key", nullable = false, length = Length.LONG32)
private byte[] publicKey;

/** Counter to detect cloned authenticators. */
Expand All @@ -57,12 +66,20 @@ public class WebAuthnCredential {
@Column(name = "backup_state", nullable = false)
private boolean backupState;

/** Attestation data from registration (can be several KB). */
@Column(name = "attestation_object", length = 65535)
/**
* Attestation data from registration (can be several KB).
*
* <p>See {@link #publicKey} for why {@code length = Length.LONG32} is used here.</p>
*/
@Column(name = "attestation_object", length = Length.LONG32)
private byte[] attestationObject;

/** Client data JSON from registration (can be several KB). */
@Column(name = "attestation_client_data_json", length = 65535)
/**
* Client data JSON from registration (can be several KB).
*
* <p>See {@link #publicKey} for why {@code length = Length.LONG32} is used here.</p>
*/
@Column(name = "attestation_client_data_json", length = Length.LONG32)
private byte[] attestationClientDataJson;

/** Creation timestamp. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.digitalsanctuary.spring.user.persistence.model;

import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.Column;
import java.lang.reflect.Field;
import org.hibernate.Length;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

@DisplayName("WebAuthnCredential Column Mapping Tests")
class WebAuthnCredentialColumnMappingTest {

@ParameterizedTest
@ValueSource(strings = {"attestationObject", "attestationClientDataJson", "publicKey"})
@DisplayName("should use Length.LONG32 on byte[] fields for cross-database BLOB compatibility")
void shouldUseLengthLong32OnBlobFields(String fieldName) throws NoSuchFieldException {
Field field = WebAuthnCredential.class.getDeclaredField(fieldName);
Column column = field.getAnnotation(Column.class);
assertThat(column)
.as("Field '%s' must have @Column annotation", fieldName)
.isNotNull();
assertThat(column.length())
.as("Field '%s' @Column length must be Length.LONG32 (%d) to auto-upgrade "
+ "to LONGBLOB on MariaDB/MySQL and remain bytea on PostgreSQL",
fieldName, Length.LONG32)
.isEqualTo(Length.LONG32);
Comment on lines +14 to +27
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reflection-based assertion verifies the annotation value but not the actual DDL/type Hibernate generates for MariaDB/MySQL (the regression reported in #286). If feasible, add a test that runs Hibernate schema generation with the MariaDB dialect (or a lightweight Testcontainers MariaDB) and asserts these fields map to a BLOB/LONGBLOB type rather than VARBINARY, so the table-creation failure can’t silently return.

Copilot uses AI. Check for mistakes.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.digitalsanctuary.spring.user.persistence.schema;

import static org.assertj.core.api.Assertions.assertThat;
import com.digitalsanctuary.spring.user.test.app.TestApplication;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;

/**
* Abstract base class for database schema validation tests. Subclasses provide a real database via Testcontainers and
* configure Spring to connect to it. This test verifies that Hibernate can create the full schema without errors on each
* target database.
*
* <p>
* The test uses {@code ddl-auto: create} (via Spring Boot properties) and then queries
* {@code INFORMATION_SCHEMA.TABLES} to verify all expected tables were created. This catches silent DDL failures like
* the one described in GitHub issue #286.
* </p>
*/
@SpringBootTest(classes = TestApplication.class)
abstract class AbstractSchemaValidationTest {

/**
* All tables expected to be created by Hibernate from the entity model. Includes entity tables and join tables.
*/
private static final Set<String> EXPECTED_TABLES = Set.of(
// Entity tables
"user_account", "role", "privilege", "verification_token", "password_reset_token",
"password_history_entry", "user_entities", "user_credentials",
// Join tables
"users_roles", "roles_privileges");

@Autowired
private JdbcTemplate jdbcTemplate;

@Test
@DisplayName("should create all expected tables without errors")
void shouldCreateAllExpectedTables() {
List<String> tables = jdbcTemplate.queryForList(
"SELECT LOWER(table_name) FROM INFORMATION_SCHEMA.TABLES WHERE LOWER(table_schema) = LOWER(?)",
String.class, getSchemaName());

assertThat(tables)
.as("All entity and join tables should be created by Hibernate on %s", getDatabaseName())
.containsAll(EXPECTED_TABLES);
}

@Test
@DisplayName("should create WebAuthn byte[] columns as BLOB-compatible types (not inline VARBINARY)")
void shouldCreateWebAuthnBlobColumns() {
List<String> blobColumns = List.of("public_key", "attestation_object", "attestation_client_data_json");

for (String column : blobColumns) {
String dataType = jdbcTemplate.queryForObject(
"SELECT LOWER(data_type) FROM INFORMATION_SCHEMA.COLUMNS "
+ "WHERE LOWER(table_schema) = LOWER(?) AND LOWER(table_name) = 'user_credentials' "
+ "AND LOWER(column_name) = ?",
String.class, getSchemaName(), column);

assertThat(dataType)
.as("Column '%s' on %s should be a BLOB-compatible type, not VARBINARY", column, getDatabaseName())
.isIn(getAllowedBlobTypes());
}
}

/**
* Returns the human-readable database name for assertion messages.
*/
protected abstract String getDatabaseName();

/**
* Returns the schema name used in INFORMATION_SCHEMA queries. MariaDB/MySQL uses the database name as schema;
* PostgreSQL uses 'public' by default.
*/
protected abstract String getSchemaName();

/**
* Returns the set of column data types considered acceptable for BLOB columns on this database.
*/
protected abstract Set<String> getAllowedBlobTypes();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.digitalsanctuary.spring.user.persistence.schema;

import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MariaDBContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

/**
* Validates that Hibernate can create the full schema on MariaDB without errors. This specifically catches the InnoDB
* row-size limit issue described in GitHub issue #286 where VARBINARY(65535) columns caused silent table creation
* failure.
*/
@Testcontainers
@DisplayName("MariaDB Schema Validation Tests")
class MariaDBSchemaValidationTest extends AbstractSchemaValidationTest {

@Container
static final MariaDBContainer<?> MARIADB = new MariaDBContainer<>("mariadb:11.4")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MARIADB::getJdbcUrl);
registry.add("spring.datasource.username", MARIADB::getUsername);
registry.add("spring.datasource.password", MARIADB::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.mariadb.jdbc.Driver");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MariaDBDialect");
registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.MariaDBDialect");
}

@Override
protected String getDatabaseName() {
return "MariaDB";
}

@Override
protected String getSchemaName() {
return "testdb";
}

@Override
protected Set<String> getAllowedBlobTypes() {
return Set.of("longblob", "mediumblob", "blob");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.digitalsanctuary.spring.user.persistence.schema;

import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

/**
* Validates that Hibernate can create the full schema on PostgreSQL without errors. Ensures the byte[] columns map to
* {@code bytea} (not {@code oid}), which would happen if {@code @Lob} were used instead of
* {@code length = Length.LONG32}.
*/
@Testcontainers
@DisplayName("PostgreSQL Schema Validation Tests")
class PostgreSQLSchemaValidationTest extends AbstractSchemaValidationTest {

@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect");
}

@Override
protected String getDatabaseName() {
return "PostgreSQL";
}

@Override
protected String getSchemaName() {
return "public";
}

@Override
protected Set<String> getAllowedBlobTypes() {
return Set.of("bytea");
}
}
Loading