feat(education): enforce catalog graph scope

This commit is contained in:
2026-07-30 19:53:05 +08:00
parent 55a991d3e4
commit 4db4a7d371
7 changed files with 517 additions and 18 deletions

View File

@@ -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);

View File

@@ -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 (