forked from wangziqi/ruoyi-vue-pro
fix(education): harden content node lifecycle
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user