From 524111f414d93cf7633be3b5ab43f7686091662e Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:47:53 +0800 Subject: [PATCH 1/3] feat(auth): enforce identity binding contract gate Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../identity-binding-v2-postgres-test.sh | 11 +- ...V46__identity_binding_v2_contract_gate.sql | 128 ++++ ...IdentityBindingV2ContractPostgresTest.java | 547 ++++++++++++++++++ ...dentityBindingV2MigrationPostgresTest.java | 1 + ...ntityBindingV2PostgresIntegrationTest.java | 26 +- .../db/FlywayMigrationGuardrailTest.java | 13 + 6 files changed, 722 insertions(+), 4 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V46__identity_binding_v2_contract_gate.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2ContractPostgresTest.java diff --git a/scripts/tests/identity-binding-v2-postgres-test.sh b/scripts/tests/identity-binding-v2-postgres-test.sh index af2d85e6..bf914dd8 100755 --- a/scripts/tests/identity-binding-v2-postgres-test.sh +++ b/scripts/tests/identity-binding-v2-postgres-test.sh @@ -45,6 +45,7 @@ docker exec "${POSTGRES_CONTAINER}" \ run_test() { test_class="$1" + flyway_target="${2:-}" java_version="" if command -v java >/dev/null 2>&1; then java_version="$(java -version 2>&1 | head -n 1)" @@ -57,6 +58,7 @@ run_test() { IDENTITY_BINDING_V2_POSTGRES_URL="jdbc:postgresql://127.0.0.1:${host_port}/${POSTGRES_DB}" \ IDENTITY_BINDING_V2_POSTGRES_USERNAME="${POSTGRES_USER}" \ IDENTITY_BINDING_V2_POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \ + IDENTITY_BINDING_V2_FLYWAY_TARGET="${flyway_target}" \ MAVEN_OPTS="-Xmx2g -XX:MaxMetaspaceSize=512m" \ ./mvnw \ -pl skillhub-app \ @@ -81,6 +83,7 @@ run_test() { -e "IDENTITY_BINDING_V2_POSTGRES_URL=jdbc:postgresql://${POSTGRES_CONTAINER}:5432/${POSTGRES_DB}" \ -e "IDENTITY_BINDING_V2_POSTGRES_USERNAME=${POSTGRES_USER}" \ -e "IDENTITY_BINDING_V2_POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" \ + -e "IDENTITY_BINDING_V2_FLYWAY_TARGET=${flyway_target}" \ -v "${REPO_ROOT}:/workspace" \ -v "${MAVEN_CACHE_DIR}:/tmp/skillhub-maven-home/.m2" \ -w /workspace/server \ @@ -95,4 +98,10 @@ run_test() { } run_test IdentityBindingV2MigrationPostgresTest -run_test IdentityBindingV2PostgresIntegrationTest +run_test \ + "IdentityBindingV2PostgresIntegrationTest#upgradesMixedVersionWriteAndPreservesLegacyReadColumn+concurrentFirstLoginConvergesOnOneBinding" \ + 45 +run_test IdentityBindingV2ContractPostgresTest 46 +run_test \ + "IdentityBindingV2PostgresIntegrationTest#concurrentFirstLoginConvergesAfterContractGate" \ + 46 diff --git a/server/skillhub-app/src/main/resources/db/migration/V46__identity_binding_v2_contract_gate.sql b/server/skillhub-app/src/main/resources/db/migration/V46__identity_binding_v2_contract_gate.sql new file mode 100644 index 00000000..211f6dd8 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V46__identity_binding_v2_contract_gate.sql @@ -0,0 +1,128 @@ +-- Binding V2 contract gate. +-- +-- Deploy this migration only after every pre-Binding-V2 application instance +-- has exited. Unlike the V45 expand migration, this gate rejects transactions +-- that leave an ACTIVE binding without exactly one ACTIVE primary subject. + +DO $$ +DECLARE + violation_summary TEXT; +BEGIN + SELECT string_agg( + format( + '%s (%s active primary subjects)', + binding_id, + active_primary_count + ), + ', ' ORDER BY binding_id + ) + INTO violation_summary + FROM ( + SELECT + binding.id AS binding_id, + COUNT(subject.id) FILTER ( + WHERE subject.status = 'ACTIVE' + AND subject.is_primary = TRUE + ) AS active_primary_count + FROM identity_binding binding + LEFT JOIN identity_binding_subject subject + ON subject.binding_id = binding.id + WHERE binding.status = 'ACTIVE' + GROUP BY binding.id + HAVING COUNT(subject.id) FILTER ( + WHERE subject.status = 'ACTIVE' + AND subject.is_primary = TRUE + ) <> 1 + ORDER BY binding.id + LIMIT 20 + ) violations; + + IF violation_summary IS NOT NULL THEN + RAISE EXCEPTION + 'Binding V2 contract preflight failed: ACTIVE bindings must have exactly one ACTIVE primary subject: %', + violation_summary; + END IF; +END +$$; + +CREATE FUNCTION assert_identity_binding_active_primary( + target_binding_id BIGINT +) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + binding_active BOOLEAN; + active_primary_count BIGINT; +BEGIN + SELECT + binding.status = 'ACTIVE', + COUNT(subject.id) FILTER ( + WHERE subject.status = 'ACTIVE' + AND subject.is_primary = TRUE + ) + INTO binding_active, active_primary_count + FROM identity_binding binding + LEFT JOIN identity_binding_subject subject + ON subject.binding_id = binding.id + WHERE binding.id = target_binding_id + GROUP BY binding.status; + + IF NOT FOUND OR NOT binding_active THEN + RETURN; + END IF; + + IF active_primary_count <> 1 THEN + RAISE EXCEPTION + 'ACTIVE identity binding % must have exactly one ACTIVE primary subject; found %', + target_binding_id, + active_primary_count + USING + ERRCODE = '23514', + CONSTRAINT = 'chk_identity_binding_active_primary'; + END IF; +END +$$; + +CREATE FUNCTION enforce_identity_binding_subject_primary() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + PERFORM assert_identity_binding_active_primary(NEW.binding_id); + ELSIF TG_OP = 'DELETE' THEN + PERFORM assert_identity_binding_active_primary(OLD.binding_id); + ELSE + PERFORM assert_identity_binding_active_primary(OLD.binding_id); + IF NEW.binding_id IS DISTINCT FROM OLD.binding_id THEN + PERFORM assert_identity_binding_active_primary(NEW.binding_id); + END IF; + END IF; + RETURN NULL; +END +$$; + +CREATE FUNCTION enforce_identity_binding_primary() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM assert_identity_binding_active_primary(NEW.id); + RETURN NULL; +END +$$; + +CREATE CONSTRAINT TRIGGER ct_identity_binding_subject_active_primary +AFTER INSERT OR UPDATE OR DELETE +ON identity_binding_subject +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION enforce_identity_binding_subject_primary(); + +CREATE CONSTRAINT TRIGGER ct_identity_binding_active_primary +AFTER INSERT OR UPDATE +ON identity_binding +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION enforce_identity_binding_primary(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2ContractPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2ContractPostgresTest.java new file mode 100644 index 00000000..725e61d4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2ContractPostgresTest.java @@ -0,0 +1,547 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable( + named = "IDENTITY_BINDING_V2_POSTGRES_URL", + matches = "jdbc:postgresql:.*") +class IdentityBindingV2ContractPostgresTest { + + private static final String PREFLIGHT_SCHEMA = + "identity_v2_contract_preflight"; + + @Test + void contractMigrationRejectsActiveBindingWithoutPrimary() + throws Exception { + Database database = database(); + dropSchema(database, PREFLIGHT_SCHEMA); + try { + Flyway.configure() + .dataSource( + database.url(), + database.username(), + database.password()) + .locations("classpath:db/migration") + .schemas(PREFLIGHT_SCHEMA) + .defaultSchema(PREFLIGHT_SCHEMA) + .createSchemas(true) + .target(MigrationVersion.fromVersion("45")) + .load() + .migrate(); + + try (Connection connection = database.connect(); + Statement statement = connection.createStatement()) { + statement.execute( + "SET search_path TO " + PREFLIGHT_SCHEMA); + insertUser(statement, "contract-preflight-user"); + statement.executeUpdate(""" + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + created_at, + updated_at + ) VALUES ( + 'contract-preflight-user', + 'contract-provider', + 'contract-preflight-subject', + 'contract-preflight', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + } + + Throwable failure = catchThrowable(() -> + Flyway.configure() + .dataSource( + database.url(), + database.username(), + database.password()) + .locations("classpath:db/migration") + .schemas(PREFLIGHT_SCHEMA) + .defaultSchema(PREFLIGHT_SCHEMA) + .createSchemas(true) + .load() + .migrate()); + + assertThat(failure).isNotNull(); + assertThat(rootCause(failure).getMessage()) + .contains( + "Binding V2 contract preflight failed"); + + try (Connection connection = database.connect(); + Statement statement = connection.createStatement()) { + statement.execute( + "SET search_path TO " + PREFLIGHT_SCHEMA); + assertThat(singleString( + statement, + """ + SELECT version + FROM flyway_schema_history + WHERE success = TRUE + ORDER BY installed_rank DESC + LIMIT 1 + """)).isEqualTo("45"); + assertThat(singleLong( + statement, + """ + SELECT COUNT(*) + FROM pg_trigger trigger + JOIN pg_class relation + ON relation.oid = trigger.tgrelid + JOIN pg_namespace namespace + ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = + 'identity_v2_contract_preflight' + AND trigger.tgname = + 'ct_identity_binding_active_primary' + """)).isZero(); + } + } finally { + dropSchema(database, PREFLIGHT_SCHEMA); + } + } + + @Test + void contractGateEnforcesExactlyOnePrimaryAndAtomicReplacement() + throws Exception { + Database database = database(); + Flyway.configure() + .dataSource( + database.url(), + database.username(), + database.password()) + .locations("classpath:db/migration") + .load() + .migrate(); + + long bindingId = createBindingWithPrimary(database); + + SQLException zeroPrimary = expectSqlFailure( + database, + connection -> update( + connection, + """ + UPDATE identity_binding_subject + SET is_primary = FALSE + WHERE binding_id = ? + AND is_primary = TRUE + """, + bindingId)); + assertConstraintViolation(zeroPrimary); + + inTransaction(database, connection -> update( + connection, + """ + INSERT INTO identity_binding_subject ( + binding_id, + provider_code, + subject_type, + subject_value, + is_primary, + status + ) VALUES (?, 'contract-provider', 'alias_id', + 'contract-alias-subject', FALSE, 'ACTIVE') + """, + bindingId)); + + inTransaction(database, connection -> { + update( + connection, + """ + UPDATE identity_binding_subject + SET is_primary = FALSE + WHERE binding_id = ? + AND is_primary = TRUE + """, + bindingId); + update( + connection, + """ + UPDATE identity_binding_subject + SET is_primary = TRUE + WHERE binding_id = ? + AND subject_type = 'alias_id' + """, + bindingId); + }); + assertThat(activePrimaryType(database, bindingId)) + .isEqualTo("alias_id"); + + SQLException twoPrimaries = expectSqlFailure( + database, + connection -> update( + connection, + """ + INSERT INTO identity_binding_subject ( + binding_id, + provider_code, + subject_type, + subject_value, + is_primary, + status + ) VALUES (?, 'contract-provider', + 'other_primary', + 'contract-other-primary', + TRUE, + 'ACTIVE') + """, + bindingId)); + assertThat(twoPrimaries.getSQLState()).isEqualTo("23505"); + + SQLException bindingWithoutPrimary = expectSqlFailure( + database, + connection -> { + try (Statement statement = + connection.createStatement()) { + insertUser( + statement, + "contract-zero-primary-user"); + statement.executeUpdate(""" + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + created_at, + updated_at + ) VALUES ( + 'contract-zero-primary-user', + 'contract-provider', + 'contract-zero-primary-subject', + 'contract-zero-primary', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + } + }); + assertConstraintViolation(bindingWithoutPrimary); + + SQLException deletedPrimary = expectSqlFailure( + database, + connection -> update( + connection, + """ + DELETE FROM identity_binding_subject + WHERE binding_id = ? + AND is_primary = TRUE + """, + bindingId)); + assertConstraintViolation(deletedPrimary); + + inTransaction(database, connection -> { + update( + connection, + """ + UPDATE identity_binding + SET status = 'REVOKED', + revoked_at = CURRENT_TIMESTAMP, + revoked_by = 'contract-test', + revocation_reason = 'contract test' + WHERE id = ? + """, + bindingId); + update( + connection, + """ + UPDATE identity_binding_subject + SET status = 'REVOKED', + revoked_at = CURRENT_TIMESTAMP, + is_primary = FALSE + WHERE binding_id = ? + AND is_primary = TRUE + """, + bindingId); + }); + + SQLException reactivatedWithoutPrimary = expectSqlFailure( + database, + connection -> activateBinding( + connection, + bindingId)); + assertConstraintViolation(reactivatedWithoutPrimary); + + inTransaction(database, connection -> { + update( + connection, + """ + UPDATE identity_binding_subject + SET is_primary = TRUE + WHERE binding_id = ? + AND status = 'ACTIVE' + """, + bindingId); + activateBinding(connection, bindingId); + }); + assertThat(activePrimaryType(database, bindingId)) + .isEqualTo("stable_id"); + + inTransaction(database, connection -> update( + connection, + "DELETE FROM identity_binding WHERE id = ?", + bindingId)); + try (Connection connection = database.connect(); + PreparedStatement statement = connection.prepareStatement( + """ + SELECT COUNT(*) + FROM identity_binding_subject + WHERE binding_id = ? + """)) { + statement.setLong(1, bindingId); + try (ResultSet result = statement.executeQuery()) { + assertThat(result.next()).isTrue(); + assertThat(result.getLong(1)).isZero(); + } + } + } + + private static long createBindingWithPrimary(Database database) + throws Exception { + final long[] bindingId = new long[1]; + inTransaction(database, connection -> { + try (Statement statement = connection.createStatement()) { + insertUser(statement, "contract-valid-user"); + } + try (PreparedStatement statement = connection.prepareStatement( + """ + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + created_at, + updated_at + ) VALUES ( + 'contract-valid-user', + 'contract-provider', + 'contract-stable-subject', + 'contract-valid', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + RETURNING id + """)) { + try (ResultSet result = statement.executeQuery()) { + assertThat(result.next()).isTrue(); + bindingId[0] = result.getLong(1); + } + } + update( + connection, + """ + INSERT INTO identity_binding_subject ( + binding_id, + provider_code, + subject_type, + subject_value, + is_primary, + status + ) VALUES (?, 'contract-provider', 'stable_id', + 'contract-stable-subject', TRUE, 'ACTIVE') + """, + bindingId[0]); + }); + return bindingId[0]; + } + + private static void activateBinding( + Connection connection, + long bindingId) throws SQLException { + update( + connection, + """ + UPDATE identity_binding + SET status = 'ACTIVE', + revoked_at = NULL, + revoked_by = NULL, + revocation_reason = NULL + WHERE id = ? + """, + bindingId); + } + + private static String activePrimaryType( + Database database, + long bindingId) throws Exception { + try (Connection connection = database.connect(); + PreparedStatement statement = connection.prepareStatement( + """ + SELECT subject_type + FROM identity_binding_subject + WHERE binding_id = ? + AND status = 'ACTIVE' + AND is_primary = TRUE + """)) { + statement.setLong(1, bindingId); + try (ResultSet result = statement.executeQuery()) { + assertThat(result.next()).isTrue(); + String subjectType = result.getString(1); + assertThat(result.next()).isFalse(); + return subjectType; + } + } + } + + private static void assertConstraintViolation( + SQLException failure) { + assertThat(failure.getSQLState()).isEqualTo("23514"); + assertThat(failure.getMessage()) + .contains( + "must have exactly one ACTIVE primary subject"); + } + + private static SQLException expectSqlFailure( + Database database, + SqlWork work) throws Exception { + try (Connection connection = database.connect()) { + connection.setAutoCommit(false); + try { + work.execute(connection); + connection.commit(); + } catch (SQLException failure) { + connection.rollback(); + return failure; + } + } + throw new AssertionError("Expected SQL transaction to fail"); + } + + private static void inTransaction( + Database database, + SqlWork work) throws Exception { + try (Connection connection = database.connect()) { + connection.setAutoCommit(false); + try { + work.execute(connection); + connection.commit(); + } catch (Exception failure) { + connection.rollback(); + throw failure; + } + } + } + + private static int update( + Connection connection, + String sql, + long bindingId) throws SQLException { + try (PreparedStatement statement = + connection.prepareStatement(sql)) { + statement.setLong(1, bindingId); + return statement.executeUpdate(); + } + } + + private static void insertUser( + Statement statement, + String userId) throws SQLException { + statement.executeUpdate(""" + INSERT INTO user_account ( + id, + display_name, + status, + created_at, + updated_at + ) VALUES ( + '%s', + 'Binding V2 Contract User', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """.formatted(userId)); + } + + private static long singleLong( + Statement statement, + String sql) throws SQLException { + try (ResultSet result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getLong(1); + } + } + + private static String singleString( + Statement statement, + String sql) throws SQLException { + try (ResultSet result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getString(1); + } + } + + private static void dropSchema( + Database database, + String schema) throws SQLException { + if (!PREFLIGHT_SCHEMA.equals(schema)) { + throw new IllegalArgumentException( + "Unexpected test schema " + schema); + } + try (Connection connection = database.connect(); + Statement statement = connection.createStatement()) { + statement.execute( + "DROP SCHEMA IF EXISTS " + schema + " CASCADE"); + } + } + + private static Throwable rootCause(Throwable failure) { + Throwable current = failure; + while (current.getCause() != null + && current.getCause() != current) { + current = current.getCause(); + } + return current; + } + + private static Database database() { + return new Database( + requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_URL"), + requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_USERNAME"), + requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_PASSWORD")); + } + + private static String requiredEnvironment(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException( + "Missing required environment variable " + name); + } + return value; + } + + @FunctionalInterface + private interface SqlWork { + void execute(Connection connection) throws Exception; + } + + private record Database( + String url, + String username, + String password) { + + Connection connect() throws SQLException { + return DriverManager.getConnection( + url, + username, + password); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2MigrationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2MigrationPostgresTest.java index 8e3c905f..f0edbb8b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2MigrationPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2MigrationPostgresTest.java @@ -87,6 +87,7 @@ class IdentityBindingV2MigrationPostgresTest { Flyway.configure() .dataSource(url, username, password) .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("45")) .load() .migrate(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2PostgresIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2PostgresIntegrationTest.java index 1065dd57..815d38c4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2PostgresIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingV2PostgresIntegrationTest.java @@ -32,6 +32,8 @@ class IdentityBindingV2PostgresIntegrationTest { private static final String CONCURRENT_SUBJECT = "900000000003"; + private static final String CONTRACT_CONCURRENT_SUBJECT = + "900000000004"; @Autowired private ExternalIdentityLoginService loginService; @@ -72,6 +74,13 @@ class IdentityBindingV2PostgresIntegrationTest { registry.add( "spring.flyway.enabled", () -> "true"); + String flywayTarget = System.getenv( + "IDENTITY_BINDING_V2_FLYWAY_TARGET"); + if (flywayTarget != null && !flywayTarget.isBlank()) { + registry.add( + "spring.flyway.target", + () -> flywayTarget); + } } @Test @@ -155,9 +164,20 @@ class IdentityBindingV2PostgresIntegrationTest { @Test void concurrentFirstLoginConvergesOnOneBinding() throws Exception { + assertConcurrentFirstLogin(CONCURRENT_SUBJECT); + } + + @Test + void concurrentFirstLoginConvergesAfterContractGate() + throws Exception { + assertConcurrentFirstLogin(CONTRACT_CONCURRENT_SUBJECT); + } + + private void assertConcurrentFirstLogin(String subject) + throws Exception { ResolvedProviderHandle provider = githubProvider(); ProviderAuthenticationResult result = - providerResult(CONCURRENT_SUBJECT); + providerResult(subject); CountDownLatch start = new CountDownLatch(1); List> futures = new ArrayList<>(); @@ -200,7 +220,7 @@ class IdentityBindingV2PostgresIntegrationTest { AND status = 'ACTIVE' """, Long.class, - CONCURRENT_SUBJECT)).isEqualTo(1L); + subject)).isEqualTo(1L); assertThat(jdbcTemplate.queryForObject( """ SELECT COUNT(*) @@ -212,7 +232,7 @@ class IdentityBindingV2PostgresIntegrationTest { AND is_primary = TRUE """, Long.class, - CONCURRENT_SUBJECT)).isEqualTo(1L); + subject)).isEqualTo(1L); } private IdentityLoginOutcome authenticate(String subject) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java index 21369016..2029103c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -102,6 +102,19 @@ class FlywayMigrationGuardrailTest { assertThat(migration).contains("bad_namespace.slug <> 'global'"); } + @Test + void identityBindingContractGate_mustRemainDeferred() throws IOException { + String migration = Files.readString( + migrationPath( + "V46__identity_binding_v2_contract_gate.sql")); + + assertThat(migration) + .contains("CREATE CONSTRAINT TRIGGER") + .contains("DEFERRABLE INITIALLY DEFERRED") + .contains("chk_identity_binding_active_primary") + .contains("Binding V2 contract preflight failed"); + } + private List migrationFiles() throws IOException { Path root = repoRoot() .resolve("server") From 636739492f38d53b5059ee894dbc7371dcd99868 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:12:44 +0800 Subject: [PATCH 2/3] fix(ci): make identity test Java detection pipe-safe Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- scripts/tests/identity-binding-v2-postgres-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/identity-binding-v2-postgres-test.sh b/scripts/tests/identity-binding-v2-postgres-test.sh index bf914dd8..5b55d517 100755 --- a/scripts/tests/identity-binding-v2-postgres-test.sh +++ b/scripts/tests/identity-binding-v2-postgres-test.sh @@ -48,7 +48,7 @@ run_test() { flyway_target="${2:-}" java_version="" if command -v java >/dev/null 2>&1; then - java_version="$(java -version 2>&1 | head -n 1)" + java_version="$(java -version 2>&1)" fi if [[ "${java_version}" == *'"21.'* ]]; then host_port="$(docker port "${POSTGRES_CONTAINER}" 5432/tcp \ From 32ee1d8108e1f835937b174c896fabfcb6c771c7 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:19:13 +0800 Subject: [PATCH 3/3] fix(ci): expose binding v2 test startup failures Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../identity-binding-v2-postgres-test.sh | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/tests/identity-binding-v2-postgres-test.sh b/scripts/tests/identity-binding-v2-postgres-test.sh index 5b55d517..4977880b 100755 --- a/scripts/tests/identity-binding-v2-postgres-test.sh +++ b/scripts/tests/identity-binding-v2-postgres-test.sh @@ -10,15 +10,29 @@ POSTGRES_PASSWORD="identity-v2-test-password" POSTGRES_DB="identity_v2" MAVEN_CACHE_DIR="${MAVEN_CACHE_DIR:-${HOME}/.m2}" +log() { + printf '[identity-binding-v2] %s\n' "$*" +} + cleanup() { + exit_code="$?" + if [[ "${exit_code}" -ne 0 ]]; then + log "failed with exit code ${exit_code}" + docker ps -a \ + --filter "label=skillhub.test.run=${RUN_ID}" \ + --format 'resource={{.Names}} status={{.Status}}' || true + docker logs "${POSTGRES_CONTAINER}" 2>&1 || true + fi docker rm -f "${POSTGRES_CONTAINER}" >/dev/null 2>&1 || true docker network rm "${NETWORK}" >/dev/null 2>&1 || true } trap cleanup EXIT +log "creating isolated Docker network ${NETWORK}" docker network create \ --label "skillhub.test.run=${RUN_ID}" \ "${NETWORK}" >/dev/null +log "starting isolated PostgreSQL container ${POSTGRES_CONTAINER}" docker run -d \ --name "${POSTGRES_CONTAINER}" \ --label "skillhub.test.run=${RUN_ID}" \ @@ -31,6 +45,7 @@ docker run -d \ -p 127.0.0.1::5432 \ postgres:16-alpine >/dev/null +log "waiting for PostgreSQL readiness" for _ in $(seq 1 60); do if docker exec "${POSTGRES_CONTAINER}" \ pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \ @@ -42,6 +57,7 @@ done docker exec "${POSTGRES_CONTAINER}" \ pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \ >/dev/null +log "PostgreSQL is ready" run_test() { test_class="$1" @@ -53,6 +69,11 @@ run_test() { if [[ "${java_version}" == *'"21.'* ]]; then host_port="$(docker port "${POSTGRES_CONTAINER}" 5432/tcp \ | sed -n 's/.*://p')" + if [[ -z "${host_port}" ]]; then + log "Docker did not publish a PostgreSQL host port" + return 1 + fi + log "running ${test_class} with host Java 21" ( cd "${REPO_ROOT}/server" IDENTITY_BINDING_V2_POSTGRES_URL="jdbc:postgresql://127.0.0.1:${host_port}/${POSTGRES_DB}" \ @@ -70,6 +91,7 @@ run_test() { return fi + log "running ${test_class} with containerized Java 21" mkdir -p "${MAVEN_CACHE_DIR}" docker run --rm \ --name "${RUN_ID}-java" \