Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

165 wiersze
11 KiB

  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFile } from 'node:fs/promises'
  4. import { createRequire } from 'node:module'
  5. const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
  6. const ts = requireWeb('typescript')
  7. const web = new URL('../../unreal_tran_web/src/', import.meta.url)
  8. const dataModule = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
  9. async function compile(file, replacements = {}) {
  10. let source = await readFile(new URL(file, web), 'utf8')
  11. for (const [from, to] of Object.entries(replacements)) source = source.replaceAll(from, to)
  12. return dataModule(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText)
  13. }
  14. const { canUsePermission } = await import(await compile('config/roles.ts'))
  15. const { contentPermission, contentReviewPermission } = await import(await compile('utils/contentPermissions.ts'))
  16. test('content abilities use exact grants independently of teacher/admin/custom role names', () => {
  17. for (const code of ['admin', 'teacher', 'custom-author', 'student']) {
  18. assert.equal(canUsePermission('content.model.update', ['content.model.update'], [{ code, enabled: true, active: true }], 'SINGLE_ACTIVE'), true)
  19. assert.equal(canUsePermission('content.scene.update', ['content.model.update'], [{ code, enabled: true, active: true }], 'UNION'), false)
  20. assert.equal(canUsePermission('content.model.update', ['content.update'], [{ code, enabled: true, active: true }], 'UNION'), false)
  21. }
  22. })
  23. test('copy, delete, review, revoke and submit retain separate action grants', () => {
  24. assert.equal(contentPermission('GUIDE', 'create'), 'content.ofd.create')
  25. assert.equal(contentPermission('TRAINING', 'delete'), 'content.training.delete')
  26. for (const action of ['APPROVE', 'REJECT']) assert.equal(contentReviewPermission('GUIDE', action), 'content.ofd.review')
  27. assert.equal(contentReviewPermission('GUIDE', 'REVOKE'), 'content.ofd.publish')
  28. for (const action of ['SUBMIT', 'WITHDRAW', 'REVISE']) assert.equal(contentReviewPermission('GUIDE', action), 'content.ofd.update')
  29. })
  30. const authUrl = dataModule(`export const auth = { profile: { userId: 'reader', displayName: 'Actual user', permissions: [] }, activeRole: { code: 'admin' }, availableRoles: [], hasEffectiveRole: () => false, hasPermission(permission) { return this.profile.permissions.includes(permission) } }; export const useAuthStore = () => auth;`)
  31. const { auth } = await import(authUrl)
  32. const storageUrl = await compile('features/legacy-shared/scopedStorage.ts')
  33. const guideRuntime = await import(await compile('features/guide-legacy/runtime.ts', {
  34. './demo-src/asset-store.js': new URL('features/guide-legacy/demo-src/asset-store.js', web).href,
  35. './demo-src/content-list-store.js': new URL('features/guide-legacy/demo-src/content-list-store.js', web).href,
  36. '../legacy-shared/scopedStorage': storageUrl,
  37. '../../stores/auth': authUrl,
  38. }))
  39. test('OFD runtime uses the real session and rejects local create/update/delete without exact grants', async () => {
  40. const { createInitialOFDState } = await import(new URL('features/guide-legacy/demo-src/ofd-document.js', web))
  41. const context = guideRuntime.legacyGuideRuntimeContext()
  42. assert.equal(context.actor.id, 'reader')
  43. assert.equal(context.readOnly, true)
  44. assert.equal(context.can('content.create'), false)
  45. assert.equal(context.storage.length, 0, 'read-only initialization must not seed projects')
  46. assert.throws(() => context.contentStore.createOFDProject({ title: 'Denied' }), /权限/)
  47. auth.profile.permissions = ['content.ofd.create']
  48. const initial = createInitialOFDState()
  49. initial.project.name = 'Full initial payload'
  50. const created = context.contentStore.createOFDProject({ title: 'Created only', state: initial })
  51. assert.ok(created.id)
  52. assert.equal(created.author, 'Actual user')
  53. assert.throws(() => context.contentStore.saveOFDProjectState(created.id, created.state), /权限/)
  54. assert.throws(() => context.contentStore.deleteOFDProject(created.id), /权限/)
  55. auth.profile.permissions = ['content.ofd.delete']
  56. assert.equal(context.contentStore.deleteOFDProject(created.id), true)
  57. })
  58. const { canPerformOFDAction, createInitialOFDState } = await import(new URL('features/guide-legacy/demo-src/ofd-document.js', web))
  59. test('OFD lifecycle does not treat admin/teacher identity as a publish grant', () => {
  60. const state = createInitialOFDState()
  61. for (const role of ['admin', 'teacher']) assert.equal(canPerformOFDAction(state, 'publish', { role, permissions: [] }), false)
  62. assert.equal(canPerformOFDAction(state, 'publish', { role: 'custom', permissions: ['content.ofd.publish'] }), true)
  63. assert.equal(canPerformOFDAction(state, 'publish', { role: 'custom', permissions: ['content.ofd.update'] }), false)
  64. })
  65. const { installLegacyModelPermissions } = await import(await compile('features/editors/model/legacy-iframe-permissions.ts', {
  66. '../common/editor-project-io.js': new URL('features/editors/common/editor-project-io.js', web).href,
  67. }))
  68. function modelFixture({ update = false, publish = false } = {}) {
  69. const calls = []
  70. const editor = {
  71. apiEditorHydrated: true, apiState: 'ready', apiSession: { project: { id: 'test', version: 7 }, lastSavedFingerprint: '' },
  72. saveProject: async () => { calls.push('save'); return {} },
  73. publishToSceneLibrary: async () => { calls.push('save-and-publish'); return {} },
  74. saveRecoveryCopy: () => calls.push('recovery'),
  75. serializeProject: () => ({}), toast: message => calls.push(message),
  76. transform: { enabled: true, detach() {}, attach() { calls.push('transform') } },
  77. }
  78. const doc = { defaultView: { editorApp: editor }, body: {}, querySelector: () => null, querySelectorAll: () => [], addEventListener() {}, removeEventListener() {} }
  79. const previousObserver = globalThis.MutationObserver
  80. globalThis.MutationObserver = class { observe() {} disconnect() {} }
  81. const bridge = installLegacyModelPermissions(doc, {
  82. canCreate: () => false, canUpdate: () => update, canPublish: () => publish,
  83. publishSaved: async version => { calls.push(['publish-saved', version]); return { id: 'test', version: 8 } },
  84. })
  85. globalThis.MutationObserver = previousObserver
  86. return { editor, calls, bridge }
  87. }
  88. test('model iframe read-only bridge blocks autosave, recovery writes and transform attach', async () => {
  89. const { editor, calls, bridge } = modelFixture()
  90. assert.equal(await editor.saveProject(true, { automatic: true }), null)
  91. assert.equal(await editor.publishToSceneLibrary(), null)
  92. editor.saveRecoveryCopy()
  93. editor.transform.attach({})
  94. assert.deepEqual(calls, [])
  95. assert.equal(editor.transform.enabled, false)
  96. bridge.dispose()
  97. })
  98. test('model iframe publisher-only uses the loaded optimistic-lock version without saving', async () => {
  99. const { editor, calls, bridge } = modelFixture({ publish: true })
  100. const published = await editor.publishToSceneLibrary()
  101. assert.equal(published.version, 8)
  102. assert.deepEqual(calls[0], ['publish-saved', 7])
  103. assert.equal(calls.includes('save-and-publish'), false)
  104. bridge.dispose()
  105. })
  106. test('committed role switch prevents iframe unload recovery from writing discarded changes', async () => {
  107. const { editor, calls, bridge } = modelFixture({ update: true, publish: true })
  108. bridge.roleSwitchCommitted()
  109. editor.saveRecoveryCopy()
  110. assert.equal(await editor.saveProject(), null)
  111. assert.equal(await editor.publishToSceneLibrary(), null)
  112. assert.deepEqual(calls, [])
  113. bridge.dispose()
  114. })
  115. test('read-only model viewing skips automatic migration and recovery without modifying saved data', async () => {
  116. const { editor, bridge } = modelFixture()
  117. editor.apiDocumentNeedsMigrationSave = true
  118. assert.equal(editor.apiDocumentNeedsMigrationSave, false)
  119. assert.equal(await editor.saveProject(true), null)
  120. bridge.dispose()
  121. assert.equal(editor.apiDocumentNeedsMigrationSave, true, 'the host must restore the original runtime state on disposal')
  122. })
  123. const policy = await import(await compile('features/guide/publishDeliveriesPolicy.ts'))
  124. const publishSource = await readFile(new URL('features/guide/publishDeliveries.ts', web), 'utf8')
  125. const publishFunction = ts.transpileModule(publishSource.slice(publishSource.indexOf('export async function publishGuideWithDeliveries(')), {
  126. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
  127. }).outputText.replace(/^export /gm, '')
  128. const publishGuide = new Function('normalizeGuideContent', 'resolveGuideDeliveryAssets', 'commitGuideDeliveries', 'reusableGuideDelivery', 'assertCompleteGuideDeliveries', 'GUIDE_DELIVERY_ASSET_CODES', 'artifactFile', 'text', `${publishFunction};return publishGuideWithDeliveries`)(
  129. value => value, async () => ({}), policy.commitGuideDeliveries, policy.reusableGuideDelivery,
  130. policy.assertCompleteGuideDeliveries, policy.GUIDE_DELIVERY_ASSET_CODES,
  131. () => { throw new Error('publisher must not upload') }, value => String(value ?? '').trim(),
  132. )
  133. function guidePublisherFixture(savedFingerprint) {
  134. const calls = []
  135. const formats = ['OFD', 'OFFLINE_HTML']
  136. const assets = formats.map(format => ({ code: policy.GUIDE_DELIVERY_ASSET_CODES[format], type: 'DOCUMENT', status: 'READY', sha256: 'a'.repeat(64),
  137. storageUri: `content://${format}`, metadata: { deliveryFormat: format, sourceFingerprint: savedFingerprint } }))
  138. const dependencies = {
  139. loadProject: async () => ({ id: 'g', version: 17, status: 'DRAFT', content: {}, assets }),
  140. readAsset: async () => new Blob(), uploadAsset: async () => { calls.push('upload'); throw new Error('forbidden') },
  141. buildArtifacts: async () => ({ sourceFingerprint: 'current-source', artifacts: formats.map(format => ({ format, code: policy.GUIDE_DELIVERY_ASSET_CODES[format] })) }),
  142. publishProject: async (_id, _type, input) => { calls.push(input); return { id: 'g', version: 18 } },
  143. }
  144. return { calls, dependencies }
  145. }
  146. test('OFD publisher reuses the exact saved source artifacts and optimistic-lock version without uploading', async () => {
  147. const { calls, dependencies } = guidePublisherFixture('current-source')
  148. const result = await publishGuide('g', { allowUpload: false }, dependencies)
  149. assert.equal(result.project.version, 18)
  150. assert.deepEqual(result.uploadedFormats, [])
  151. assert.deepEqual(result.reusedFormats, ['OFD', 'OFFLINE_HTML'])
  152. assert.equal(calls.length, 1)
  153. assert.equal(calls[0].version, 17)
  154. assert.equal(calls[0].deliveries.length, 2)
  155. })
  156. test('OFD publisher cannot regenerate/upload stale artifacts or publish them against changed content', async () => {
  157. const { calls, dependencies } = guidePublisherFixture('stale-source')
  158. await assert.rejects(publishGuide('g', { allowUpload: false }, dependencies), /交付物/)
  159. assert.deepEqual(calls, [])
  160. })