From de7fe04c0c3519caa55339d9a9432df385b5a4f7 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 31 Jul 2026 04:52:20 +0800 Subject: [PATCH] fix(education): harden manual collection authoring --- ...QuestionCollectionAuthoringController.java | 2 +- .../vo/QuestionCollectionDraftReqVO.java | 1 - .../vo/QuestionCollectionMembershipReqVO.java | 3 +- .../education/enums/ErrorCodeConstants.java | 1 + .../QuestionCollectionAuthoringCommand.java | 2 +- ...uestionCollectionAuthoringServiceImpl.java | 4 +- .../V4110__add_manual_question_collection.sql | 27 ++++++++--- ...ectionAuthoringControllerContractTest.java | 2 +- ...ionAuthoringPostgreSqlIntegrationTest.java | 10 ++++- ...ucationFlywayMigrationIntegrationTest.java | 45 +++++++++++++++++++ 10 files changed, 85 insertions(+), 12 deletions(-) diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringController.java index ad7983df..964884ac 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringController.java @@ -40,7 +40,7 @@ public class QuestionCollectionAuthoringController { return success(service.archive(id, expectedAuthoringVersion, getLoginUserId())); } private QuestionCollectionAuthoringCommand toCommand(QuestionCollectionDraftReqVO r, Integer version) { - return new QuestionCollectionAuthoringCommand(r.getNodeId(), r.getName(), r.getTitle(), r.getCollectionType(), + return new QuestionCollectionAuthoringCommand(r.getNodeId(), r.getName(), r.getTitle(), r.getDurationMinutes(), r.getAccessRules(), r.getSortOrder(), r.getMetadata(), version); } } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionDraftReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionDraftReqVO.java index 9492871b..719346b1 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionDraftReqVO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionDraftReqVO.java @@ -8,7 +8,6 @@ public class QuestionCollectionDraftReqVO { @NotNull private Long nodeId; @NotBlank @Size(max = 200) private String name; @Size(max = 200) private String title; - @NotBlank @Size(max = 50) private String collectionType; @Positive private Integer durationMinutes; private String accessRules; private Integer sortOrder; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionMembershipReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionMembershipReqVO.java index 51d55d03..a6451d15 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionMembershipReqVO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/collection/vo/QuestionCollectionMembershipReqVO.java @@ -1,11 +1,12 @@ package cn.iocoder.yudao.module.education.controller.admin.collection.vo; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.Data; import java.util.List; @Data public class QuestionCollectionMembershipReqVO { @NotNull private Integer expectedAuthoringVersion; - @NotNull private List<@NotNull Long> questionIds; + @NotNull @Size(max = 1000) private List<@NotNull Long> questionIds; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 2d1e49a3..e0545763 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -123,4 +123,5 @@ public interface ErrorCodeConstants { ErrorCode QUESTION_COLLECTION_NODE_UNAVAILABLE = new ErrorCode(1_005_002_033, "题集内容节点不存在或不可用"); ErrorCode QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE = new ErrorCode(1_005_002_034, "题集成员包含重复题目"); ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目"); + ErrorCode QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE = new ErrorCode(1_005_002_036, "题集成员数量不能超过 {}"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringCommand.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringCommand.java index 0eb695a5..814d62e8 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringCommand.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringCommand.java @@ -1,6 +1,6 @@ package cn.iocoder.yudao.module.education.service.collection.authoring; -public record QuestionCollectionAuthoringCommand(Long nodeId, String name, String title, String collectionType, +public record QuestionCollectionAuthoringCommand(Long nodeId, String name, String title, Integer durationMinutes, String accessRules, Integer sortOrder, String metadata, Integer expectedAuthoringVersion) { } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringServiceImpl.java index a256c3c6..2fe2b60d 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringServiceImpl.java @@ -15,6 +15,7 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; @Service public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectionAuthoringService { + private static final int MAX_MEMBERSHIP_SIZE = 1000; private final EducationProperties properties; private final QuestionCollectionMapper collectionMapper; private final QuestionCollectionQuestionMapper membershipMapper; @@ -52,6 +53,7 @@ public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectio public int replaceMembership(Long id, List questionIds, int expectedVersion) { assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); requireDraft(tenantId, id, expectedVersion); List ids = questionIds != null ? questionIds : List.of(); + if (ids.size() > MAX_MEMBERSHIP_SIZE) throw exception(QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE, MAX_MEMBERSHIP_SIZE); if (new HashSet<>(ids).size() != ids.size()) throw exception(QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE); if (!ids.isEmpty() && TenantUtils.executeIgnore(() -> questionMapper.countTenantOwnedPublishedByIds(tenantId, ids)) != ids.size()) { throw exception(QUESTION_COLLECTION_MEMBERSHIP_INVALID); @@ -90,7 +92,7 @@ public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectio if (node == null) throw exception(QUESTION_COLLECTION_NODE_UNAVAILABLE); return node; } private void apply(QuestionCollectionDO c, QuestionCollectionAuthoringCommand x) { - c.setNodeId(x.nodeId()); c.setName(x.name()); c.setTitle(x.title()); c.setCollectionType(x.collectionType()); + c.setNodeId(x.nodeId()); c.setName(x.name()); c.setTitle(x.title()); c.setCollectionType("MANUAL"); c.setDurationMinutes(x.durationMinutes()); c.setAccessRules(x.accessRules()); c.setSortOrder(x.sortOrder() != null ? x.sortOrder() : 0); c.setMetadata(x.metadata()); } private void assertMode() { diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql b/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql index c4c59b54..efefd162 100644 --- a/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql +++ b/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql @@ -50,11 +50,12 @@ BEGIN 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 RAISE EXCEPTION 'PUBLIC collection writes are not tenant authoring' USING ERRCODE='23514'; END IF; - ELSIF NEW.scope='TENANT_OWNED' AND (NEW.publication_status<>'DRAFT' OR NEW.is_active OR NOT NEW.is_hidden OR NEW.authoring_version<>0 OR NEW.question_count<>0) THEN + ELSIF NEW.scope='TENANT_OWNED' AND (NEW.collection_type<>'MANUAL' OR NEW.publication_status<>'DRAFT' OR NEW.is_active OR NOT NEW.is_hidden OR NEW.authoring_version<>0 OR NEW.question_count<>0) THEN RAISE EXCEPTION 'tenant collection must start empty DRAFT' USING ERRCODE='23514'; END IF; RETURN NEW; END IF; IF NEW.scope='PUBLIC' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'PUBLIC collection writes are not tenant authoring' USING ERRCODE='23514'; END IF; + IF OLD.scope='TENANT_OWNED' AND NEW.collection_type IS DISTINCT FROM OLD.collection_type THEN RAISE EXCEPTION 'tenant collection type is immutable MANUAL' USING ERRCODE='23514'; END IF; IF OLD.scope='TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN RAISE EXCEPTION 'collection cannot be logically deleted' USING ERRCODE='23514'; END IF; IF OLD.publication_status='ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'archived collection is immutable' USING ERRCODE='23514'; END IF; IF NEW.authoring_version<>OLD.authoring_version+1 THEN RAISE EXCEPTION 'collection authoring version must advance exactly once' USING ERRCODE='23514'; END IF; @@ -78,7 +79,7 @@ END $$; CREATE FUNCTION education_enforce_collection_membership_mutation() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ -DECLARE c_status VARCHAR(16); c_version INTEGER; c_tenant BIGINT; c_scope VARCHAR(20); q_tenant BIGINT; q_scope VARCHAR(20); q_status VARCHAR(20); q_published BOOLEAN; token_count INTEGER; +DECLARE c_status VARCHAR(16); c_version INTEGER; c_tenant BIGINT; c_scope VARCHAR(20); q_tenant BIGINT; q_scope VARCHAR(20); q_status VARCHAR(20); q_published BOOLEAN; q_deleted BOOLEAN; token_count INTEGER; BEGIN IF TG_OP='UPDATE' THEN RAISE EXCEPTION 'collection membership replacement requires physical delete and insert' USING ERRCODE='23514'; END IF; EXECUTE format('SELECT tenant_id,scope,publication_status,authoring_version FROM %I.education_question_collection WHERE id=$1 FOR UPDATE',TG_TABLE_SCHEMA) @@ -89,9 +90,9 @@ BEGIN IF token_count<>1 THEN RAISE EXCEPTION 'collection membership mutation requires transaction token' USING ERRCODE='23514'; END IF; IF TG_OP='DELETE' THEN RETURN OLD; END IF; IF NEW.deleted OR NEW.tenant_id IS DISTINCT FROM c_tenant OR NEW.scope IS DISTINCT FROM c_scope THEN RAISE EXCEPTION 'membership ownership must equal collection' USING ERRCODE='23514'; END IF; - EXECUTE format('SELECT tenant_id,scope,status,is_published FROM %I.education_question WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA) - INTO q_tenant,q_scope,q_status,q_published USING NEW.question_id; - IF q_tenant IS DISTINCT FROM c_tenant OR q_scope IS DISTINCT FROM 'TENANT_OWNED' OR q_status IS DISTINCT FROM 'PUBLISHED' OR NOT q_published THEN + EXECUTE format('SELECT tenant_id,scope,status,is_published,deleted FROM %I.education_question WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA) + INTO q_tenant,q_scope,q_status,q_published,q_deleted USING NEW.question_id; + IF q_tenant IS DISTINCT FROM c_tenant OR q_scope IS DISTINCT FROM 'TENANT_OWNED' OR q_status IS DISTINCT FROM 'PUBLISHED' OR NOT q_published OR q_deleted THEN RAISE EXCEPTION 'membership question must be current-tenant published' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; @@ -111,6 +112,20 @@ BEGIN RETURN NEW; END $$; +CREATE FUNCTION education_require_question_collection_membership_count() RETURNS TRIGGER +LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ +DECLARE actual_count INTEGER; consumed INTEGER; +BEGIN + IF OLD.publication_status IS DISTINCT FROM 'DRAFT' OR NEW.publication_status IS DISTINCT FROM 'DRAFT' THEN RETURN NULL; END IF; + EXECUTE format('SELECT count(*) FROM %I.education_question_collection_question WHERE tenant_id=$1 AND collection_id=$2 AND deleted=false',TG_TABLE_SCHEMA) + INTO actual_count USING NEW.tenant_id,NEW.id; + IF actual_count IS DISTINCT FROM NEW.question_count THEN RAISE EXCEPTION 'collection question_count must equal physical membership' USING ERRCODE='23514'; END IF; + EXECUTE format('DELETE FROM %I.education_question_collection_membership_token WHERE transaction_id=$1 AND tenant_id=$2 AND collection_id=$3 AND authoring_version=$4',TG_TABLE_SCHEMA) + USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version; + GET DIAGNOSTICS consumed=ROW_COUNT; IF consumed<>1 THEN RAISE EXCEPTION 'collection membership transaction token required' USING ERRCODE='23514'; END IF; + RETURN NULL; +END $$; + CREATE FUNCTION education_require_question_collection_audit() RETURNS TRIGGER LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ DECLARE n INTEGER; BEGIN IF NEW.publication_status IS NOT DISTINCT FROM OLD.publication_status THEN RETURN NULL; END IF; @@ -122,12 +137,14 @@ CREATE FUNCTION education_prevent_question_collection_audit_mutation() RETURNS T REVOKE ALL ON FUNCTION education_enforce_question_collection_authoring() FROM PUBLIC; REVOKE ALL ON FUNCTION education_enforce_collection_membership_mutation() FROM PUBLIC; REVOKE ALL ON FUNCTION education_validate_question_collection_audit() FROM PUBLIC; +REVOKE ALL ON FUNCTION education_require_question_collection_membership_count() FROM PUBLIC; REVOKE ALL ON FUNCTION education_require_question_collection_audit() FROM PUBLIC; REVOKE ALL ON FUNCTION education_prevent_question_collection_audit_mutation() FROM PUBLIC; CREATE TRIGGER trg_education_question_collection_authoring BEFORE INSERT OR UPDATE ON education_question_collection FOR EACH ROW EXECUTE FUNCTION education_enforce_question_collection_authoring(); CREATE TRIGGER trg_education_collection_membership_mutation BEFORE INSERT OR UPDATE OR DELETE ON education_question_collection_question FOR EACH ROW EXECUTE FUNCTION education_enforce_collection_membership_mutation(); CREATE TRIGGER trg_education_question_collection_audit_validate BEFORE INSERT ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_validate_question_collection_audit(); CREATE TRIGGER trg_education_question_collection_audit_immutable BEFORE UPDATE OR DELETE ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_prevent_question_collection_audit_mutation(); +CREATE CONSTRAINT TRIGGER trg_education_question_collection_membership_count AFTER UPDATE OF question_count ON education_question_collection DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_question_collection_membership_count(); CREATE CONSTRAINT TRIGGER trg_education_question_collection_audit_required AFTER UPDATE OF publication_status ON education_question_collection DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_question_collection_audit(); DO $$ DECLARE installed INTEGER; BEGIN diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringControllerContractTest.java index 020ce453..09a09c73 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringControllerContractTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/collection/QuestionCollectionAuthoringControllerContractTest.java @@ -46,7 +46,7 @@ class QuestionCollectionAuthoringControllerContractTest { private QuestionCollectionDraftReqVO draft() { QuestionCollectionDraftReqVO r = new QuestionCollectionDraftReqVO(); - r.setNodeId(100L); r.setName("manual-set"); r.setTitle("Manual set"); r.setCollectionType("MANUAL"); + r.setNodeId(100L); r.setName("manual-set"); r.setTitle("Manual set"); return r; } private QuestionCollectionMembershipReqVO membership() { diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java index 8a1c07e4..965721fb 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java @@ -82,6 +82,14 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI assertEquals(0L, collectionMapper.selectCount()); } + @Test void oversizedMembershipFailsBeforePersistence() { + Long id = service.createDraft(command(null)); + List oversized = java.util.stream.LongStream.rangeClosed(1, 1001).boxed().toList(); + ServiceException tooLarge = assertThrows(ServiceException.class, () -> service.replaceMembership(id, oversized, 0)); + assertEquals(QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE.getCode(), tooLarge.getCode()); + assertEquals(0, collectionMapper.selectTenantOwnedById(10L, id).getAuthoringVersion()); + } + @Test void otherTenantCannotManageCollection() { Long id = service.createDraft(command(null)); TenantContextHolder.setTenantId(20L); @@ -99,7 +107,7 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI } private QuestionCollectionAuthoringCommand command(Integer version) { - return new QuestionCollectionAuthoringCommand(activeNodeId, "manual", "Manual", "MANUAL", 30, "{\"tier\":\"vip\"}", 0, null, version); + return new QuestionCollectionAuthoringCommand(activeNodeId, "manual", "Manual", 30, "{\"tier\":\"vip\"}", 0, null, version); } private Long createPublishedQuestion() { Long id = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand( 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 e0391afd..abf12078 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 @@ -675,6 +675,51 @@ class EducationFlywayMigrationIntegrationTest { } } + @Test + void shouldHardenManualCollectionLifecycleAndRuntimePrivileges() throws SQLException { + String schema = createSchema("collection_guards"); + configureFlyway(schema, false).load().migrate(); + assertThat(queryLong(schema, """ + SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace + WHERE n.nspname=current_schema() AND p.proname LIKE 'education_%question_collection%' + AND p.proconfig @> ARRAY['search_path=pg_catalog, pg_temp']::text[] + AND NOT has_function_privilege('public', p.oid, 'EXECUTE') + """)).isGreaterThanOrEqualTo(5L); + execute(schema, """ + INSERT INTO education_content_entry(id,tenant_id,scope,entry_key,name,entry_type) VALUES (100,0,'PUBLIC','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,0,'PUBLIC',100,'Node','category','ACTIVE',true,false); + INSERT INTO education_question_collection(id,tenant_id,scope,entry_id,node_id,name,collection_type,question_count,publication_status,is_active,is_hidden) + VALUES (300,10,'TENANT_OWNED',100,200,'Manual','MANUAL',0,'DRAFT',false,true); + """); + assertThatThrownBy(() -> execute(schema, "UPDATE education_question_collection SET publication_status='ACTIVE',is_active=true,is_hidden=false,authoring_version=1 WHERE id=300")) + .hasMessageContaining("collection lifecycle audit required"); + assertThatThrownBy(() -> execute(schema, "UPDATE education_question_collection SET collection_type='DYNAMIC',authoring_version=1 WHERE id=300")) + .hasMessageContaining("tenant collection type is immutable MANUAL"); + + String runtimeRole = "edu_collection_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, DELETE 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_question_collection_lifecycle_transition_token, " + schema + ".education_question_collection_membership_token FROM " + runtimeRole); + try (Connection connection = DriverManager.getConnection(jdbcUrl(schema), runtimeRole, "runtime_test"); var statement = connection.createStatement()) { + statement.execute(""" + DO $activate$ BEGIN + UPDATE education_question_collection SET publication_status='ACTIVE',is_active=true,is_hidden=false,authoring_version=1 WHERE id=300; + INSERT INTO education_question_collection_lifecycle_audit(tenant_id,collection_id,authoring_version,actor_id,from_status,to_status) VALUES(10,300,1,7,'DRAFT','ACTIVE'); + END $activate$; + """); + assertThatThrownBy(() -> statement.execute("INSERT INTO education_question_collection_lifecycle_transition_token VALUES(1,10,300,2,'ACTIVE','ARCHIVED')")) + .hasMessageContaining("permission denied"); + assertThatThrownBy(() -> statement.execute("UPDATE education_question_collection_lifecycle_audit SET actor_id=8 WHERE collection_id=300")) + .hasMessageContaining("append-only"); + } + } finally { + execute(schema, "DROP OWNED BY " + runtimeRole + "; DROP ROLE " + runtimeRole); + } + } + @Test void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException { String schema = createSchema("question_history");