fix(education): enforce collection target integrity
This commit is contained in:
@@ -18,7 +18,10 @@ BEGIN
|
||||
WHERE collection.scope='TENANT_OWNED' AND collection.is_active AND NOT collection.is_hidden AND NOT collection.deleted
|
||||
AND (node.id IS NULL OR node.tenant_id IS DISTINCT FROM collection.tenant_id OR node.scope<>'TENANT_OWNED'
|
||||
OR node.deleted OR NOT node.is_active OR node.is_hidden
|
||||
OR entry.id IS NULL OR entry.deleted OR NOT entry.is_active OR entry.is_hidden
|
||||
OR collection.entry_id IS DISTINCT FROM node.entry_id
|
||||
OR entry.id IS NULL OR entry.scope NOT IN ('PUBLIC','TENANT_OWNED')
|
||||
OR (entry.scope='TENANT_OWNED' AND entry.tenant_id IS DISTINCT FROM collection.tenant_id)
|
||||
OR entry.deleted OR NOT entry.is_active OR entry.is_hidden
|
||||
OR (membership.id IS NOT NULL AND (question.id IS NULL OR question.tenant_id IS DISTINCT FROM collection.tenant_id
|
||||
OR question.scope<>'TENANT_OWNED' OR question.deleted OR question.status<>'PUBLISHED' OR NOT question.is_published)))
|
||||
ORDER BY collection.id,membership.question_id NULLS FIRST LIMIT 1;
|
||||
@@ -73,15 +76,31 @@ END $$;
|
||||
|
||||
CREATE FUNCTION education_enforce_question_collection_authoring() RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
DECLARE member RECORD;
|
||||
DECLARE member RECORD; target_node_tenant BIGINT; target_node_scope VARCHAR(20); target_node_entry BIGINT;
|
||||
target_node_deleted BOOLEAN; target_node_active BOOLEAN; target_node_hidden BOOLEAN;
|
||||
target_entry_tenant BIGINT; target_entry_scope VARCHAR(20); target_entry_deleted BOOLEAN;
|
||||
target_entry_active BOOLEAN; target_entry_hidden BOOLEAN;
|
||||
BEGIN
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.deleted THEN RAISE EXCEPTION 'collection cannot start 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
|
||||
RAISE EXCEPTION 'PUBLIC collection writes are not tenant authoring' USING ERRCODE='23514'; END IF;
|
||||
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.membership_version<>0 OR NEW.question_count<>0) THEN
|
||||
RAISE EXCEPTION 'tenant collection must start empty DRAFT' USING ERRCODE='23514'; END IF;
|
||||
ELSIF NEW.scope='TENANT_OWNED' THEN
|
||||
IF 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.membership_version<>0 OR NEW.question_count<>0 THEN
|
||||
RAISE EXCEPTION 'tenant collection must start empty DRAFT' USING ERRCODE='23514';
|
||||
END IF;
|
||||
EXECUTE format('SELECT node.tenant_id,node.scope,node.entry_id,node.deleted,node.is_active,node.is_hidden,entry.tenant_id,entry.scope,entry.deleted,entry.is_active,entry.is_hidden FROM %I.education_content_node node JOIN %I.education_content_entry entry ON entry.id=node.entry_id WHERE node.id=$1 FOR SHARE OF node,entry',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA)
|
||||
INTO target_node_tenant,target_node_scope,target_node_entry,target_node_deleted,target_node_active,target_node_hidden,
|
||||
target_entry_tenant,target_entry_scope,target_entry_deleted,target_entry_active,target_entry_hidden USING NEW.node_id;
|
||||
IF target_node_tenant IS DISTINCT FROM NEW.tenant_id OR target_node_scope IS DISTINCT FROM 'TENANT_OWNED'
|
||||
OR target_node_deleted OR NOT target_node_active OR target_node_hidden OR NEW.entry_id IS DISTINCT FROM target_node_entry
|
||||
OR target_entry_scope NOT IN ('PUBLIC','TENANT_OWNED')
|
||||
OR (target_entry_scope='TENANT_OWNED' AND target_entry_tenant IS DISTINCT FROM NEW.tenant_id)
|
||||
OR target_entry_deleted OR NOT target_entry_active OR target_entry_hidden THEN
|
||||
RAISE EXCEPTION 'tenant collection target node or entry is unavailable' USING ERRCODE='23514';
|
||||
END IF;
|
||||
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;
|
||||
@@ -94,6 +113,19 @@ BEGIN
|
||||
NEW.question_count IS DISTINCT FROM OLD.question_count OR NEW.membership_version IS DISTINCT FROM OLD.membership_version OR NEW.duration_minutes IS DISTINCT FROM OLD.duration_minutes OR
|
||||
NEW.access_rules IS DISTINCT FROM OLD.access_rules OR NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata) THEN
|
||||
RAISE EXCEPTION 'active collection content is immutable' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.publication_status='DRAFT' AND (NEW.node_id IS DISTINCT FROM OLD.node_id OR NEW.entry_id IS DISTINCT FROM OLD.entry_id
|
||||
OR NEW.publication_status IS DISTINCT FROM OLD.publication_status) THEN
|
||||
EXECUTE format('SELECT node.tenant_id,node.scope,node.entry_id,node.deleted,node.is_active,node.is_hidden,entry.tenant_id,entry.scope,entry.deleted,entry.is_active,entry.is_hidden FROM %I.education_content_node node JOIN %I.education_content_entry entry ON entry.id=node.entry_id WHERE node.id=$1 FOR SHARE OF node,entry',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA)
|
||||
INTO target_node_tenant,target_node_scope,target_node_entry,target_node_deleted,target_node_active,target_node_hidden,
|
||||
target_entry_tenant,target_entry_scope,target_entry_deleted,target_entry_active,target_entry_hidden USING NEW.node_id;
|
||||
IF target_node_tenant IS DISTINCT FROM NEW.tenant_id OR target_node_scope IS DISTINCT FROM 'TENANT_OWNED'
|
||||
OR target_node_deleted OR NOT target_node_active OR target_node_hidden OR NEW.entry_id IS DISTINCT FROM target_node_entry
|
||||
OR target_entry_scope NOT IN ('PUBLIC','TENANT_OWNED')
|
||||
OR (target_entry_scope='TENANT_OWNED' AND target_entry_tenant IS DISTINCT FROM NEW.tenant_id)
|
||||
OR target_entry_deleted OR NOT target_entry_active OR target_entry_hidden THEN
|
||||
RAISE EXCEPTION 'tenant collection target node or entry is unavailable' USING ERRCODE='23514';
|
||||
END IF;
|
||||
END IF;
|
||||
IF NEW.membership_version IS DISTINCT FROM OLD.membership_version THEN
|
||||
IF OLD.publication_status<>'DRAFT' OR NEW.publication_status<>'DRAFT' OR NEW.membership_version<>OLD.membership_version+1 THEN
|
||||
RAISE EXCEPTION 'collection membership version must advance exactly once in DRAFT' USING ERRCODE='23514';
|
||||
|
||||
@@ -675,6 +675,22 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailV4110ForHistoricalCollectionEntryDivergence() throws SQLException {
|
||||
String schema = createSchema("collection_entry_divergence");
|
||||
configureFlyway(schema, false).target("4100").load().migrate();
|
||||
execute(schema, """
|
||||
INSERT INTO education_content_entry(id,tenant_id,scope,entry_key,name,entry_type) VALUES
|
||||
(100,10,'TENANT_OWNED','one','One','question'),(101,10,'TENANT_OWNED','two','Two','question');
|
||||
INSERT INTO education_content_node(id,tenant_id,scope,entry_id,name,node_type,publication_status,authoring_version,is_active,is_hidden) VALUES(200,10,'TENANT_OWNED',100,'Node','category','DRAFT',0,false,true);
|
||||
UPDATE education_content_node SET publication_status='ACTIVE',authoring_version=1,is_active=true,is_hidden=false 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');
|
||||
INSERT INTO education_question_collection(id,tenant_id,scope,entry_id,node_id,name,collection_type,question_count,is_active,is_hidden) VALUES(400,10,'TENANT_OWNED',101,200,'Divergent','MANUAL',0,true,false);
|
||||
""");
|
||||
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
|
||||
.hasMessageContaining("invalid historical active collection 400 member 0 blocks V4110");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailV4110ForInvalidHistoricalActiveCollectionMembership() throws SQLException {
|
||||
String schema = createSchema("invalid_collection_history");
|
||||
@@ -706,10 +722,19 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
""")).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_content_node(id,tenant_id,scope,entry_id,name,node_type,publication_status,is_active,is_hidden) VALUES (200,0,'PUBLIC',100,'Public Node','category','ACTIVE',true,false);
|
||||
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,'Tenant Node','category','DRAFT',false,true);
|
||||
UPDATE education_content_node SET publication_status='ACTIVE',authoring_version=1,is_active=true,is_hidden=false 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');
|
||||
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);
|
||||
VALUES (300,10,'TENANT_OWNED',100,201,'Manual','MANUAL',0,'DRAFT',false,true);
|
||||
""");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
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 (301,10,'TENANT_OWNED',100,200,'Public target','MANUAL',0,'DRAFT',false,true)
|
||||
""")).hasMessageContaining("tenant collection target node or entry is unavailable");
|
||||
assertThatThrownBy(() -> execute(schema, "UPDATE education_question_collection SET node_id=200,authoring_version=1 WHERE id=300"))
|
||||
.hasMessageContaining("tenant collection target node or entry is unavailable");
|
||||
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");
|
||||
execute(schema, "UPDATE education_question_collection SET name='Metadata',authoring_version=1 WHERE id=300");
|
||||
|
||||
Reference in New Issue
Block a user