'use strict'; /** * Creator Studio 契约测试。 * * 这些断言锁定“服务端才是权威”的边界:状态、付费、审核与私有附件不能由前端 * 直接写穿;已发布文章的改稿不得覆盖线上内容;私有附件不得暴露公开路径。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const root = path.resolve(__dirname, '..', '..'); const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); const exists = (file) => fs.existsSync(path.join(root, file)); const creatorRest = read('src/Core/CreatorRest.php'); const versions = read('src/Core/SubmissionVersion.php'); const privateAssets = read('src/Content/PrivateAssets.php'); const protectedDownload = read('src/Content/ProtectedDownload.php'); const studio = read('src/Core/CreatorStudio.php'); const pageSubmit = read('templates/pages/page-submit.php'); const saveTrait = read('src/Core/FrontSubmitSave.php'); const domainModel = read('src/Core/DomainModel.php'); const appSource = read('assets/creator-studio/src/index.tsx'); const drawer = read('assets/creator-studio/src/panels/publish-drawer.tsx'); const api = read('assets/creator-studio/src/services/api.ts'); test('creator REST routes always register a permission callback', () => { const routes = creatorRest.match(/register_rest_route\(/g) || []; const permissions = creatorRest.match(/'permission_callback'\s*=>/g) || []; assert.ok(routes.length >= 5, 'creator API should expose bootstrap/save/submit/preview/upload routes'); assert.equal(routes.length, permissions.length, 'every creator route needs a permission callback'); assert.doesNotMatch(creatorRest, /'permission_callback'\s*=>\s*'__return_true'/); }); test('studio writes reuse the existing submit service instead of raw post APIs', () => { assert.match(creatorRest, /\$front->persist\(\$data, 'posts_draft'\)/); assert.match(creatorRest, /\$front->persist\(\$data, 'posts_save'\)/); assert.doesNotMatch(creatorRest, /wp_insert_post\(/); assert.doesNotMatch(creatorRest, /wp_update_post\(\$update/); assert.match(creatorRest, /can_use_studio/); assert.match(creatorRest, /user_can_cap\('new_post_audit_no', \$user_id\)/); }); test('published edits never overwrite the live post before approval', () => { assert.match(versions, /public static function requires_version/); assert.match(versions, /post_status === 'publish'/); assert.match(versions, /self::POST_TYPE/); assert.match(versions, /public function approve/); assert.match(versions, /public function reject/); assert.match(versions, /jio_version_source_changed/); assert.match(versions, /'status'\s*=>\s*409/); // Approval is the only path that writes title/content back to the source post. assert.match(versions, /'ID'\s*=>\s*\$source_id[\s\S]{0,400}?wp_update_post/); assert.match(creatorRest, /SubmissionVersion::requires_version\(\$post_id\)[\s\S]{0,400}?save_working/); }); test('stale autosave requests are rejected instead of overwriting', () => { assert.match(versions, /'jio_version_conflict'/); assert.match(versions, /current_rev/); assert.match(versions, /base_rev/); assert.match(appSource, /status === 409/); }); test('paid payload is validated before any post write', () => { const prepareAt = saveTrait.indexOf('prepare_pay_meta'); const insertAt = saveTrait.indexOf('wp_insert_post'); const updateAt = saveTrait.indexOf('wp_update_post'); assert.notEqual(prepareAt, -1, 'persist must validate pay payload'); assert.ok(prepareAt < insertAt, 'pay validation must precede insert'); assert.ok(prepareAt < updateAt, 'pay validation must precede update'); assert.match(saveTrait, /if \(empty\(\$pay_result\['success'\]\)\) \{\s*return \$pay_result;/); }); test('private attachments never expose a public URL', () => { assert.match(privateAssets, /define\('JIO_PRIVATE_STORAGE_DIR'/); assert.match(privateAssets, /is_available\(\)/); assert.match(privateAssets, /is_verified/); assert.match(privateAssets, /return \$outside \|\| \$explicit;/); assert.doesNotMatch(privateAssets, /wp_upload_dir\(/); assert.doesNotMatch(privateAssets, /wp_get_attachment_url/); assert.match(privateAssets, /asset_key/); assert.match(privateAssets, /@chmod\(\$target, 0600\)/); }); test('protected downloads require entitlement, tokens, and the private asset id', () => { assert.match(protectedDownload, /jio_can_download/); assert.match(protectedDownload, /Visibility::can_download_resource/); assert.match(protectedDownload, /asset_id/); assert.match(protectedDownload, /jio_token_expired/); assert.match(protectedDownload, /jio_token_exhausted/); assert.match(protectedDownload, /UPDATE \{\$table\} SET used_count = used_count \+ 1/); assert.match(protectedDownload, /GREATEST\(used_count - 1, 0\)/); assert.match(protectedDownload, /X-Content-Type-Options/); assert.doesNotMatch(protectedDownload, /wp_get_attachment_url/); }); test('model stores protected downloads by asset key only', () => { assert.match(domainModel, /'protected'/); assert.match(domainModel, /'asset_key'/); assert.match(domainModel, /PrivateAssets::sanitize_asset_key/); assert.match(domainModel, /\$is_protected\) \{\s*\$type = 'protected';\s*\$url = '';/); }); test('studio is mounted only on the submit template with a fallback', () => { assert.match(pageSubmit, /CreatorStudio::get_instance\(\)/); assert.match(pageSubmit, /is_built\(\)/); assert.match(pageSubmit, /get_current_user_id\(\) > 0/); assert.match(pageSubmit, /id="jio-creator-studio"/); // Guests and missing builds fall back to the existing TinyMCE form. assert.match(pageSubmit, /wp_enqueue_editor\(\);/); assert.match(studio, /is_page_template\('templates\/pages\/page-submit.php'\)/); assert.match(studio, /index\.asset\.php/); assert.match(studio, /wp_add_inline_script/); }); test('studio app keeps autosave, recovery, and the publish drawer wired', () => { assert.match(appSource, /useAutosave/); assert.match(appSource, /useLocalRecovery/); assert.match(appSource, /PublishDrawer/); assert.match(appSource, /BlockEditorProvider/); assert.match(appSource, /registerCoreBlocks/); assert.match(appSource, /installMediaUploadBridge/); assert.match(api, /'X-WP-Nonce'/); assert.match(drawer, /canPay/); assert.match(drawer, /canPayDownload/); assert.match(drawer, /私有附件存储尚未配置/); assert.match(drawer, /onSubmit\(false\)/); assert.match(drawer, /立即发布/); }); test('固定顶栏包含保存状态、撤销/重做、预览和发布动作', () => { assert.match(appSource, /className="jio-creator__bar"/); assert.match(appSource, /history\.undo\(\)/); assert.match(appSource, /history\.redo\(\)/); assert.match(appSource, /保存草稿/); assert.match(appSource, / { assert.ok(exists('assets/creator-studio/package.json')); assert.ok(exists('assets/creator-studio/package-lock.json')); assert.ok(exists('assets/creator-studio/build/index.asset.php')); assert.ok(exists('assets/creator-studio/build/index.js')); assert.ok(exists('assets/creator-studio/build/index.css')); assert.ok(exists('assets/js/protected-download.js')); assert.ok(exists('src/Core/PreviewToken.php')); assert.ok(exists('src/Admin/Native/SubmissionReview.php')); }); test('沉浸式布局屏蔽主题头部/页脚与挂件区', () => { const header = read('header.php'); const footer = read('footer.php'); // 工作台模式下这些输出必须可关闭,否则顶栏重复、正文被挂件挤压。 assert.match(header, /apply_filters\('jio_render_header', true\)/); assert.match(header, /apply_filters\('jio_render_fluid_widgets', true\)/); assert.match(footer, /apply_filters\('jio_render_footer', true\)/); assert.match(pageSubmit, /add_filter\('jio_render_header', '__return_false'\)/); assert.match(pageSubmit, /add_filter\('jio_render_footer', '__return_false'\)/); assert.match(pageSubmit, /add_filter\('jio_render_fluid_widgets', '__return_false'\)/); }); test('插入面板与封面条走真实区块名与服务端能力', () => { const insertItems = read('assets/creator-studio/src/utils/insert-items.ts'); const insertPanel = read('assets/creator-studio/src/panels/insert-panel.tsx'); const cover = read('assets/creator-studio/src/components/cover-strip.tsx'); assert.match(appSource, /createBlock/); assert.match(insertItems, /'core\/paragraph'/); assert.match(insertItems, /'core\/heading'/); assert.match(insertItems, /'core\/image'/); assert.match(insertItems, /'jio\/paid-fragment'/); // 没有能力的条目不能出现在面板里。 assert.match(insertItems, /if \(capabilities\.canUploadImage\)/); assert.match(insertItems, /if \(capabilities\.canPay\)/); assert.match(insertPanel, /role="dialog"/); assert.match(insertPanel, /event\.key === 'Escape'/); assert.match(cover, /添加封面/); assert.match(appSource, / { assert.match(appSource, /characters_excluding_spaces/); assert.doesNotMatch(appSource, /countWords\([^)]*'words'/); });