fix(education): harden content node lifecycle
This commit is contained in:
@@ -36,7 +36,7 @@ EDU-000 Phase 0 artifacts done
|
||||
└── EDU-005 PostgreSQL/Flyway takeover decision done
|
||||
|
||||
EDU-009 + provider/content decisions
|
||||
└── EDU-010 Tenant content publication in progress (graph integrity delivered; lifecycle remains)
|
||||
└── EDU-010 Tenant content publication in progress (graph integrity and Content Node lifecycle delivered)
|
||||
└── EDU-011 Import/export/assets/scanning blocked
|
||||
|
||||
EDU-004
|
||||
|
||||
@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
@@ -27,30 +28,34 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
|
||||
@Param("nodeId") Long nodeId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM education_content_entry
|
||||
SELECT * FROM education_content_entry
|
||||
WHERE id = #{entryId} AND deleted = false AND is_active = true AND is_hidden = false
|
||||
AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED') OR (tenant_id = 0 AND scope = 'PUBLIC'))
|
||||
FOR SHARE
|
||||
""")
|
||||
int countAvailableEntry(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId);
|
||||
ContentEntryDO selectAvailableEntryForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM education_content_node
|
||||
SELECT * FROM education_content_node
|
||||
WHERE id = #{parentId} AND entry_id = #{entryId} AND deleted = false
|
||||
AND publication_status = 'ACTIVE' AND is_active = true AND is_hidden = false
|
||||
AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED') OR (tenant_id = 0 AND scope = 'PUBLIC'))
|
||||
FOR SHARE
|
||||
""")
|
||||
int countAvailableParent(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
|
||||
@Param("parentId") Long parentId);
|
||||
ContentNodeDO selectAvailableParentForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
|
||||
@Param("parentId") Long parentId);
|
||||
|
||||
default ContentNodeDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<ContentNodeDO>().eq(ContentNodeDO::getId, id)
|
||||
.eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED"));
|
||||
.eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED")
|
||||
.eq(ContentNodeDO::getDeleted, false));
|
||||
}
|
||||
|
||||
default int updateDraftCas(Long tenantId, Long id, ContentNodeDO values, int expectedVersion) {
|
||||
return update(values, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ContentNodeDO>()
|
||||
.eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId)
|
||||
.eq(ContentNodeDO::getScope, "TENANT_OWNED")
|
||||
.eq(ContentNodeDO::getDeleted, false)
|
||||
.eq(ContentNodeDO::getPublicationStatus, "DRAFT")
|
||||
.eq(ContentNodeDO::getAuthoringVersion, expectedVersion)
|
||||
.set(ContentNodeDO::getAuthoringVersion, expectedVersion + 1));
|
||||
@@ -61,6 +66,7 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
|
||||
return update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ContentNodeDO>()
|
||||
.eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId)
|
||||
.eq(ContentNodeDO::getScope, "TENANT_OWNED")
|
||||
.eq(ContentNodeDO::getDeleted, false)
|
||||
.eq(ContentNodeDO::getPublicationStatus, expectedStatus)
|
||||
.eq(ContentNodeDO::getAuthoringVersion, expectedVersion)
|
||||
.set(ContentNodeDO::getPublicationStatus, targetStatus)
|
||||
|
||||
@@ -96,9 +96,9 @@ public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringServ
|
||||
}
|
||||
|
||||
private void validateStructure(Long tenantId, Long nodeId, Long entryId, Long parentId) {
|
||||
if (entryId == null || TenantUtils.executeIgnore(() -> nodeMapper.countAvailableEntry(tenantId, entryId)) != 1
|
||||
if (entryId == null || TenantUtils.executeIgnore(() -> nodeMapper.selectAvailableEntryForShare(tenantId, entryId)) == null
|
||||
|| (parentId != null && (parentId.equals(nodeId)
|
||||
|| TenantUtils.executeIgnore(() -> nodeMapper.countAvailableParent(tenantId, entryId, parentId)) != 1))) {
|
||||
|| TenantUtils.executeIgnore(() -> nodeMapper.selectAvailableParentForShare(tenantId, entryId, parentId)) == null))) {
|
||||
throw exception(CONTENT_NODE_STRUCTURE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +40,34 @@ CREATE TABLE education_content_node_lifecycle_audit (
|
||||
FOREIGN KEY (node_id) REFERENCES education_content_node(id),
|
||||
CONSTRAINT ck_education_content_node_lifecycle_audit_transition CHECK (
|
||||
(from_status = 'DRAFT' AND to_status = 'ACTIVE') OR
|
||||
(from_status = 'ACTIVE' AND to_status = 'ARCHIVED'))
|
||||
(from_status = 'ACTIVE' AND to_status = 'ARCHIVED')),
|
||||
CONSTRAINT uk_education_content_node_lifecycle_audit_version_transition
|
||||
UNIQUE (tenant_id, node_id, authoring_version)
|
||||
);
|
||||
COMMENT ON TABLE education_content_node_lifecycle_audit IS '教育-内容节点追加式生命周期审计';
|
||||
CREATE INDEX idx_education_content_node_lifecycle_audit_node
|
||||
ON education_content_node_lifecycle_audit (tenant_id, node_id, id);
|
||||
|
||||
CREATE TABLE education_content_node_lifecycle_transition_token (
|
||||
transaction_id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
node_id BIGINT NOT NULL,
|
||||
authoring_version INTEGER NOT NULL,
|
||||
from_status VARCHAR(16) NOT NULL,
|
||||
to_status VARCHAR(16) NOT NULL,
|
||||
PRIMARY KEY (transaction_id, tenant_id, node_id, authoring_version, from_status, to_status)
|
||||
);
|
||||
COMMENT ON TABLE education_content_node_lifecycle_transition_token IS
|
||||
'内容节点状态触发器与审计触发器之间的事务内临时凭据;成功审计后即消费';
|
||||
REVOKE ALL ON TABLE education_content_node_lifecycle_transition_token FROM PUBLIC;
|
||||
|
||||
CREATE FUNCTION education_enforce_content_node_authoring()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql SET search_path = pg_catalog, pg_temp AS $$
|
||||
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.deleted THEN
|
||||
RAISE EXCEPTION 'content node cannot start logically 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
|
||||
@@ -65,6 +83,9 @@ BEGIN
|
||||
IF NEW.scope = 'PUBLIC' AND (NEW IS DISTINCT FROM OLD) THEN
|
||||
RAISE EXCEPTION 'PUBLIC content node writes are not managed by tenant authoring' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.scope = 'TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN
|
||||
RAISE EXCEPTION 'content node cannot be logically deleted' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.publication_status = 'ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN
|
||||
RAISE EXCEPTION 'archived content node is immutable' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
@@ -76,12 +97,22 @@ BEGIN
|
||||
(OLD.publication_status = 'ACTIVE' AND NEW.publication_status = 'ARCHIVED')) THEN
|
||||
RAISE EXCEPTION 'invalid content node lifecycle transition' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.education_content_node_lifecycle_transition_token' ||
|
||||
' (transaction_id, tenant_id, node_id, authoring_version, from_status, to_status)' ||
|
||||
' VALUES ($1, $2, $3, $4, $5, $6)',
|
||||
TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.id,
|
||||
NEW.authoring_version, OLD.publication_status, NEW.publication_status;
|
||||
END IF;
|
||||
IF OLD.publication_status <> 'DRAFT' AND (
|
||||
NEW.entry_id IS DISTINCT FROM OLD.entry_id OR NEW.parent_id IS DISTINCT FROM OLD.parent_id OR
|
||||
NEW.name IS DISTINCT FROM OLD.name OR NEW.title IS DISTINCT FROM OLD.title OR
|
||||
NEW.node_type IS DISTINCT FROM OLD.node_type OR NEW.marker_type IS DISTINCT FROM OLD.marker_type OR
|
||||
NEW.is_leaf IS DISTINCT FROM OLD.is_leaf OR NEW.is_selectable IS DISTINCT FROM OLD.is_selectable OR
|
||||
NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata) THEN
|
||||
NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata OR
|
||||
NEW.deleted IS DISTINCT FROM OLD.deleted) THEN
|
||||
RAISE EXCEPTION 'active content node content is immutable' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
@@ -98,7 +129,7 @@ RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog,
|
||||
DECLARE matching_count INTEGER;
|
||||
BEGIN
|
||||
IF NEW.publication_status IS NOT DISTINCT FROM OLD.publication_status THEN RETURN NULL; END IF;
|
||||
EXECUTE format('SELECT count(*) FROM %I.education_content_node_lifecycle_audit WHERE tenant_id=$1 AND node_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5', TG_TABLE_SCHEMA)
|
||||
EXECUTE format('SELECT count(*) FROM %I.education_content_node_lifecycle_audit WHERE tenant_id=$1 AND node_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5 AND deleted=false', TG_TABLE_SCHEMA)
|
||||
INTO matching_count USING NEW.tenant_id, NEW.id, NEW.authoring_version, OLD.publication_status, NEW.publication_status;
|
||||
IF matching_count <> 1 THEN RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514'; END IF;
|
||||
RETURN NULL;
|
||||
@@ -106,13 +137,27 @@ END $$;
|
||||
|
||||
CREATE FUNCTION education_validate_content_node_lifecycle_audit()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
|
||||
DECLARE node_tenant BIGINT; node_version INTEGER; node_status VARCHAR(16);
|
||||
DECLARE node_tenant BIGINT; node_version INTEGER; node_status VARCHAR(16); transition_token_count INTEGER;
|
||||
BEGIN
|
||||
EXECUTE format('SELECT tenant_id, authoring_version, publication_status FROM %I.education_content_node WHERE id=$1', TG_TABLE_SCHEMA)
|
||||
IF NEW.deleted THEN
|
||||
RAISE EXCEPTION 'content node lifecycle audit cannot be logically deleted' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
EXECUTE format('SELECT tenant_id, authoring_version, publication_status FROM %I.education_content_node WHERE id=$1 FOR SHARE', TG_TABLE_SCHEMA)
|
||||
INTO node_tenant, node_version, node_status USING NEW.node_id;
|
||||
IF node_tenant IS DISTINCT FROM NEW.tenant_id OR node_version IS DISTINCT FROM NEW.authoring_version
|
||||
OR node_status IS DISTINCT FROM NEW.to_status THEN
|
||||
RAISE EXCEPTION 'content node lifecycle audit must match current transition' USING ERRCODE = '23514';
|
||||
RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
EXECUTE format(
|
||||
'DELETE FROM %I.education_content_node_lifecycle_transition_token' ||
|
||||
' WHERE transaction_id=$1 AND tenant_id=$2 AND node_id=$3 AND authoring_version=$4' ||
|
||||
' AND from_status=$5 AND to_status=$6',
|
||||
TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.node_id,
|
||||
NEW.authoring_version, NEW.from_status, NEW.to_status;
|
||||
GET DIAGNOSTICS transition_token_count = ROW_COUNT;
|
||||
IF transition_token_count <> 1 THEN
|
||||
RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
@@ -495,15 +495,129 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
INSERT INTO education_content_entry
|
||||
(id, tenant_id, scope, entry_key, name, entry_type)
|
||||
VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question');
|
||||
""");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
INSERT INTO education_content_node
|
||||
(id, tenant_id, scope, entry_id, name, node_type,
|
||||
publication_status, is_active, is_hidden, deleted)
|
||||
VALUES (199, 10, 'TENANT_OWNED', 100, 'Deleted draft', 'category',
|
||||
'DRAFT', false, true, true);
|
||||
""")).hasMessageContaining("content node cannot start logically deleted");
|
||||
execute(schema, """
|
||||
INSERT INTO education_content_node
|
||||
(id, tenant_id, scope, entry_id, name, node_type, publication_status, is_active, is_hidden)
|
||||
VALUES (200, 10, 'TENANT_OWNED', 100, 'Draft', 'category', 'DRAFT', false, true);
|
||||
""");
|
||||
execute(schema, """
|
||||
DO $activate$
|
||||
BEGIN
|
||||
UPDATE education_content_node
|
||||
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
|
||||
authoring_version = 1
|
||||
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');
|
||||
END
|
||||
$activate$;
|
||||
""");
|
||||
assertThat(queryLong(schema, """
|
||||
SELECT COUNT(*)
|
||||
FROM pg_constraint constraint_definition
|
||||
JOIN pg_class audit_table ON audit_table.oid = constraint_definition.conrelid
|
||||
JOIN pg_namespace audit_schema ON audit_schema.oid = audit_table.relnamespace
|
||||
WHERE audit_schema.nspname = current_schema()
|
||||
AND audit_table.relname = 'education_content_node_lifecycle_audit'
|
||||
AND constraint_definition.conname =
|
||||
'uk_education_content_node_lifecycle_audit_version_transition'
|
||||
AND constraint_definition.contype = 'u'
|
||||
""")).isEqualTo(1L);
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
INSERT INTO education_content_node_lifecycle_audit
|
||||
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
|
||||
VALUES (10, 200, 1, 8, 'DRAFT', 'ACTIVE');
|
||||
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
|
||||
execute(schema, """
|
||||
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, 'Deleted audit draft', 'category', 'DRAFT', false, true);
|
||||
""");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
DO $activate_deleted$
|
||||
BEGIN
|
||||
UPDATE education_content_node
|
||||
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
|
||||
deleted = true, authoring_version = 1
|
||||
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');
|
||||
END
|
||||
$activate_deleted$;
|
||||
""")).hasMessageContaining("content node cannot be logically deleted");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_content_node
|
||||
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false, authoring_version = 1
|
||||
SET deleted = true, authoring_version = 1
|
||||
WHERE id = 201;
|
||||
""")).hasMessageContaining("content node cannot be logically deleted");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
DO $deleted_audit$
|
||||
BEGIN
|
||||
UPDATE education_content_node
|
||||
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
|
||||
authoring_version = 1
|
||||
WHERE id = 201;
|
||||
INSERT INTO education_content_node_lifecycle_audit
|
||||
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status, deleted)
|
||||
VALUES (10, 201, 1, 7, 'DRAFT', 'ACTIVE', true);
|
||||
END
|
||||
$deleted_audit$;
|
||||
""")).hasMessageContaining("content node lifecycle audit cannot be logically deleted");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_content_node
|
||||
SET publication_status = 'ARCHIVED', is_active = false, is_hidden = true,
|
||||
authoring_version = 2
|
||||
WHERE id = 200;
|
||||
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
INSERT INTO education_content_node_lifecycle_audit
|
||||
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
|
||||
VALUES (10, 200, 2, 8, 'ACTIVE', 'ARCHIVED');
|
||||
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_content_node
|
||||
SET deleted = true, authoring_version = 2
|
||||
WHERE id = 200;
|
||||
""")).hasMessageContaining("content node cannot be logically deleted");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_content_node
|
||||
SET publication_status = 'ARCHIVED', is_active = false, is_hidden = true,
|
||||
authoring_version = 2
|
||||
WHERE id = 200;
|
||||
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
|
||||
execute(schema, """
|
||||
DO $archive$
|
||||
BEGIN
|
||||
UPDATE education_content_node
|
||||
SET publication_status = 'ARCHIVED', is_active = false, is_hidden = true,
|
||||
authoring_version = 2
|
||||
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, 2, 7, 'ACTIVE', 'ARCHIVED');
|
||||
END
|
||||
$archive$;
|
||||
""");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_content_node
|
||||
SET deleted = true, authoring_version = 3
|
||||
WHERE id = 200;
|
||||
""")).hasMessageContaining("content node cannot be logically deleted");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
INSERT INTO education_content_node_lifecycle_audit
|
||||
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
|
||||
VALUES (10, 200, 3, 8, 'ACTIVE', 'ARCHIVED');
|
||||
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
INSERT INTO education_content_node
|
||||
(tenant_id, scope, entry_id, name, node_type, publication_status, is_active)
|
||||
@@ -511,6 +625,56 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
""")).hasMessageContaining("PUBLIC content node writes are not managed by tenant authoring");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowRuntimeRoleTransitionsWithoutTokenTablePrivileges() throws SQLException {
|
||||
String schema = createSchema("content_node_runtime_role");
|
||||
configureFlyway(schema, false).load().migrate();
|
||||
String runtimeRole = "edu_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 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_content_node_lifecycle_transition_token FROM " + runtimeRole);
|
||||
execute(schema, """
|
||||
INSERT INTO education_content_entry
|
||||
(id, tenant_id, scope, entry_key, name, entry_type)
|
||||
VALUES (100, 10, 'TENANT_OWNED', '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, 10, 'TENANT_OWNED', 100, 'Draft', 'category',
|
||||
'DRAFT', false, true);
|
||||
""");
|
||||
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
jdbcUrl(schema), runtimeRole, "runtime_test");
|
||||
var statement = connection.createStatement()) {
|
||||
statement.execute("""
|
||||
DO $activate$
|
||||
BEGIN
|
||||
UPDATE education_content_node
|
||||
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
|
||||
authoring_version = 1
|
||||
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');
|
||||
END
|
||||
$activate$;
|
||||
""");
|
||||
assertThatThrownBy(() -> statement.execute("""
|
||||
INSERT INTO education_content_node_lifecycle_transition_token
|
||||
(transaction_id, tenant_id, node_id, authoring_version, from_status, to_status)
|
||||
VALUES (1, 10, 200, 2, 'ACTIVE', 'ARCHIVED');
|
||||
""")).hasMessageContaining("permission denied");
|
||||
}
|
||||
} finally {
|
||||
execute(schema, "DROP OWNED BY " + runtimeRole + "; DROP ROLE " + runtimeRole);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
|
||||
String schema = createSchema("question_history");
|
||||
@@ -749,6 +913,10 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
'education_require_question_lifecycle_audit',
|
||||
'education_check_question_version_ownership',
|
||||
'education_validate_question_lifecycle_audit_insert',
|
||||
'education_enforce_content_node_authoring',
|
||||
'education_prevent_content_node_audit_mutation',
|
||||
'education_require_content_node_lifecycle_audit',
|
||||
'education_validate_content_node_lifecycle_audit',
|
||||
'education_enforce_question_placement_mutation',
|
||||
'education_question_answer_key_is_valid')
|
||||
AND grantee = 'PUBLIC'
|
||||
|
||||
Reference in New Issue
Block a user