From b441cb56fa994db3152e1b0c423e1aa844e7578e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 15:39:34 -0600 Subject: [PATCH 1/4] test: add column mapping test for WebAuthnCredential byte[] length (#286) --- .../WebAuthnCredentialColumnMappingTest.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java new file mode 100644 index 0000000..bfaee33 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java @@ -0,0 +1,29 @@ +package com.digitalsanctuary.spring.user.persistence.model; + +import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Field; +import jakarta.persistence.Column; +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); + } +} From 6bc18e52e9a12211a5081f50ea2ec70e1eba0793 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 15:40:44 -0600 Subject: [PATCH 2/4] fix(webauthn): use Length.LONG32 for byte[] columns to fix MariaDB row size limit (#286) VARBINARY(65535) columns exceed MariaDB's InnoDB 65,535-byte row size limit, causing silent table creation failure with ddl-auto: update. Length.LONG32 causes Hibernate to auto-upgrade to LONGBLOB on MariaDB (stored off-page) and stays as bytea on PostgreSQL (no OID issues). Avoids @Lob which maps to OID on PostgreSQL per Hibernate docs. --- .../spring/user/persistence/model/WebAuthnCredential.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java index 6620644..f9fbe5c 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java @@ -1,6 +1,7 @@ package com.digitalsanctuary.spring.user.persistence.model; import java.time.Instant; +import org.hibernate.Length; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -30,7 +31,7 @@ public class WebAuthnCredential { private WebAuthnUserEntity userEntity; /** COSE-encoded public key (typically 77-300 bytes, RSA keys can be larger). */ - @Column(name = "public_key", nullable = false, length = 2048) + @Column(name = "public_key", nullable = false, length = Length.LONG32) private byte[] publicKey; /** Counter to detect cloned authenticators. */ @@ -58,11 +59,11 @@ public class WebAuthnCredential { private boolean backupState; /** Attestation data from registration (can be several KB). */ - @Column(name = "attestation_object", length = 65535) + @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) + @Column(name = "attestation_client_data_json", length = Length.LONG32) private byte[] attestationClientDataJson; /** Creation timestamp. */ From d0142c7523908730b9e75e9ad2b937b281ef8351 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 16:24:03 -0600 Subject: [PATCH 3/4] fix(webauthn): address PR #287 review feedback - Fix import ordering (alphabetical: jakarta < java < lombok < org) in WebAuthnCredential.java and WebAuthnCredentialColumnMappingTest.java - Add Javadoc rationale to byte[] fields explaining Length.LONG32 is intentional to prevent future reversion to smaller lengths - Add MIGRATION.md note for existing deployments with VARBINARY columns --- MIGRATION.md | 26 +++++++++++++++++++ .../persistence/model/WebAuthnCredential.java | 26 +++++++++++++++---- .../WebAuthnCredentialColumnMappingTest.java | 2 +- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 5ba128f..e8cc4ed 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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. diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java index f9fbe5c..0fe7f72 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredential.java @@ -1,7 +1,5 @@ package com.digitalsanctuary.spring.user.persistence.model; -import java.time.Instant; -import org.hibernate.Length; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -9,7 +7,9 @@ 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 @@ -30,7 +30,15 @@ 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). */ + /** + * COSE-encoded public key (typically 77-300 bytes, RSA keys can be larger). + * + *

{@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.

+ */ @Column(name = "public_key", nullable = false, length = Length.LONG32) private byte[] publicKey; @@ -58,11 +66,19 @@ public class WebAuthnCredential { @Column(name = "backup_state", nullable = false) private boolean backupState; - /** Attestation data from registration (can be several KB). */ + /** + * Attestation data from registration (can be several KB). + * + *

See {@link #publicKey} for why {@code length = Length.LONG32} is used here.

+ */ @Column(name = "attestation_object", length = Length.LONG32) private byte[] attestationObject; - /** Client data JSON from registration (can be several KB). */ + /** + * Client data JSON from registration (can be several KB). + * + *

See {@link #publicKey} for why {@code length = Length.LONG32} is used here.

+ */ @Column(name = "attestation_client_data_json", length = Length.LONG32) private byte[] attestationClientDataJson; diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java index bfaee33..2369736 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/model/WebAuthnCredentialColumnMappingTest.java @@ -1,8 +1,8 @@ package com.digitalsanctuary.spring.user.persistence.model; import static org.assertj.core.api.Assertions.assertThat; -import java.lang.reflect.Field; 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; From dd213d54866d7524f27d1cb02312a2665fbaf5b3 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 16:30:56 -0600 Subject: [PATCH 4/4] test(schema): add Testcontainers schema validation for MariaDB and PostgreSQL (#286) Validate that Hibernate can create the full schema without errors on real MariaDB and PostgreSQL instances via Testcontainers. Tests verify: - All 10 expected tables (entities + join tables) are created - WebAuthn byte[] columns map to BLOB-compatible types (longblob on MariaDB, bytea on PostgreSQL) rather than inline VARBINARY Adds testcontainers-postgresql and testcontainers-junit-jupiter deps. --- build.gradle | 2 + .../schema/AbstractSchemaValidationTest.java | 85 +++++++++++++++++++ .../schema/MariaDBSchemaValidationTest.java | 51 +++++++++++ .../PostgreSQLSchemaValidationTest.java | 51 +++++++++++ 4 files changed, 189 insertions(+) create mode 100644 src/test/java/com/digitalsanctuary/spring/user/persistence/schema/AbstractSchemaValidationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/persistence/schema/MariaDBSchemaValidationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/user/persistence/schema/PostgreSQLSchemaValidationTest.java diff --git a/build.gradle b/build.gradle index 1058748..61c2875 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/AbstractSchemaValidationTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/AbstractSchemaValidationTest.java new file mode 100644 index 0000000..48e7797 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/AbstractSchemaValidationTest.java @@ -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. + * + *

+ * 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. + *

+ */ +@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 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 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 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 getAllowedBlobTypes(); +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/MariaDBSchemaValidationTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/MariaDBSchemaValidationTest.java new file mode 100644 index 0000000..17c3c28 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/MariaDBSchemaValidationTest.java @@ -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 getAllowedBlobTypes() { + return Set.of("longblob", "mediumblob", "blob"); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/PostgreSQLSchemaValidationTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/PostgreSQLSchemaValidationTest.java new file mode 100644 index 0000000..5a8347d --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/schema/PostgreSQLSchemaValidationTest.java @@ -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 getAllowedBlobTypes() { + return Set.of("bytea"); + } +}