feat(education): enforce catalog graph scope
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
CREATE FUNCTION education_check_reference_scope()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
reference_id BIGINT;
|
||||
reference_tenant_id BIGINT;
|
||||
reference_scope VARCHAR(20);
|
||||
reference_count INTEGER;
|
||||
BEGIN
|
||||
reference_id := (to_jsonb(NEW) ->> TG_ARGV[0])::BIGINT;
|
||||
IF reference_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
EXECUTE format('SELECT tenant_id, scope FROM %I.%I WHERE id = $1 FOR SHARE',
|
||||
TG_TABLE_SCHEMA, TG_ARGV[1])
|
||||
INTO reference_tenant_id, reference_scope
|
||||
USING reference_id;
|
||||
GET DIAGNOSTICS reference_count = ROW_COUNT;
|
||||
|
||||
IF reference_count = 0 THEN
|
||||
RAISE EXCEPTION 'catalog reference %.% points to missing %.id %',
|
||||
TG_TABLE_NAME, TG_ARGV[0], TG_ARGV[1], reference_id
|
||||
USING ERRCODE = '23503';
|
||||
END IF;
|
||||
|
||||
IF NEW.scope = 'PUBLIC' THEN
|
||||
IF NEW.tenant_id <> 0 OR reference_scope <> 'PUBLIC' OR reference_tenant_id <> 0 THEN
|
||||
RAISE EXCEPTION 'PUBLIC catalog row %.% may only reference PUBLIC parents',
|
||||
TG_TABLE_NAME, TG_ARGV[0]
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSIF NEW.scope = 'TENANT_OWNED' THEN
|
||||
IF NEW.tenant_id <= 0 OR NOT (
|
||||
(reference_scope = 'PUBLIC' AND reference_tenant_id = 0) OR
|
||||
(reference_scope = 'TENANT_OWNED' AND reference_tenant_id = NEW.tenant_id)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'cross-tenant catalog reference rejected at %.%',
|
||||
TG_TABLE_NAME, TG_ARGV[0]
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'unsupported catalog scope % at %.%',
|
||||
NEW.scope, TG_TABLE_NAME, TG_ARGV[0]
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION education_check_reference_scope() IS
|
||||
'Rejects PUBLIC-to-tenant and cross-tenant references in the Education catalog graph';
|
||||
|
||||
CREATE FUNCTION education_prevent_catalog_scope_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
|
||||
OR NEW.scope IS DISTINCT FROM OLD.scope THEN
|
||||
RAISE EXCEPTION 'catalog ownership scope is immutable for %', TG_TABLE_NAME
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION education_prevent_catalog_scope_change() IS
|
||||
'Makes Education catalog tenant ownership and PUBLIC/TENANT_OWNED scope immutable after insert';
|
||||
|
||||
REVOKE ALL ON FUNCTION education_check_reference_scope() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_prevent_catalog_scope_change() FROM PUBLIC;
|
||||
|
||||
CREATE TRIGGER trg_education_school_region_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, region_id ON education_school
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('region_id', 'education_region');
|
||||
|
||||
CREATE TRIGGER trg_education_major_region_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, region_id ON education_major
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('region_id', 'education_region');
|
||||
CREATE TRIGGER trg_education_major_school_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, school_id ON education_major
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('school_id', 'education_school');
|
||||
|
||||
CREATE TRIGGER trg_education_subject_region_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, region_id ON education_subject
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('region_id', 'education_region');
|
||||
CREATE TRIGGER trg_education_subject_school_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, school_id ON education_subject
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('school_id', 'education_school');
|
||||
CREATE TRIGGER trg_education_subject_major_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, major_id ON education_subject
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('major_id', 'education_major');
|
||||
|
||||
CREATE TRIGGER trg_education_category_subject_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, subject_id ON education_category
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('subject_id', 'education_subject');
|
||||
|
||||
CREATE TRIGGER trg_education_content_entry_region_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, region_id ON education_content_entry
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('region_id', 'education_region');
|
||||
|
||||
CREATE TRIGGER trg_education_content_node_entry_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, entry_id ON education_content_node
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('entry_id', 'education_content_entry');
|
||||
CREATE TRIGGER trg_education_content_node_parent_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, parent_id ON education_content_node
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('parent_id', 'education_content_node');
|
||||
|
||||
CREATE TRIGGER trg_education_question_collection_entry_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, entry_id ON education_question_collection
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('entry_id', 'education_content_entry');
|
||||
CREATE TRIGGER trg_education_question_collection_node_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, node_id ON education_question_collection
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('node_id', 'education_content_node');
|
||||
|
||||
CREATE TRIGGER trg_education_question_subject_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, subject_id ON education_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('subject_id', 'education_subject');
|
||||
CREATE TRIGGER trg_education_question_node_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, node_id ON education_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('node_id', 'education_content_node');
|
||||
|
||||
CREATE TRIGGER trg_education_practice_blueprint_entry_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, entry_id ON education_practice_blueprint
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('entry_id', 'education_content_entry');
|
||||
CREATE TRIGGER trg_education_practice_blueprint_node_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, node_id ON education_practice_blueprint
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('node_id', 'education_content_node');
|
||||
CREATE TRIGGER trg_education_practice_blueprint_collection_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, collection_id ON education_practice_blueprint
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('collection_id', 'education_question_collection');
|
||||
|
||||
CREATE TRIGGER trg_education_qcq_collection_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, collection_id ON education_question_collection_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('collection_id', 'education_question_collection');
|
||||
CREATE TRIGGER trg_education_qcq_question_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, question_id ON education_question_collection_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('question_id', 'education_question');
|
||||
|
||||
CREATE TRIGGER trg_education_region_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_region
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_school_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_school
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_major_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_major
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_subject_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_subject
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_category_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_category
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_content_entry_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_content_entry
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_content_node_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_content_node
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_question_collection_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_question_collection
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_question_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_practice_blueprint_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_practice_blueprint
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_qcq_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_question_collection_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
|
||||
-- CREATE TRIGGER takes a SHARE ROW EXCLUSIVE lock on each protected table. Run
|
||||
-- historical validation only after all guards exist so concurrent writes cannot
|
||||
-- slip an invalid edge between validation and trigger installation.
|
||||
DO $$
|
||||
DECLARE
|
||||
catalog_schema TEXT := current_schema();
|
||||
edge RECORD;
|
||||
violation_exists BOOLEAN;
|
||||
BEGIN
|
||||
FOR edge IN
|
||||
SELECT *
|
||||
FROM (VALUES
|
||||
('education_school', 'region_id', 'education_region'),
|
||||
('education_major', 'region_id', 'education_region'),
|
||||
('education_major', 'school_id', 'education_school'),
|
||||
('education_subject', 'region_id', 'education_region'),
|
||||
('education_subject', 'school_id', 'education_school'),
|
||||
('education_subject', 'major_id', 'education_major'),
|
||||
('education_category', 'subject_id', 'education_subject'),
|
||||
('education_content_entry', 'region_id', 'education_region'),
|
||||
('education_content_node', 'entry_id', 'education_content_entry'),
|
||||
('education_content_node', 'parent_id', 'education_content_node'),
|
||||
('education_question_collection', 'entry_id', 'education_content_entry'),
|
||||
('education_question_collection', 'node_id', 'education_content_node'),
|
||||
('education_question', 'subject_id', 'education_subject'),
|
||||
('education_question', 'node_id', 'education_content_node'),
|
||||
('education_practice_blueprint', 'entry_id', 'education_content_entry'),
|
||||
('education_practice_blueprint', 'node_id', 'education_content_node'),
|
||||
('education_practice_blueprint', 'collection_id', 'education_question_collection'),
|
||||
('education_question_collection_question', 'collection_id', 'education_question_collection'),
|
||||
('education_question_collection_question', 'question_id', 'education_question')
|
||||
) AS catalog_edge(child_table, reference_column, parent_table)
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'SELECT EXISTS (' ||
|
||||
'SELECT 1 FROM %1$I.%2$I child ' ||
|
||||
'JOIN %1$I.%3$I parent ON parent.id = child.%4$I ' ||
|
||||
'WHERE child.%4$I IS NOT NULL AND NOT (' ||
|
||||
' (child.scope = ''PUBLIC'' AND child.tenant_id = 0 ' ||
|
||||
' AND parent.scope = ''PUBLIC'' AND parent.tenant_id = 0) OR ' ||
|
||||
' (child.scope = ''TENANT_OWNED'' AND child.tenant_id > 0 AND (' ||
|
||||
' (parent.scope = ''PUBLIC'' AND parent.tenant_id = 0) OR ' ||
|
||||
' (parent.scope = ''TENANT_OWNED'' AND parent.tenant_id = child.tenant_id)' ||
|
||||
' ))' ||
|
||||
'))',
|
||||
catalog_schema, edge.child_table, edge.parent_table, edge.reference_column)
|
||||
INTO violation_exists;
|
||||
|
||||
IF violation_exists THEN
|
||||
RAISE EXCEPTION 'existing catalog graph violates scope rules at %.%',
|
||||
edge.child_table, edge.reference_column
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Verification examples:
|
||||
-- SELECT tgname FROM pg_trigger
|
||||
-- WHERE tgname LIKE 'trg_education_%_reference_scope' AND NOT tgisinternal ORDER BY tgname;
|
||||
-- SELECT conname FROM pg_constraint
|
||||
-- WHERE conname LIKE 'uk_education_%_tenant_id_id' ORDER BY conname;
|
||||
@@ -108,6 +108,11 @@ class QuestionControllerHttpTest {
|
||||
.type("choice")
|
||||
.difficulty("easy")
|
||||
.isPublished(true)
|
||||
.options(List.of(
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.QuestionOptionDTO
|
||||
.builder().label("A").content("Answer A").build(),
|
||||
cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO.QuestionOptionDTO
|
||||
.builder().label("B").content("Answer B").build()))
|
||||
.build();
|
||||
when(provider.getQuestion("q1")).thenReturn(dto);
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060");
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070");
|
||||
assertThat(queryLong(schema,
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
|
||||
"AND table_name = 'education_idempotency'"))
|
||||
@@ -181,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060");
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070");
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT table_name FROM information_schema.tables " +
|
||||
"WHERE table_schema = current_schema() AND table_name IN (" +
|
||||
@@ -230,6 +230,181 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
.isEqualTo(8L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEnforcePublicAndTenantCatalogGraphRules() throws SQLException {
|
||||
String schema = createSchema("catalog_graph");
|
||||
configureFlyway(schema, false).load().migrate();
|
||||
|
||||
execute(schema, """
|
||||
INSERT INTO education_region (id, tenant_id, scope, name)
|
||||
VALUES (100, 0, 'PUBLIC', 'Public region'),
|
||||
(101, 10, 'TENANT_OWNED', 'Tenant 10 region'),
|
||||
(102, 20, 'TENANT_OWNED', 'Tenant 20 region');
|
||||
|
||||
INSERT INTO education_school (tenant_id, scope, region_id, name)
|
||||
VALUES (0, 'PUBLIC', 100, 'Public school'),
|
||||
(10, 'TENANT_OWNED', 100, 'Tenant school using public region'),
|
||||
(10, 'TENANT_OWNED', 101, 'Tenant school using own region');
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
INSERT INTO education_school (tenant_id, scope, region_id, name)
|
||||
VALUES (0, 'PUBLIC', 101, 'Invalid public school');
|
||||
"""))
|
||||
.hasMessageContaining("PUBLIC catalog row")
|
||||
.hasMessageContaining("education_school.region_id");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
INSERT INTO education_school (tenant_id, scope, region_id, name)
|
||||
VALUES (10, 'TENANT_OWNED', 102, 'Cross-tenant school');
|
||||
"""))
|
||||
.hasMessageContaining("cross-tenant catalog reference")
|
||||
.hasMessageContaining("education_school.region_id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAttachGraphGuardToEveryCatalogReference() throws SQLException {
|
||||
String schema = createSchema("catalog_triggers");
|
||||
configureFlyway(schema, false).load().migrate();
|
||||
|
||||
assertThat(queryLong(schema, """
|
||||
WITH expected(table_name, trigger_name, trigger_arguments) AS (VALUES
|
||||
('education_school', 'trg_education_school_region_reference_scope',
|
||||
'region_id|education_region|'),
|
||||
('education_major', 'trg_education_major_region_reference_scope',
|
||||
'region_id|education_region|'),
|
||||
('education_major', 'trg_education_major_school_reference_scope',
|
||||
'school_id|education_school|'),
|
||||
('education_subject', 'trg_education_subject_region_reference_scope',
|
||||
'region_id|education_region|'),
|
||||
('education_subject', 'trg_education_subject_school_reference_scope',
|
||||
'school_id|education_school|'),
|
||||
('education_subject', 'trg_education_subject_major_reference_scope',
|
||||
'major_id|education_major|'),
|
||||
('education_category', 'trg_education_category_subject_reference_scope',
|
||||
'subject_id|education_subject|'),
|
||||
('education_content_entry', 'trg_education_content_entry_region_reference_scope',
|
||||
'region_id|education_region|'),
|
||||
('education_content_node', 'trg_education_content_node_entry_reference_scope',
|
||||
'entry_id|education_content_entry|'),
|
||||
('education_content_node', 'trg_education_content_node_parent_reference_scope',
|
||||
'parent_id|education_content_node|'),
|
||||
('education_question_collection', 'trg_education_question_collection_entry_reference_scope',
|
||||
'entry_id|education_content_entry|'),
|
||||
('education_question_collection', 'trg_education_question_collection_node_reference_scope',
|
||||
'node_id|education_content_node|'),
|
||||
('education_question', 'trg_education_question_subject_reference_scope',
|
||||
'subject_id|education_subject|'),
|
||||
('education_question', 'trg_education_question_node_reference_scope',
|
||||
'node_id|education_content_node|'),
|
||||
('education_practice_blueprint', 'trg_education_practice_blueprint_entry_reference_scope',
|
||||
'entry_id|education_content_entry|'),
|
||||
('education_practice_blueprint', 'trg_education_practice_blueprint_node_reference_scope',
|
||||
'node_id|education_content_node|'),
|
||||
('education_practice_blueprint', 'trg_education_practice_blueprint_collection_reference_scope',
|
||||
'collection_id|education_question_collection|'),
|
||||
('education_question_collection_question', 'trg_education_qcq_collection_reference_scope',
|
||||
'collection_id|education_question_collection|'),
|
||||
('education_question_collection_question', 'trg_education_qcq_question_reference_scope',
|
||||
'question_id|education_question|')
|
||||
)
|
||||
SELECT COUNT(*)
|
||||
FROM expected
|
||||
JOIN pg_namespace catalog_schema ON catalog_schema.nspname = current_schema()
|
||||
JOIN pg_class catalog_table
|
||||
ON catalog_table.relnamespace = catalog_schema.oid
|
||||
AND catalog_table.relname = expected.table_name
|
||||
JOIN pg_trigger trigger
|
||||
ON trigger.tgrelid = catalog_table.oid
|
||||
AND trigger.tgname = expected.trigger_name
|
||||
AND NOT trigger.tgisinternal
|
||||
JOIN pg_proc trigger_function
|
||||
ON trigger_function.oid = trigger.tgfoid
|
||||
AND trigger_function.pronamespace = catalog_schema.oid
|
||||
AND trigger_function.proname = 'education_check_reference_scope'
|
||||
WHERE replace(encode(trigger.tgargs, 'escape'), $$\\000$$, '|') =
|
||||
expected.trigger_arguments
|
||||
"""))
|
||||
.isEqualTo(19L);
|
||||
assertThat(queryLong(schema, """
|
||||
SELECT COUNT(*)
|
||||
FROM pg_trigger trigger
|
||||
JOIN pg_class catalog_table ON catalog_table.oid = trigger.tgrelid
|
||||
JOIN pg_namespace catalog_schema ON catalog_schema.oid = catalog_table.relnamespace
|
||||
WHERE catalog_schema.nspname = current_schema()
|
||||
AND trigger.tgname LIKE 'trg_education_%_scope_immutable'
|
||||
AND NOT trigger.tgisinternal
|
||||
"""))
|
||||
.isEqualTo(11L);
|
||||
assertThat(queryLong(schema, """
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.routine_privileges
|
||||
WHERE specific_schema = current_schema()
|
||||
AND routine_name IN (
|
||||
'education_check_reference_scope',
|
||||
'education_prevent_catalog_scope_change')
|
||||
AND grantee = 'PUBLIC'
|
||||
AND privilege_type = 'EXECUTE'
|
||||
"""))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectParentScopeChangeThatWouldInvalidateExistingGraph() throws SQLException {
|
||||
String schema = createSchema("catalog_parent_update");
|
||||
configureFlyway(schema, false).load().migrate();
|
||||
execute(schema, """
|
||||
INSERT INTO education_region (id, tenant_id, scope, name)
|
||||
VALUES (100, 0, 'PUBLIC', 'Public region');
|
||||
INSERT INTO education_school (tenant_id, scope, region_id, name)
|
||||
VALUES (0, 'PUBLIC', 100, 'Public school');
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_region
|
||||
SET tenant_id = 10, scope = 'TENANT_OWNED'
|
||||
WHERE id = 100;
|
||||
"""))
|
||||
.hasMessageContaining("catalog ownership scope is immutable")
|
||||
.hasMessageContaining("education_region");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepCatalogOwnershipScopeImmutable() throws SQLException {
|
||||
String schema = createSchema("catalog_scope_immutable");
|
||||
configureFlyway(schema, false).load().migrate();
|
||||
execute(schema, """
|
||||
INSERT INTO education_region (id, tenant_id, scope, name)
|
||||
VALUES (100, 10, 'TENANT_OWNED', 'Tenant region');
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_region
|
||||
SET tenant_id = 20
|
||||
WHERE id = 100;
|
||||
"""))
|
||||
.hasMessageContaining("catalog ownership scope is immutable")
|
||||
.hasMessageContaining("education_region");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenExistingCatalogGraphViolatesScopeRules() throws SQLException {
|
||||
String schema = createSchema("catalog_prevalidation");
|
||||
configureFlyway(schema, false).target("4060").load().migrate();
|
||||
execute(schema, """
|
||||
INSERT INTO education_region (id, tenant_id, scope, name)
|
||||
VALUES (100, 10, 'TENANT_OWNED', 'Tenant region');
|
||||
INSERT INTO education_school (tenant_id, scope, region_id, name)
|
||||
VALUES (0, 'PUBLIC', 100, 'Invalid public school');
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
|
||||
.hasMessageContaining("existing catalog graph violates scope rules")
|
||||
.hasMessageContaining("education_school.region_id");
|
||||
assertThat(queryLong(schema,
|
||||
"SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4070' AND success = TRUE"))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
private void createCompatibleManualPracticeFixture(String schema) throws SQLException {
|
||||
execute(schema, """
|
||||
CREATE TABLE education_practice_session (
|
||||
|
||||
Reference in New Issue
Block a user