From f445c61f926461ee26c1052d773b6af52f7270dc Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 31 Jul 2026 01:36:04 +0800 Subject: [PATCH] fix(education): harden content node lifecycle --- docs/education/migration/issues/README.md | 2 +- .../dal/mysql/catalog/ContentNodeMapper.java | 18 +- .../ContentNodeAuthoringServiceImpl.java | 4 +- ...__add_content_node_authoring_lifecycle.sql | 59 +++++- ...ucationFlywayMigrationIntegrationTest.java | 170 +++++++++++++++++- 5 files changed, 236 insertions(+), 17 deletions(-) diff --git a/docs/education/migration/issues/README.md b/docs/education/migration/issues/README.md index 2839a821..509d4ba6 100644 --- a/docs/education/migration/issues/README.md +++ b/docs/education/migration/issues/README.md @@ -36,7 +36,7 @@ EDU-000 Phase 0 artifacts done └── EDU-005 PostgreSQL/Flyway takeover decision done EDU-009 + provider/content decisions - └── EDU-010 Tenant content publication in progress (graph integrity delivered; lifecycle remains) + └── EDU-010 Tenant content publication in progress (graph integrity and Content Node lifecycle delivered) └── EDU-011 Import/export/assets/scanning blocked EDU-004 diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java index 0481ecf8..74c1a3d3 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.education.dal.mysql.catalog; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -27,30 +28,34 @@ public interface ContentNodeMapper extends BaseMapperX { @Param("nodeId") Long nodeId); @Select(""" - SELECT COUNT(*) FROM education_content_entry + SELECT * FROM education_content_entry WHERE id = #{entryId} AND deleted = false AND is_active = true AND is_hidden = false AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED') OR (tenant_id = 0 AND scope = 'PUBLIC')) + FOR SHARE """) - int countAvailableEntry(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId); + ContentEntryDO selectAvailableEntryForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId); @Select(""" - SELECT COUNT(*) FROM education_content_node + SELECT * FROM education_content_node WHERE id = #{parentId} AND entry_id = #{entryId} AND deleted = false AND publication_status = 'ACTIVE' AND is_active = true AND is_hidden = false AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED') OR (tenant_id = 0 AND scope = 'PUBLIC')) + FOR SHARE """) - int countAvailableParent(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId, - @Param("parentId") Long parentId); + ContentNodeDO selectAvailableParentForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId, + @Param("parentId") Long parentId); default ContentNodeDO selectTenantOwnedById(Long tenantId, Long id) { return selectOne(new LambdaQueryWrapperX().eq(ContentNodeDO::getId, id) - .eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED")); + .eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED") + .eq(ContentNodeDO::getDeleted, false)); } default int updateDraftCas(Long tenantId, Long id, ContentNodeDO values, int expectedVersion) { return update(values, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() .eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId) .eq(ContentNodeDO::getScope, "TENANT_OWNED") + .eq(ContentNodeDO::getDeleted, false) .eq(ContentNodeDO::getPublicationStatus, "DRAFT") .eq(ContentNodeDO::getAuthoringVersion, expectedVersion) .set(ContentNodeDO::getAuthoringVersion, expectedVersion + 1)); @@ -61,6 +66,7 @@ public interface ContentNodeMapper extends BaseMapperX { return update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() .eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId) .eq(ContentNodeDO::getScope, "TENANT_OWNED") + .eq(ContentNodeDO::getDeleted, false) .eq(ContentNodeDO::getPublicationStatus, expectedStatus) .eq(ContentNodeDO::getAuthoringVersion, expectedVersion) .set(ContentNodeDO::getPublicationStatus, targetStatus) diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java index 9e911709..c2a0461d 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java @@ -96,9 +96,9 @@ public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringServ } private void validateStructure(Long tenantId, Long nodeId, Long entryId, Long parentId) { - if (entryId == null || TenantUtils.executeIgnore(() -> nodeMapper.countAvailableEntry(tenantId, entryId)) != 1 + if (entryId == null || TenantUtils.executeIgnore(() -> nodeMapper.selectAvailableEntryForShare(tenantId, entryId)) == null || (parentId != null && (parentId.equals(nodeId) - || TenantUtils.executeIgnore(() -> nodeMapper.countAvailableParent(tenantId, entryId, parentId)) != 1))) { + || TenantUtils.executeIgnore(() -> nodeMapper.selectAvailableParentForShare(tenantId, entryId, parentId)) == null))) { throw exception(CONTENT_NODE_STRUCTURE_INVALID); } } diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4100__add_content_node_authoring_lifecycle.sql b/yudao-module-education/src/main/resources/db/migration/education/V4100__add_content_node_authoring_lifecycle.sql index 63c47883..345092af 100644 --- a/yudao-module-education/src/main/resources/db/migration/education/V4100__add_content_node_authoring_lifecycle.sql +++ b/yudao-module-education/src/main/resources/db/migration/education/V4100__add_content_node_authoring_lifecycle.sql @@ -40,16 +40,34 @@ CREATE TABLE education_content_node_lifecycle_audit ( FOREIGN KEY (node_id) REFERENCES education_content_node(id), CONSTRAINT ck_education_content_node_lifecycle_audit_transition CHECK ( (from_status = 'DRAFT' AND to_status = 'ACTIVE') OR - (from_status = 'ACTIVE' AND to_status = 'ARCHIVED')) + (from_status = 'ACTIVE' AND to_status = 'ARCHIVED')), + CONSTRAINT uk_education_content_node_lifecycle_audit_version_transition + UNIQUE (tenant_id, node_id, authoring_version) ); COMMENT ON TABLE education_content_node_lifecycle_audit IS '教育-内容节点追加式生命周期审计'; CREATE INDEX idx_education_content_node_lifecycle_audit_node ON education_content_node_lifecycle_audit (tenant_id, node_id, id); +CREATE TABLE education_content_node_lifecycle_transition_token ( + transaction_id BIGINT NOT NULL, + tenant_id BIGINT NOT NULL, + node_id BIGINT NOT NULL, + authoring_version INTEGER NOT NULL, + from_status VARCHAR(16) NOT NULL, + to_status VARCHAR(16) NOT NULL, + PRIMARY KEY (transaction_id, tenant_id, node_id, authoring_version, from_status, to_status) +); +COMMENT ON TABLE education_content_node_lifecycle_transition_token IS + '内容节点状态触发器与审计触发器之间的事务内临时凭据;成功审计后即消费'; +REVOKE ALL ON TABLE education_content_node_lifecycle_transition_token FROM PUBLIC; + CREATE FUNCTION education_enforce_content_node_authoring() -RETURNS TRIGGER LANGUAGE plpgsql SET search_path = pg_catalog, pg_temp AS $$ +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ BEGIN IF TG_OP = 'INSERT' THEN + IF NEW.deleted THEN + RAISE EXCEPTION 'content node cannot start logically deleted' USING ERRCODE = '23514'; + END IF; IF NEW.scope = 'PUBLIC' THEN IF NEW.publication_status <> 'ACTIVE' OR NOT NEW.is_active OR NEW.is_hidden OR NEW.authoring_version <> 0 THEN @@ -65,6 +83,9 @@ BEGIN IF NEW.scope = 'PUBLIC' AND (NEW IS DISTINCT FROM OLD) THEN RAISE EXCEPTION 'PUBLIC content node writes are not managed by tenant authoring' USING ERRCODE = '23514'; END IF; + IF OLD.scope = 'TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN + RAISE EXCEPTION 'content node cannot be logically deleted' USING ERRCODE = '23514'; + END IF; IF OLD.publication_status = 'ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'archived content node is immutable' USING ERRCODE = '23514'; END IF; @@ -76,12 +97,22 @@ BEGIN (OLD.publication_status = 'ACTIVE' AND NEW.publication_status = 'ARCHIVED')) THEN RAISE EXCEPTION 'invalid content node lifecycle transition' USING ERRCODE = '23514'; END IF; + IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN + EXECUTE format( + 'INSERT INTO %I.education_content_node_lifecycle_transition_token' || + ' (transaction_id, tenant_id, node_id, authoring_version, from_status, to_status)' || + ' VALUES ($1, $2, $3, $4, $5, $6)', + TG_TABLE_SCHEMA) + USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.id, + NEW.authoring_version, OLD.publication_status, NEW.publication_status; + END IF; IF OLD.publication_status <> 'DRAFT' AND ( NEW.entry_id IS DISTINCT FROM OLD.entry_id OR NEW.parent_id IS DISTINCT FROM OLD.parent_id OR NEW.name IS DISTINCT FROM OLD.name OR NEW.title IS DISTINCT FROM OLD.title OR NEW.node_type IS DISTINCT FROM OLD.node_type OR NEW.marker_type IS DISTINCT FROM OLD.marker_type OR NEW.is_leaf IS DISTINCT FROM OLD.is_leaf OR NEW.is_selectable IS DISTINCT FROM OLD.is_selectable OR - NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata) THEN + NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata OR + NEW.deleted IS DISTINCT FROM OLD.deleted) THEN RAISE EXCEPTION 'active content node content is immutable' USING ERRCODE = '23514'; END IF; RETURN NEW; @@ -98,7 +129,7 @@ RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, DECLARE matching_count INTEGER; BEGIN IF NEW.publication_status IS NOT DISTINCT FROM OLD.publication_status THEN RETURN NULL; END IF; - EXECUTE format('SELECT count(*) FROM %I.education_content_node_lifecycle_audit WHERE tenant_id=$1 AND node_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5', TG_TABLE_SCHEMA) + EXECUTE format('SELECT count(*) FROM %I.education_content_node_lifecycle_audit WHERE tenant_id=$1 AND node_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5 AND deleted=false', TG_TABLE_SCHEMA) INTO matching_count USING NEW.tenant_id, NEW.id, NEW.authoring_version, OLD.publication_status, NEW.publication_status; IF matching_count <> 1 THEN RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514'; END IF; RETURN NULL; @@ -106,13 +137,27 @@ END $$; CREATE FUNCTION education_validate_content_node_lifecycle_audit() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ -DECLARE node_tenant BIGINT; node_version INTEGER; node_status VARCHAR(16); +DECLARE node_tenant BIGINT; node_version INTEGER; node_status VARCHAR(16); transition_token_count INTEGER; BEGIN - EXECUTE format('SELECT tenant_id, authoring_version, publication_status FROM %I.education_content_node WHERE id=$1', TG_TABLE_SCHEMA) + IF NEW.deleted THEN + RAISE EXCEPTION 'content node lifecycle audit cannot be logically deleted' USING ERRCODE = '23514'; + END IF; + EXECUTE format('SELECT tenant_id, authoring_version, publication_status FROM %I.education_content_node WHERE id=$1 FOR SHARE', TG_TABLE_SCHEMA) INTO node_tenant, node_version, node_status USING NEW.node_id; IF node_tenant IS DISTINCT FROM NEW.tenant_id OR node_version IS DISTINCT FROM NEW.authoring_version OR node_status IS DISTINCT FROM NEW.to_status THEN - RAISE EXCEPTION 'content node lifecycle audit must match current transition' USING ERRCODE = '23514'; + RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514'; + END IF; + EXECUTE format( + 'DELETE FROM %I.education_content_node_lifecycle_transition_token' || + ' WHERE transaction_id=$1 AND tenant_id=$2 AND node_id=$3 AND authoring_version=$4' || + ' AND from_status=$5 AND to_status=$6', + TG_TABLE_SCHEMA) + USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.node_id, + NEW.authoring_version, NEW.from_status, NEW.to_status; + GET DIAGNOSTICS transition_token_count = ROW_COUNT; + IF transition_token_count <> 1 THEN + RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514'; END IF; RETURN NEW; END $$; diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java index 10a0b128..b8a42984 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java @@ -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'