fix(education): harden content node lifecycle

This commit is contained in:
2026-07-31 01:36:04 +08:00
parent 62bbdd4b87
commit f445c61f92
5 changed files with 236 additions and 17 deletions

View File

@@ -495,15 +495,129 @@ class EducationFlywayMigrationIntegrationTest {
INSERT INTO education_content_entry
(id, tenant_id, scope, entry_key, name, entry_type)
VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question');
""");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type,
publication_status, is_active, is_hidden, deleted)
VALUES (199, 10, 'TENANT_OWNED', 100, 'Deleted draft', 'category',
'DRAFT', false, true, true);
""")).hasMessageContaining("content node cannot start logically deleted");
execute(schema, """
INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type, publication_status, is_active, is_hidden)
VALUES (200, 10, 'TENANT_OWNED', 100, 'Draft', 'category', 'DRAFT', false, true);
""");
execute(schema, """
DO $activate$
BEGIN
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
authoring_version = 1
WHERE id = 200;
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 200, 1, 7, 'DRAFT', 'ACTIVE');
END
$activate$;
""");
assertThat(queryLong(schema, """
SELECT COUNT(*)
FROM pg_constraint constraint_definition
JOIN pg_class audit_table ON audit_table.oid = constraint_definition.conrelid
JOIN pg_namespace audit_schema ON audit_schema.oid = audit_table.relnamespace
WHERE audit_schema.nspname = current_schema()
AND audit_table.relname = 'education_content_node_lifecycle_audit'
AND constraint_definition.conname =
'uk_education_content_node_lifecycle_audit_version_transition'
AND constraint_definition.contype = 'u'
""")).isEqualTo(1L);
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 200, 1, 8, 'DRAFT', 'ACTIVE');
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
execute(schema, """
INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type, publication_status, is_active, is_hidden)
VALUES (201, 10, 'TENANT_OWNED', 100, 'Deleted audit draft', 'category', 'DRAFT', false, true);
""");
assertThatThrownBy(() -> execute(schema, """
DO $activate_deleted$
BEGIN
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
deleted = true, authoring_version = 1
WHERE id = 201;
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 201, 1, 7, 'DRAFT', 'ACTIVE');
END
$activate_deleted$;
""")).hasMessageContaining("content node cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false, authoring_version = 1
SET deleted = true, authoring_version = 1
WHERE id = 201;
""")).hasMessageContaining("content node cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
DO $deleted_audit$
BEGIN
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
authoring_version = 1
WHERE id = 201;
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status, deleted)
VALUES (10, 201, 1, 7, 'DRAFT', 'ACTIVE', true);
END
$deleted_audit$;
""")).hasMessageContaining("content node lifecycle audit cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET publication_status = 'ARCHIVED', is_active = false, is_hidden = true,
authoring_version = 2
WHERE id = 200;
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 200, 2, 8, 'ACTIVE', 'ARCHIVED');
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET deleted = true, authoring_version = 2
WHERE id = 200;
""")).hasMessageContaining("content node cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET publication_status = 'ARCHIVED', is_active = false, is_hidden = true,
authoring_version = 2
WHERE id = 200;
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
execute(schema, """
DO $archive$
BEGIN
UPDATE education_content_node
SET publication_status = 'ARCHIVED', is_active = false, is_hidden = true,
authoring_version = 2
WHERE id = 200;
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 200, 2, 7, 'ACTIVE', 'ARCHIVED');
END
$archive$;
""");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET deleted = true, authoring_version = 3
WHERE id = 200;
""")).hasMessageContaining("content node cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 200, 3, 8, 'ACTIVE', 'ARCHIVED');
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node
(tenant_id, scope, entry_id, name, node_type, publication_status, is_active)
@@ -511,6 +625,56 @@ class EducationFlywayMigrationIntegrationTest {
""")).hasMessageContaining("PUBLIC content node writes are not managed by tenant authoring");
}
@Test
void shouldAllowRuntimeRoleTransitionsWithoutTokenTablePrivileges() throws SQLException {
String schema = createSchema("content_node_runtime_role");
configureFlyway(schema, false).load().migrate();
String runtimeRole = "edu_runtime_" + UUID.randomUUID().toString().replace("-", "");
try {
execute(schema, "CREATE ROLE " + runtimeRole + " LOGIN PASSWORD 'runtime_test'");
execute(schema, "GRANT USAGE ON SCHEMA " + schema + " TO " + runtimeRole);
execute(schema, "GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA " + schema + " TO " + runtimeRole);
execute(schema, "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA " + schema + " TO " + runtimeRole);
execute(schema, "REVOKE ALL ON " + schema
+ ".education_content_node_lifecycle_transition_token FROM " + runtimeRole);
execute(schema, """
INSERT INTO education_content_entry
(id, tenant_id, scope, entry_key, name, entry_type)
VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question');
INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type,
publication_status, is_active, is_hidden)
VALUES (200, 10, 'TENANT_OWNED', 100, 'Draft', 'category',
'DRAFT', false, true);
""");
try (Connection connection = DriverManager.getConnection(
jdbcUrl(schema), runtimeRole, "runtime_test");
var statement = connection.createStatement()) {
statement.execute("""
DO $activate$
BEGIN
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
authoring_version = 1
WHERE id = 200;
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 200, 1, 7, 'DRAFT', 'ACTIVE');
END
$activate$;
""");
assertThatThrownBy(() -> statement.execute("""
INSERT INTO education_content_node_lifecycle_transition_token
(transaction_id, tenant_id, node_id, authoring_version, from_status, to_status)
VALUES (1, 10, 200, 2, 'ACTIVE', 'ARCHIVED');
""")).hasMessageContaining("permission denied");
}
} finally {
execute(schema, "DROP OWNED BY " + runtimeRole + "; DROP ROLE " + runtimeRole);
}
}
@Test
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
String schema = createSchema("question_history");
@@ -749,6 +913,10 @@ class EducationFlywayMigrationIntegrationTest {
'education_require_question_lifecycle_audit',
'education_check_question_version_ownership',
'education_validate_question_lifecycle_audit_insert',
'education_enforce_content_node_authoring',
'education_prevent_content_node_audit_mutation',
'education_require_content_node_lifecycle_audit',
'education_validate_content_node_lifecycle_audit',
'education_enforce_question_placement_mutation',
'education_question_answer_key_is_valid')
AND grantee = 'PUBLIC'