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-005 PostgreSQL/Flyway takeover decision done
|
||||||
|
|
||||||
EDU-009 + provider/content decisions
|
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-011 Import/export/assets/scanning blocked
|
||||||
|
|
||||||
EDU-004
|
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.mapper.BaseMapperX;
|
||||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
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 cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
@@ -27,30 +28,34 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
|
|||||||
@Param("nodeId") Long nodeId);
|
@Param("nodeId") Long nodeId);
|
||||||
|
|
||||||
@Select("""
|
@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
|
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'))
|
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("""
|
||||||
SELECT COUNT(*) FROM education_content_node
|
SELECT * FROM education_content_node
|
||||||
WHERE id = #{parentId} AND entry_id = #{entryId} AND deleted = false
|
WHERE id = #{parentId} AND entry_id = #{entryId} AND deleted = false
|
||||||
AND publication_status = 'ACTIVE' AND is_active = true AND is_hidden = 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'))
|
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,
|
ContentNodeDO selectAvailableParentForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
|
||||||
@Param("parentId") Long parentId);
|
@Param("parentId") Long parentId);
|
||||||
|
|
||||||
default ContentNodeDO selectTenantOwnedById(Long tenantId, Long id) {
|
default ContentNodeDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||||
return selectOne(new LambdaQueryWrapperX<ContentNodeDO>().eq(ContentNodeDO::getId, 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) {
|
default int updateDraftCas(Long tenantId, Long id, ContentNodeDO values, int expectedVersion) {
|
||||||
return update(values, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ContentNodeDO>()
|
return update(values, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ContentNodeDO>()
|
||||||
.eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId)
|
.eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId)
|
||||||
.eq(ContentNodeDO::getScope, "TENANT_OWNED")
|
.eq(ContentNodeDO::getScope, "TENANT_OWNED")
|
||||||
|
.eq(ContentNodeDO::getDeleted, false)
|
||||||
.eq(ContentNodeDO::getPublicationStatus, "DRAFT")
|
.eq(ContentNodeDO::getPublicationStatus, "DRAFT")
|
||||||
.eq(ContentNodeDO::getAuthoringVersion, expectedVersion)
|
.eq(ContentNodeDO::getAuthoringVersion, expectedVersion)
|
||||||
.set(ContentNodeDO::getAuthoringVersion, expectedVersion + 1));
|
.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>()
|
return update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ContentNodeDO>()
|
||||||
.eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId)
|
.eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId)
|
||||||
.eq(ContentNodeDO::getScope, "TENANT_OWNED")
|
.eq(ContentNodeDO::getScope, "TENANT_OWNED")
|
||||||
|
.eq(ContentNodeDO::getDeleted, false)
|
||||||
.eq(ContentNodeDO::getPublicationStatus, expectedStatus)
|
.eq(ContentNodeDO::getPublicationStatus, expectedStatus)
|
||||||
.eq(ContentNodeDO::getAuthoringVersion, expectedVersion)
|
.eq(ContentNodeDO::getAuthoringVersion, expectedVersion)
|
||||||
.set(ContentNodeDO::getPublicationStatus, targetStatus)
|
.set(ContentNodeDO::getPublicationStatus, targetStatus)
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringServ
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validateStructure(Long tenantId, Long nodeId, Long entryId, Long parentId) {
|
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)
|
|| (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);
|
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),
|
FOREIGN KEY (node_id) REFERENCES education_content_node(id),
|
||||||
CONSTRAINT ck_education_content_node_lifecycle_audit_transition CHECK (
|
CONSTRAINT ck_education_content_node_lifecycle_audit_transition CHECK (
|
||||||
(from_status = 'DRAFT' AND to_status = 'ACTIVE') OR
|
(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 '教育-内容节点追加式生命周期审计';
|
COMMENT ON TABLE education_content_node_lifecycle_audit IS '教育-内容节点追加式生命周期审计';
|
||||||
CREATE INDEX idx_education_content_node_lifecycle_audit_node
|
CREATE INDEX idx_education_content_node_lifecycle_audit_node
|
||||||
ON education_content_node_lifecycle_audit (tenant_id, node_id, id);
|
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()
|
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
|
BEGIN
|
||||||
IF TG_OP = 'INSERT' THEN
|
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.scope = 'PUBLIC' THEN
|
||||||
IF NEW.publication_status <> 'ACTIVE' OR NOT NEW.is_active OR NEW.is_hidden
|
IF NEW.publication_status <> 'ACTIVE' OR NOT NEW.is_active OR NEW.is_hidden
|
||||||
OR NEW.authoring_version <> 0 THEN
|
OR NEW.authoring_version <> 0 THEN
|
||||||
@@ -65,6 +83,9 @@ BEGIN
|
|||||||
IF NEW.scope = 'PUBLIC' AND (NEW IS DISTINCT FROM OLD) THEN
|
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';
|
RAISE EXCEPTION 'PUBLIC content node writes are not managed by tenant authoring' USING ERRCODE = '23514';
|
||||||
END IF;
|
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
|
IF OLD.publication_status = 'ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN
|
||||||
RAISE EXCEPTION 'archived content node is immutable' USING ERRCODE = '23514';
|
RAISE EXCEPTION 'archived content node is immutable' USING ERRCODE = '23514';
|
||||||
END IF;
|
END IF;
|
||||||
@@ -76,12 +97,22 @@ BEGIN
|
|||||||
(OLD.publication_status = 'ACTIVE' AND NEW.publication_status = 'ARCHIVED')) THEN
|
(OLD.publication_status = 'ACTIVE' AND NEW.publication_status = 'ARCHIVED')) THEN
|
||||||
RAISE EXCEPTION 'invalid content node lifecycle transition' USING ERRCODE = '23514';
|
RAISE EXCEPTION 'invalid content node lifecycle transition' USING ERRCODE = '23514';
|
||||||
END IF;
|
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 (
|
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.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.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.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.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';
|
RAISE EXCEPTION 'active content node content is immutable' USING ERRCODE = '23514';
|
||||||
END IF;
|
END IF;
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
@@ -98,7 +129,7 @@ RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog,
|
|||||||
DECLARE matching_count INTEGER;
|
DECLARE matching_count INTEGER;
|
||||||
BEGIN
|
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;
|
||||||
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;
|
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;
|
IF matching_count <> 1 THEN RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514'; END IF;
|
||||||
RETURN NULL;
|
RETURN NULL;
|
||||||
@@ -106,13 +137,27 @@ END $$;
|
|||||||
|
|
||||||
CREATE FUNCTION education_validate_content_node_lifecycle_audit()
|
CREATE FUNCTION education_validate_content_node_lifecycle_audit()
|
||||||
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
|
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
|
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;
|
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
|
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
|
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;
|
END IF;
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END $$;
|
END $$;
|
||||||
|
|||||||
@@ -495,15 +495,129 @@ class EducationFlywayMigrationIntegrationTest {
|
|||||||
INSERT INTO education_content_entry
|
INSERT INTO education_content_entry
|
||||||
(id, tenant_id, scope, entry_key, name, entry_type)
|
(id, tenant_id, scope, entry_key, name, entry_type)
|
||||||
VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question');
|
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
|
INSERT INTO education_content_node
|
||||||
(id, tenant_id, scope, entry_id, name, node_type, publication_status, is_active, is_hidden)
|
(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);
|
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, """
|
assertThatThrownBy(() -> execute(schema, """
|
||||||
UPDATE education_content_node
|
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;
|
WHERE id = 200;
|
||||||
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
|
""")).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, """
|
assertThatThrownBy(() -> execute(schema, """
|
||||||
INSERT INTO education_content_node
|
INSERT INTO education_content_node
|
||||||
(tenant_id, scope, entry_id, name, node_type, publication_status, is_active)
|
(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");
|
""")).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
|
@Test
|
||||||
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
|
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
|
||||||
String schema = createSchema("question_history");
|
String schema = createSchema("question_history");
|
||||||
@@ -749,6 +913,10 @@ class EducationFlywayMigrationIntegrationTest {
|
|||||||
'education_require_question_lifecycle_audit',
|
'education_require_question_lifecycle_audit',
|
||||||
'education_check_question_version_ownership',
|
'education_check_question_version_ownership',
|
||||||
'education_validate_question_lifecycle_audit_insert',
|
'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_enforce_question_placement_mutation',
|
||||||
'education_question_answer_key_is_valid')
|
'education_question_answer_key_is_valid')
|
||||||
AND grantee = 'PUBLIC'
|
AND grantee = 'PUBLIC'
|
||||||
|
|||||||
Reference in New Issue
Block a user