mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
Merge pull request #650 from iflytek/feature/identity-binding-contract-gate
feat(auth): enforce identity binding v2 contract gate
This commit is contained in:
commit
50a35f3aa9
6 changed files with 745 additions and 5 deletions
|
|
@ -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,21 +57,29 @@ done
|
|||
docker exec "${POSTGRES_CONTAINER}" \
|
||||
pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
|
||||
>/dev/null
|
||||
log "PostgreSQL is ready"
|
||||
|
||||
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)"
|
||||
java_version="$(java -version 2>&1)"
|
||||
fi
|
||||
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}" \
|
||||
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 \
|
||||
|
|
@ -68,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" \
|
||||
|
|
@ -81,6 +105,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 +120,10 @@ run_test() {
|
|||
}
|
||||
|
||||
run_test IdentityBindingV2MigrationPostgresTest
|
||||
run_test IdentityBindingV2PostgresIntegrationTest
|
||||
run_test \
|
||||
"IdentityBindingV2PostgresIntegrationTest#upgradesMixedVersionWriteAndPreservesLegacyReadColumn+concurrentFirstLoginConvergesOnOneBinding" \
|
||||
45
|
||||
run_test IdentityBindingV2ContractPostgresTest 46
|
||||
run_test \
|
||||
"IdentityBindingV2PostgresIntegrationTest#concurrentFirstLoginConvergesAfterContractGate" \
|
||||
46
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +91,7 @@ class IdentityBindingV2MigrationPostgresTest {
|
|||
Flyway.configure()
|
||||
.dataSource(url, username, password)
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("45"))
|
||||
.load()
|
||||
.migrate();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Future<IdentityLoginOutcome>> 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) {
|
||||
|
|
|
|||
|
|
@ -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<Path> migrationFiles() throws IOException {
|
||||
Path root = repoRoot()
|
||||
.resolve("server")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue