Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
147 lines
12 KiB
JavaScript
147 lines
12 KiB
JavaScript
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const crypto = require('node:crypto')
|
|
const { execFileSync } = require('node:child_process')
|
|
const { chromium } = require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
|
|
|
const directory = '/Volumes/Extreme SSD/yawan-storage/weknora/acceptance'
|
|
const base = 'http://127.0.0.1:18080/api/v1'
|
|
const prefix = `content-permission-check-${Date.now()}`
|
|
const checks = [], failures = [], cleanup = []
|
|
let admin, member, role, browser
|
|
const literal = value => `'${String(value).replaceAll("'", "''")}'`
|
|
const sql = query => execFileSync('docker', ['--context', 'colima-yawan', 'exec', '-i', 'WeKnora-postgres',
|
|
'psql', '-X', '-q', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915', '-At', '-v', 'ON_ERROR_STOP=1'],
|
|
{ input: query, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
|
|
const check = (name, value) => { checks.push({ name, passed: !!value }); assert.ok(value, name) }
|
|
async function api(route, method = 'GET', body, identity = admin, expected = 200) {
|
|
const response = await fetch(base + route, { method, headers: { 'Content-Type': 'application/json',
|
|
...(identity ? { Authorization: `Bearer ${identity.token}`, 'X-Tenant-ID': '10059' } : {}) },
|
|
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) })
|
|
const data = await response.json()
|
|
assert.ok((Array.isArray(expected) ? expected : [expected]).includes(response.status), `${method} ${route}: HTTP ${response.status}; ${data.message || data.error?.message || ''}`)
|
|
return data
|
|
}
|
|
async function setPermissions(permissions) {
|
|
role = (await api(`/access/roles/${role.id}`, 'PUT', { ...role, permissions })).data
|
|
}
|
|
async function openPage(resourceID) {
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
|
await context.addInitScript(auth => {
|
|
for (const [key, value] of Object.entries({ weknora_user: JSON.stringify({ ...auth.user, tenant_id: String(auth.user.tenant_id) }), weknora_tenant: JSON.stringify({ ...auth.active_tenant, id: '10059' }), weknora_token: auth.token, weknora_refresh_token: auth.refresh_token, weknora_memberships: JSON.stringify(auth.memberships), 'weknora:new-user-guide-done:v1': '1' })) localStorage.setItem(key, value)
|
|
}, member)
|
|
const page = await context.newPage()
|
|
await page.goto(`http://127.0.0.1:15173/platform/workbench/content?tab=tasks&resource=${resourceID}`)
|
|
await page.getByRole('heading', { name: prefix + '-task', exact: true }).waitFor()
|
|
await page.waitForLoadState('networkidle')
|
|
return { page, context }
|
|
}
|
|
function historicalState() {
|
|
return sql(`SELECT coalesce(jsonb_agg(jsonb_build_object('id',u.id,'active',u.is_active,'member_status',m.status,'member_role',m.role) ORDER BY u.id)::text,'[]')
|
|
FROM yawan_legacy_identity_mappings l JOIN users u ON u.id=l.target_user_id
|
|
JOIN tenant_members m ON m.user_id=u.id AND m.tenant_id=l.target_tenant_id
|
|
WHERE l.target_tenant_id=10059 AND l.source_user_id<>1`)
|
|
}
|
|
async function main() {
|
|
const before = historicalState()
|
|
const credentials = JSON.parse(fs.readFileSync(`${directory}/super-admin-login.json`, 'utf8'))
|
|
admin = await api('/auth/login', 'POST', { email: credentials.email, password: credentials.password }, null)
|
|
check('migration_207_applied', sql('SELECT count(*) FROM workbench_content_permission_migrations WHERE version=207') === '1')
|
|
const imported = JSON.parse(sql(`SELECT r.permissions FROM workbench_access_roles r JOIN yawan_legacy_access_mappings m
|
|
ON r.id=m.source_spec->>'id' AND r.tenant_id=m.target_tenant_id WHERE m.entity_key='role/reviewer' AND r.tenant_id=10059`))
|
|
check('legacy_reviewer_has_only_view_and_review', JSON.stringify(imported) === JSON.stringify(['content.read', 'content.review']))
|
|
const setupPermissions = ['content.read', 'content.write', 'content.review', 'content.deliver', 'agents.read', 'agents.write', 'flows.read', 'flows.write']
|
|
role = (await api('/access/roles', 'POST', { name: prefix, permissions: setupPermissions })).data
|
|
cleanup.push(() => api(`/access/roles/${role.id}`, 'DELETE'))
|
|
const password = crypto.randomBytes(18).toString('base64url')
|
|
const userForm = { username: prefix, email: `${prefix}@example.test`, password, role: 'contributor', status: 'active', custom_role_id: role.id }
|
|
const user = (await api('/access/users', 'POST', userForm)).data
|
|
cleanup.push(() => api(`/access/users/${user.id}`, 'DELETE'))
|
|
member = await api('/auth/login', 'POST', { email: userForm.email, password }, null)
|
|
const source = (await api('/agents')).data.find(agent => agent.config?.model_id)
|
|
assert.ok(source, 'Configured model fixture')
|
|
const agent = (await api('/agents', 'POST', { name: prefix + '-agent', config: {
|
|
model_id: source.config.model_id, role: 'production', system_prompt: 'Disposable permission fixture.',
|
|
knowledge_bases: [], kb_selection_mode: 'none', skills: [] } }, member, 201)).data
|
|
cleanup.push(() => api(`/agents/${agent.id}?detach=true`, 'DELETE', undefined, member))
|
|
const flow = (await api('/workbench/resources/flows', 'POST', { name: prefix + '-flow', data: { nodes: [{ id: 'writer', label: 'Writer', agent_id: agent.id }], edges: [] } }, member, 201)).data
|
|
cleanup.push(() => api(`/workbench/resources/flows/${flow.id}?detach=true`, 'DELETE', undefined, member))
|
|
let task = (await api('/workbench/resources/content-tasks', 'POST', { name: prefix + '-task', data: { source_text: 'Permission fixture outline', flow_id: flow.id } }, member, 201)).data
|
|
cleanup.push(() => api(`/workbench/resources/content-tasks/${task.id}?detach=true`, 'DELETE', undefined, member))
|
|
// Seed only this disposable task, avoiding all paid model calls.
|
|
const state = { ...task.data, segments: [{ id: 'one', title: 'First section', agent_id: agent.id, content: 'Reviewed source paragraph.', status: 'generated' },
|
|
{ id: 'two', title: 'Second section', agent_id: agent.id, content: 'Optional source paragraph.', status: 'generated' }], composed: 'Delivery permission fixture.', agent_snapshots: { [agent.id]: { name: agent.name } } }
|
|
sql(`UPDATE workbench_resources SET data=${literal(JSON.stringify(state))}::jsonb WHERE id=${literal(task.id)} AND owner_id=${literal(user.id)};`)
|
|
await setPermissions(['content.read', 'content.review'])
|
|
const reviewAccess = (await api('/access/me', 'GET', undefined, member)).data.permissions
|
|
check('reviewer_api_advertises_no_write_or_delivery', reviewAccess.includes('content.review') && !reviewAccess.includes('content.write') && !reviewAccess.includes('content.deliver'))
|
|
await api(`/workbench/resources/content-tasks/${task.id}`, 'GET', undefined, member)
|
|
for (const action of ['generate', 'revise', 'compose', 'decompose', 'import-image']) {
|
|
await api(`/workbench/resources/content-tasks/${task.id}/actions/${action}`, 'POST', { version: task.version, segment_id: 'one' }, member, 403)
|
|
}
|
|
await api(`/workbench/resources/content-tasks/${task.id}`, 'PUT', { ...task, name: 'forbidden' }, member, 403)
|
|
await api(`/workbench/resources/content-tasks/${task.id}/export`, 'GET', undefined, member, 403)
|
|
// An outdated version proves review dispatch is permitted without submitting an AI job.
|
|
await api(`/workbench/resources/content-tasks/${task.id}/actions/review`, 'POST', { version: 0 }, member, 409)
|
|
for (const kind of ['usage', 'audit', 'system']) await api(`/workbench/operations/records/${kind}`, 'GET', undefined, member, 403)
|
|
check('reviewer_api_action_export_and_admin_boundaries', true)
|
|
browser = await chromium.launch({ headless: true, channel: 'chrome' })
|
|
const reviewer = await openPage(task.id)
|
|
check('reviewer_ui_accept_enabled', await reviewer.page.getByRole('button', { name: '采用本段', exact: true }).first().isEnabled())
|
|
check('reviewer_ui_production_disabled', await reviewer.page.getByRole('button', { name: '重新生成', exact: true }).first().isDisabled())
|
|
check('reviewer_ui_delivery_hidden', await reviewer.page.getByRole('button', { name: '下载文稿', exact: true }).count() === 0)
|
|
check('reviewer_ui_edit_and_create_hidden', await reviewer.page.getByRole('button', { name: '编辑配置', exact: true }).count() === 0 && await reviewer.page.getByRole('button', { name: '创建内容任务', exact: true }).count() === 0)
|
|
await reviewer.page.screenshot({ path: `${directory}/content-reviewer-desktop.png`, animations: 'disabled' })
|
|
await reviewer.page.setViewportSize({ width: 390, height: 844 })
|
|
await reviewer.page.screenshot({ path: `${directory}/content-reviewer-mobile.png`, animations: 'disabled' })
|
|
check('reviewer_mobile_no_overflow', await reviewer.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2))
|
|
await reviewer.context.close()
|
|
const reviewJob = (await api(`/workbench/resources/content-tasks/${task.id}/actions/accept`, 'POST', { version: task.version, segment_id: 'one' }, member, 202)).data
|
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
const current = (await api(`/workbench/jobs/${reviewJob.id}`, 'GET', undefined, member)).data
|
|
if (current.status === 'succeeded') break
|
|
assert.notEqual(current.status, 'failed', `Reviewer worker failed: ${current.error}`)
|
|
assert.ok(attempt < 29, 'Reviewer worker timeout')
|
|
await new Promise(resolve => setTimeout(resolve, 500))
|
|
}
|
|
task = (await api(`/workbench/resources/content-tasks/${task.id}`, 'GET', undefined, member)).data
|
|
check('reviewer_background_job_accepts_without_generation_permission', task.data.segments[0].status === 'accepted')
|
|
await setPermissions(['content.read'])
|
|
await api(`/workbench/resources/content-tasks/${task.id}/actions/skip`, 'POST', { version: task.version, segment_id: 'two' }, member, 403)
|
|
await api(`/workbench/jobs/${reviewJob.id}/retry`, 'POST', {}, member, 403)
|
|
const reader = await openPage(task.id)
|
|
check('reader_ui_review_disabled', await reader.page.getByRole('button', { name: '跳过本段', exact: true }).first().isDisabled())
|
|
await reader.context.close()
|
|
sql(`UPDATE workbench_resources SET data=jsonb_set(data,'{composed}','"Delivery permission fixture."') WHERE id=${literal(task.id)} AND owner_id=${literal(user.id)};`)
|
|
await setPermissions(['content.read', 'content.deliver'])
|
|
check('delivery_role_exports_without_write', (await api(`/workbench/resources/content-tasks/${task.id}/export`, 'GET', undefined, member)).data.content === 'Delivery permission fixture.')
|
|
await api(`/workbench/resources/content-tasks/${task.id}/actions/accept`, 'POST', { version: task.version, segment_id: 'two' }, member, 403)
|
|
const delivery = await openPage(task.id)
|
|
check('delivery_ui_download_enabled', await delivery.page.getByRole('button', { name: '下载文稿', exact: true }).isEnabled())
|
|
const download = delivery.page.waitForEvent('download')
|
|
await delivery.page.getByRole('button', { name: '下载文稿', exact: true }).click()
|
|
check('delivery_browser_downloads_markdown', (await download).suggestedFilename() === prefix + '-task.md')
|
|
await delivery.context.close()
|
|
const foreignID = crypto.randomUUID()
|
|
sql(`INSERT INTO workbench_resources(id,tenant_id,owner_id,owner_name,kind,name,status,version,data,created_at,updated_at)
|
|
VALUES(${literal(foreignID)},10059,${literal(admin.user.id)},'admin','content-tasks',${literal(prefix + '-foreign')},'draft',1,'{"composed":"Private final"}',now(),now())`)
|
|
cleanup.push(() => sql(`DELETE FROM workbench_resources WHERE id=${literal(foreignID)} AND name=${literal(prefix + '-foreign')}`))
|
|
await api(`/workbench/resources/content-tasks/${foreignID}/export`, 'GET', undefined, member, 404)
|
|
check('delivery_keeps_owner_scope', true)
|
|
check('historical_users_and_memberships_unchanged', historicalState() === before)
|
|
check('no_ai_model_usage_created', sql(`SELECT count(*) FROM workbench_usage WHERE owner_id=${literal(user.id)}`) === '0')
|
|
}
|
|
main().catch(error => { failures.push(error.message); process.exitCode = 1 }).finally(async () => {
|
|
await browser?.close()
|
|
if (role) {
|
|
try { await setPermissions(['content.read', 'content.write', 'content.review', 'content.deliver', 'agents.read', 'agents.write', 'flows.read', 'flows.write']) }
|
|
catch (error) { failures.push('restore fixture permissions: ' + error.message); process.exitCode = 1 }
|
|
}
|
|
for (const action of cleanup.reverse()) {
|
|
try { await action() } catch (error) { failures.push('cleanup: ' + error.message); process.exitCode = 1 }
|
|
}
|
|
const report = { time: new Date().toISOString(), checks, failures, passed: failures.length === 0 }
|
|
fs.writeFileSync(`${directory}/content-permissions-live.json`, JSON.stringify(report, null, 2))
|
|
console.log(JSON.stringify(report, null, 2))
|
|
})
|