'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('creator studio build artifacts and contracts exist', () => { 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')); });