fix(education): harden manual collection authoring

This commit is contained in:
2026-07-31 04:52:20 +08:00
parent 8861b6c8dc
commit de7fe04c0c
10 changed files with 85 additions and 12 deletions

View File

@@ -40,7 +40,7 @@ public class QuestionCollectionAuthoringController {
return success(service.archive(id, expectedAuthoringVersion, getLoginUserId())); return success(service.archive(id, expectedAuthoringVersion, getLoginUserId()));
} }
private QuestionCollectionAuthoringCommand toCommand(QuestionCollectionDraftReqVO r, Integer version) { 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); r.getDurationMinutes(), r.getAccessRules(), r.getSortOrder(), r.getMetadata(), version);
} }
} }

View File

@@ -8,7 +8,6 @@ public class QuestionCollectionDraftReqVO {
@NotNull private Long nodeId; @NotNull private Long nodeId;
@NotBlank @Size(max = 200) private String name; @NotBlank @Size(max = 200) private String name;
@Size(max = 200) private String title; @Size(max = 200) private String title;
@NotBlank @Size(max = 50) private String collectionType;
@Positive private Integer durationMinutes; @Positive private Integer durationMinutes;
private String accessRules; private String accessRules;
private Integer sortOrder; private Integer sortOrder;

View File

@@ -1,11 +1,12 @@
package cn.iocoder.yudao.module.education.controller.admin.collection.vo; package cn.iocoder.yudao.module.education.controller.admin.collection.vo;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data; import lombok.Data;
import java.util.List; import java.util.List;
@Data @Data
public class QuestionCollectionMembershipReqVO { public class QuestionCollectionMembershipReqVO {
@NotNull private Integer expectedAuthoringVersion; @NotNull private Integer expectedAuthoringVersion;
@NotNull private List<@NotNull Long> questionIds; @NotNull @Size(max = 1000) private List<@NotNull Long> questionIds;
} }

View File

@@ -123,4 +123,5 @@ public interface ErrorCodeConstants {
ErrorCode QUESTION_COLLECTION_NODE_UNAVAILABLE = new ErrorCode(1_005_002_033, "题集内容节点不存在或不可用"); 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_DUPLICATE = new ErrorCode(1_005_002_034, "题集成员包含重复题目");
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目"); ErrorCode QUESTION_COLLECTION_MEMBERSHIP_INVALID = new ErrorCode(1_005_002_035, "题集成员必须是当前租户已发布题目");
ErrorCode QUESTION_COLLECTION_MEMBERSHIP_TOO_LARGE = new ErrorCode(1_005_002_036, "题集成员数量不能超过 {}");
} }

View File

@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.education.service.collection.authoring; 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, Integer durationMinutes, String accessRules, Integer sortOrder,
String metadata, Integer expectedAuthoringVersion) { String metadata, Integer expectedAuthoringVersion) {
} }

View File

@@ -15,6 +15,7 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
@Service @Service
public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectionAuthoringService { public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectionAuthoringService {
private static final int MAX_MEMBERSHIP_SIZE = 1000;
private final EducationProperties properties; private final EducationProperties properties;
private final QuestionCollectionMapper collectionMapper; private final QuestionCollectionMapper collectionMapper;
private final QuestionCollectionQuestionMapper membershipMapper; private final QuestionCollectionQuestionMapper membershipMapper;
@@ -52,6 +53,7 @@ public class QuestionCollectionAuthoringServiceImpl implements QuestionCollectio
public int replaceMembership(Long id, List<Long> questionIds, int expectedVersion) { public int replaceMembership(Long id, List<Long> questionIds, int expectedVersion) {
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); requireDraft(tenantId, id, expectedVersion); assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); requireDraft(tenantId, id, expectedVersion);
List<Long> ids = questionIds != null ? questionIds : List.of(); List<Long> 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 (new HashSet<>(ids).size() != ids.size()) throw exception(QUESTION_COLLECTION_MEMBERSHIP_DUPLICATE);
if (!ids.isEmpty() && TenantUtils.executeIgnore(() -> questionMapper.countTenantOwnedPublishedByIds(tenantId, ids)) != ids.size()) { if (!ids.isEmpty() && TenantUtils.executeIgnore(() -> questionMapper.countTenantOwnedPublishedByIds(tenantId, ids)) != ids.size()) {
throw exception(QUESTION_COLLECTION_MEMBERSHIP_INVALID); 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; if (node == null) throw exception(QUESTION_COLLECTION_NODE_UNAVAILABLE); return node;
} }
private void apply(QuestionCollectionDO c, QuestionCollectionAuthoringCommand x) { 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()); c.setDurationMinutes(x.durationMinutes()); c.setAccessRules(x.accessRules()); c.setSortOrder(x.sortOrder() != null ? x.sortOrder() : 0); c.setMetadata(x.metadata());
} }
private void assertMode() { private void assertMode() {

View File

@@ -50,11 +50,12 @@ BEGIN
IF NEW.scope='PUBLIC' THEN 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 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; 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; RAISE EXCEPTION 'tenant collection must start empty DRAFT' USING ERRCODE='23514'; END IF;
RETURN NEW; RETURN NEW;
END IF; 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 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.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 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; 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 CREATE FUNCTION education_enforce_collection_membership_mutation() RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ 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 BEGIN
IF TG_OP='UPDATE' THEN RAISE EXCEPTION 'collection membership replacement requires physical delete and insert' USING ERRCODE='23514'; END IF; 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) 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 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 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; 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) 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 USING NEW.question_id; 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 THEN 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; RAISE EXCEPTION 'membership question must be current-tenant published' USING ERRCODE='23514'; END IF;
RETURN NEW; RETURN NEW;
END $$; END $$;
@@ -111,6 +112,20 @@ BEGIN
RETURN NEW; RETURN NEW;
END $$; 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 CREATE FUNCTION education_require_question_collection_audit() RETURNS TRIGGER
LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ DECLARE n INTEGER; BEGIN 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; 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_question_collection_authoring() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_enforce_collection_membership_mutation() 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_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_require_question_collection_audit() FROM PUBLIC;
REVOKE ALL ON FUNCTION education_prevent_question_collection_audit_mutation() 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_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_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_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 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(); 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 DO $$ DECLARE installed INTEGER; BEGIN

View File

@@ -46,7 +46,7 @@ class QuestionCollectionAuthoringControllerContractTest {
private QuestionCollectionDraftReqVO draft() { private QuestionCollectionDraftReqVO draft() {
QuestionCollectionDraftReqVO r = new QuestionCollectionDraftReqVO(); 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; return r;
} }
private QuestionCollectionMembershipReqVO membership() { private QuestionCollectionMembershipReqVO membership() {

View File

@@ -82,6 +82,14 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI
assertEquals(0L, collectionMapper.selectCount()); assertEquals(0L, collectionMapper.selectCount());
} }
@Test void oversizedMembershipFailsBeforePersistence() {
Long id = service.createDraft(command(null));
List<Long> 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() { @Test void otherTenantCannotManageCollection() {
Long id = service.createDraft(command(null)); Long id = service.createDraft(command(null));
TenantContextHolder.setTenantId(20L); TenantContextHolder.setTenantId(20L);
@@ -99,7 +107,7 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI
} }
private QuestionCollectionAuthoringCommand command(Integer version) { 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() { private Long createPublishedQuestion() {
Long id = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand( Long id = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand(

View File

@@ -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 @Test
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException { void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
String schema = createSchema("question_history"); String schema = createSchema("question_history");