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
98 lines
9.0 KiB
JavaScript
98 lines
9.0 KiB
JavaScript
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
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 = path.resolve(__dirname, '../../acceptance')
|
|
const base = 'http://127.0.0.1:18080/api/v1'
|
|
const checks = [], browserErrors = []
|
|
const prefix = `quota-check-${Date.now()}`
|
|
let admin, member, user, browser, failure, stage = 'login'
|
|
const sql = statement => execFileSync('docker', ['--context', 'colima-yawan', 'exec', 'WeKnora-postgres', 'psql', '-U', 'postgres', '-d', 'yawan_native_acceptance_20260915', '-v', 'ON_ERROR_STOP=1', '-Atc', statement], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
|
const literal = value => "'" + String(value).replaceAll("'", "''") + "'"
|
|
function check(name, passed) { checks.push({ name, passed: !!passed }); assert.ok(passed, 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) })
|
|
assert.equal(response.status, expected, `${stage}: ${method} ${route}: HTTP ${response.status}`)
|
|
return response.json()
|
|
}
|
|
async function main() {
|
|
const credentials = JSON.parse(fs.readFileSync(path.join(directory, 'super-admin-login.json'), 'utf8'))
|
|
admin = await api('/auth/login', 'POST', { email: credentials.email, password: credentials.password }, null)
|
|
const password = crypto.randomBytes(18).toString('base64url')
|
|
const userForm = { username: prefix, email: `${prefix}@example.test`, password, role: 'contributor', status: 'active', custom_role_id: '' }
|
|
user = (await api('/access/users', 'POST', userForm)).data
|
|
member = await api('/auth/login', 'POST', { email: userForm.email, password }, null)
|
|
stage = 'api'
|
|
const body = { period: 'daily', token_limit: 1000, points_limit: 10000, storage_limit_bytes: 1024, concurrent_limit: 2, warning_ratio: 0.8, overage_policy: 'hard_limit', enabled: true }
|
|
const saved = (await api(`/workbench/operations/quotas/${user.id}`, 'PUT', body)).data
|
|
check('admin_saves_daily_quota', saved.token_limit === body.token_limit && saved.storage_limit_bytes === 1024)
|
|
await api(`/workbench/operations/quotas/${user.id}`, 'PUT', { ...body, period: 'monthly', token_limit: 10000, points_limit: 100000 })
|
|
await api(`/workbench/operations/quotas/${user.id}`, 'PUT', { ...body, token_limit: -1 }, admin, 400)
|
|
await api(`/workbench/operations/quotas/${crypto.randomUUID()}`, 'PUT', body, admin, 404)
|
|
for (const role of ['contributor', 'viewer']) {
|
|
if (role === 'viewer') await api(`/access/users/${user.id}`, 'PUT', { ...userForm, password: '', role })
|
|
await api('/workbench/operations/quotas', 'GET', undefined, member, 403)
|
|
await api(`/workbench/operations/quotas/${user.id}`, 'PUT', body, member, 403)
|
|
await api(`/workbench/operations/quotas/${saved.id}`, 'DELETE', undefined, member, 403)
|
|
}
|
|
check('members_cannot_read_or_change_quotas', true)
|
|
const usageID = crypto.randomUUID(), holdID = crypto.randomUUID(), userID = literal(user.id)
|
|
sql(`BEGIN; INSERT INTO workbench_usage (id, tenant_id, owner_id, call_id, input_tokens, output_tokens, points, status, created_at) VALUES (${literal(usageID)},10059,${userID},${literal(usageID)},700,200,9000,'succeeded',now()); INSERT INTO workbench_balance_holds (id,tenant_id,user_id,amount,status,created_at,expires_at) VALUES (${literal(holdID)},10059,${userID},500,'reserved',now(),now()+interval '10 minutes'); UPDATE workbench_balances SET reserved=500 WHERE tenant_id=10059 AND user_id=${userID}; COMMIT;`)
|
|
let rows = (await api('/workbench/operations/quotas')).data.filter(row => row.user_id === user.id)
|
|
const daily = rows.find(row => row.period === 'daily')
|
|
check('daily_and_monthly_coexist', rows.length === 2)
|
|
check('warning_includes_settled_and_reserved', daily.used.tokens === 900 && daily.used.points === 9000 && daily.used.reserved_tokens === 50 && daily.used.active_calls === 1 && daily.warning && daily.allowed)
|
|
check('explicit_shanghai_period_and_unenforced_storage', daily.period_timezone === 'Asia/Shanghai' && daily.storage_enforced === false && daily.storage_limit_bytes === 1024)
|
|
await api(`/workbench/operations/quotas/${user.id}`, 'PUT', { ...body, enabled: false })
|
|
rows = (await api('/workbench/operations/quotas')).data.filter(row => row.user_id === user.id)
|
|
check('disabled_quota_allows_without_warning', rows.find(row => row.period === 'daily').allowed && !rows.find(row => row.period === 'daily').warning)
|
|
await api(`/workbench/operations/quotas/${user.id}`, 'PUT', body)
|
|
stage = 'browser'
|
|
browser = await chromium.launch({ headless: true, channel: 'chrome' })
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
|
await context.addInitScript(a => {
|
|
const values = { weknora_user: JSON.stringify({ ...a.user, tenant_id: String(a.user.tenant_id) }), weknora_tenant: JSON.stringify({ ...a.active_tenant, id: '10059' }), weknora_token: a.token, weknora_refresh_token: a.refresh_token, weknora_memberships: JSON.stringify(a.memberships), weknora_selected_tenant_id: '10059' }
|
|
for (const [key, value] of Object.entries(values)) localStorage.setItem(key, value)
|
|
localStorage.setItem('weknora:new-user-guide-done:v1', '1')
|
|
}, admin)
|
|
const page = await context.newPage(); page.setDefaultTimeout(15000)
|
|
page.on('pageerror', error => browserErrors.push(error.message))
|
|
await page.goto('http://127.0.0.1:15173/platform/workbench/operations/usage')
|
|
const panel = page.locator('.quota-panel')
|
|
await panel.getByRole('heading', { name: '成员配额' }).waitFor()
|
|
let row = panel.locator('tbody tr').filter({ hasText: prefix }).filter({ hasText: '每日' })
|
|
await row.getByText('用量预警', { exact: true }).waitFor()
|
|
await row.getByRole('button', { name: '编辑', exact: true }).click()
|
|
const dialog = page.locator('.t-dialog:visible')
|
|
const field = dialog.locator('.t-form__item').filter({ hasText: 'Token 上限(0 不限)' }).locator('input')
|
|
await field.fill('2000'); await field.press('Tab')
|
|
await dialog.getByRole('button', { name: '保存配额', exact: true }).click()
|
|
await dialog.waitFor({ state: 'hidden' })
|
|
check('browser_edit_persists', (await api('/workbench/operations/quotas')).data.find(item => item.id === saved.id).token_limit === 2000)
|
|
await page.screenshot({ path: path.join(directory, 'usage-quotas-desktop.png'), animations: 'disabled' })
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await page.screenshot({ path: path.join(directory, 'usage-quotas-mobile.png'), animations: 'disabled' })
|
|
check('mobile_has_no_document_overflow', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2))
|
|
await page.setViewportSize({ width: 1440, height: 1000 })
|
|
row = panel.locator('tbody tr').filter({ hasText: prefix }).filter({ hasText: '每日' })
|
|
await row.getByRole('button', { name: '删除', exact: true }).click()
|
|
await page.locator('.t-popconfirm:visible').getByRole('button', { name: '确定', exact: true }).click()
|
|
await row.waitFor({ state: 'detached' })
|
|
check('browser_delete_persists', !(await api('/workbench/operations/quotas')).data.some(item => item.id === saved.id))
|
|
check('browser_has_no_errors', browserErrors.length === 0)
|
|
}
|
|
main().catch(error => { failure = error.message; process.exitCode = 1 }).finally(async () => {
|
|
if (browser) await browser.close()
|
|
if (user) {
|
|
const userID = literal(user.id)
|
|
sql(`BEGIN; DELETE FROM workbench_usage_quotas WHERE tenant_id=10059 AND user_id=${userID}; DELETE FROM workbench_usage WHERE tenant_id=10059 AND owner_id=${userID}; DELETE FROM workbench_balance_holds WHERE tenant_id=10059 AND user_id=${userID}; DELETE FROM workbench_balances WHERE tenant_id=10059 AND user_id=${userID}; COMMIT;`)
|
|
await api(`/access/users/${user.id}`, 'DELETE').catch(error => { failure ||= `cleanup: ${error.message}`; process.exitCode = 1 })
|
|
check('temporary_quota_and_usage_removed', sql(`SELECT (SELECT count(*) FROM workbench_usage_quotas WHERE user_id=${userID})+(SELECT count(*) FROM workbench_usage WHERE owner_id=${userID})+(SELECT count(*) FROM workbench_balance_holds WHERE user_id=${userID})`) === '0')
|
|
}
|
|
const report = { time: new Date().toISOString(), stage, checks, browser_errors: browserErrors, failure, passed: !failure, limits: ['Warning totals verified with disposable ledger fixtures; no external paid model invoked.', 'Atomic reservation and settlement races verified separately against an isolated PostgreSQL schema.', 'Storage quota is configuration only, matching legacy behavior.'] }
|
|
fs.writeFileSync(path.join(directory, 'usage-quota-live-20260917.json'), JSON.stringify(report, null, 2))
|
|
console.log(JSON.stringify(report, null, 2))
|
|
})
|