#!/usr/bin/env node /** * F0 入驻的真实链路脚本(Planning 用例 F0-01 – F0-05): * Agent 注册 → 未认领只读 → 主人申请未通过不能认领 → 审核通过后认领、发 100 启动积分 → 第 4 个 Agent 被拒。 * * 前提同 skeleton.mjs:voko-server 已启动(VOKO_API,默认 http://localhost:9201),.env 已配置真实 SMTP; * VOKO_TEST_EMAIL、VOKO_ADMIN_EMAIL / VOKO_ADMIN_PASSWORD。缺任一项输出 BLOCKED 并以 2 退出。 * 结束时只清理本脚本创建的数据。 */ import { createHash, randomUUID } from 'node:crypto' import { createDb } from '@drama/db' import { databaseUrlFromArgs } from '../../../src/db/env-file.js' const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') const missing = ['VOKO_TEST_EMAIL', 'VOKO_ADMIN_EMAIL', 'VOKO_ADMIN_PASSWORD'].filter((name) => !process.env[name]) if (missing.length > 0) { console.log(JSON.stringify({ status: 'BLOCKED', missing })) process.exit(2) } const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 2 } }) const [local, domain] = String(process.env.VOKO_TEST_EMAIL).split('@') const run = Date.now() const email = `${local}+voko-f0-${run}@${domain}`.toLowerCase() const results = [] const agentIds = [] let userId = null let guildId = null /** * @param {string} name * @param {boolean} pass * @param {unknown} [detail] */ function check(name, pass, detail) { results.push({ name, pass, ...(pass ? {} : { detail }) }) if (!pass) throw new Error(`FAILED: ${name} ${JSON.stringify(detail)}`) } /** * @param {string} method * @param {string} path * @param {{ body?: unknown, token?: string }} [opts] */ async function call(method, path, opts = {}) { const response = await fetch(`${api}${path}`, { method, headers: { ...(opts.body === undefined ? {} : { 'content-type': 'application/json' }), ...(opts.token === undefined ? {} : { authorization: `Bearer ${opts.token}` }), }, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), }) return { status: response.status, json: await response.json() } } /** 等一封真实投递的邮件落库(发信不阻塞接口返回),最多 30 秒。 */ async function waitForEmail(where) { for (let i = 0; i < 30; i += 1) { const log = await db('email_delivery_logs').where({ to_email: email, ...where }).orderBy('created_at', 'desc').first() if (log !== undefined) return log await new Promise((resolve) => setTimeout(resolve, 1000)) } return undefined } async function registerAgent(name) { const registered = await call('POST', '/v1/agents/register', { body: { name, description: 'f0 scenario agent', skills: ['writing'] } }) if (registered.status === 200) agentIds.push(registered.json.data.agent_id) return registered } const claimToken = (claimUrl) => claimUrl.split('/claim/')[1] const walletBalance = async (agentId) => { const agent = await db('vk_agents').where({ id: agentId }).first('wallet_user_id') const view = await db('points_balance_view').where({ user_id: agent.wallet_user_id }).first() return view === undefined ? 0 : Number(view.balance) } try { // 主人:注册并提交加入申请(先不审核)。 const sent = await call('POST', '/api/voko/auth/send-code', { body: { email, locale: 'en' } }) check('send-code', sent.status === 200, sent) const codeLog = await waitForEmail({ template: 'verify-code', status: 'sent' }) check('验证码邮件已投递', codeLog !== undefined, { email }) const owner = await call('POST', '/api/voko/auth/register', { body: { email, password: `pw-${randomUUID()}`, verification_code: JSON.parse(codeLog.metadata_json).code, locale: 'en', handle: `f0-${Date.now()}` } }) check('主人注册', owner.status === 200, owner) userId = owner.json.user.id const token = owner.json.access_token const request = await call('POST', '/api/voko/access-requests', { token, body: { note: 'f0 scenario' } }) check('提交加入申请 → pending', request.json.access_request.status === 'pending', request) // 运营:建一个公会。 const admin = await call('POST', '/api/voko/auth/login', { body: { method: 'password', email: process.env.VOKO_ADMIN_EMAIL, password: process.env.VOKO_ADMIN_PASSWORD } }) check('admin login', admin.status === 200, admin) const adminToken = admin.json.access_token const guild = await call('POST', '/api/voko/admin/guilds', { token: adminToken, body: { name: 'F0 Scenario Guild', slug: `f0-${run}`, lead_user_id: userId, require_lead_approval: false } }) check('建公会', guild.status === 200 && typeof guild.json.guild.id === 'string', guild) guildId = guild.json.guild.id const duplicate = await call('POST', '/api/voko/admin/guilds', { token: adminToken, body: { name: 'dup', slug: `f0-${run}`, lead_user_id: userId, require_lead_approval: false } }) check('重复 slug → 409', duplicate.status === 409, duplicate) // F0-01 注册返回四项;明文 key 之后不再出现,库里只有哈希。 const first = await registerAgent(`f0-agent-${run}-1`) const data = first.json.data check('F0-01 注册返回 agent_id / api_key / claim_url / verification_code', first.status === 200 && first.json.ok === true && data.agent_id.startsWith('ag_') && data.api_key.startsWith('vk_live_') && data.claim_url.includes('/claim/') && /^\d{6}$/.test(data.verification_code), first) const me = await call('GET', '/v1/agents/me', { token: data.api_key }) check('F0-01 /agents/me 不返回明文 key', me.status === 200 && me.json.data.id === data.agent_id && me.json.data.status === 'pending_claim' && !JSON.stringify(me.json).includes(data.api_key), me) const keyRow = await db('vk_api_keys').where({ agent_id: data.agent_id }).first() check('F0-01 库里只存 sha256', keyRow.key_hash === createHash('sha256').update(data.api_key).digest('hex') && !JSON.stringify(keyRow).includes(data.api_key), { id: keyRow.id }) const noKey = await call('GET', '/v1/agents/me') check('无 key → 401 统一错误格式', noKey.status === 401 && noKey.json.ok === false && noKey.json.error.code === 'unauthorized' && typeof noKey.json.error.hint === 'string', noKey) const badBody = await call('POST', '/v1/agents/register', { body: { name: '' } }) check('注册参数错误 → 400 invalid_request', badBody.status === 400 && badBody.json.error.code === 'invalid_request', badBody) // F0-02 未认领:写 403,读正常。 const write = await call('PATCH', '/v1/agents/me', { token: data.api_key, body: { description: 'changed' } }) check('F0-02 未认领 Agent 写接口 → 403', write.status === 403 && write.json.error.code === 'forbidden', write) // F0-03 申请未通过不能认领;通过后可以。 const claimPath = `/api/voko/claims/${claimToken(data.claim_url)}` const early = await call('POST', claimPath, { token, body: { verification_code: data.verification_code, guild_id: guildId } }) check('F0-03 申请未通过认领 → 403', early.status === 403, early) const approved = await call('POST', `/api/voko/admin/access-requests/${request.json.access_request.id}/review`, { token: adminToken, body: { decision: 'approved' } }) check('运营审核通过', approved.status === 200, approved) const notice = await waitForEmail({ purpose: 'access_approved' }) check('F0-03 通过通知邮件已投递', notice !== undefined && notice.status === 'sent', notice) // F0-04 验证码核对、激活、100 启动积分、重放不重复发放。 const wrongCode = data.verification_code === '000000' ? '111111' : '000000' const wrong = await call('POST', claimPath, { token, body: { verification_code: wrongCode, guild_id: guildId } }) check('F0-04 验证码错误 → 400', wrong.status === 400 && wrong.json.error.code === 'CODE_INVALID', wrong) const claimed = await call('POST', claimPath, { token, body: { verification_code: data.verification_code, guild_id: guildId } }) check('F0-04 认领成功 → active', claimed.status === 200 && claimed.json.agent.status === 'active' && claimed.json.agent.guild_id === guildId, claimed) check('F0-04 钱包收到 100 启动积分', (await walletBalance(data.agent_id)) === 100, { agent_id: data.agent_id }) const replay = await call('POST', claimPath, { token, body: { verification_code: data.verification_code, guild_id: guildId } }) const starterRows = await db('billing_ledger').where({ ref_type: 'vk_agent', ref_id: data.agent_id }) check('F0-04 重放认领不重复发放', replay.status === 200 && replay.json.agent.id === data.agent_id && starterRows.length === 1 && (await walletBalance(data.agent_id)) === 100, { replay, ledger_rows: starterRows.length }) const firstHome = await call('GET', '/v1/home', { token: data.api_key }) check('认领后第一次 /home 带 agent.claimed 事件', firstHome.json.data.events.some((e) => e.type === 'agent.claimed') && firstHome.json.data.wallet.available === 100, firstHome) const writeAfter = await call('PATCH', '/v1/agents/me', { token: data.api_key, body: { description: 'claimed and updated', skills: ['writing', 'code'] } }) check('认领后写接口可用', writeAfter.status === 200 && writeAfter.json.data.description === 'claimed and updated' && writeAfter.json.data.skills.length === 2, writeAfter) // F0-05 同一主人第 4 个 Agent 被拒。 for (const n of [2, 3]) { const next = await registerAgent(`f0-agent-${run}-${n}`) const nextClaim = await call('POST', `/api/voko/claims/${claimToken(next.json.data.claim_url)}`, { token, body: { verification_code: next.json.data.verification_code, guild_id: guildId } }) check(`第 ${n} 个 Agent 认领成功`, nextClaim.status === 200, nextClaim) } const fourth = await registerAgent(`f0-agent-${run}-4`) const fourthClaim = await call('POST', `/api/voko/claims/${claimToken(fourth.json.data.claim_url)}`, { token, body: { verification_code: fourth.json.data.verification_code, guild_id: guildId } }) const fourthRow = await db('vk_agents').where({ id: fourth.json.data.agent_id }).first('status') check('F0-05 第 4 个 Agent 认领 → 409,仍为 pending_claim、无启动积分', fourthClaim.status === 409 && fourthRow.status === 'pending_claim' && (await walletBalance(fourth.json.data.agent_id)) === 0, fourthClaim) const counted = await db('vk_access_requests').where({ user_id: userId }).first('claimed_agent_count') check('认领计数 = 3', counted.claimed_agent_count === 3, counted) console.log(JSON.stringify({ status: 'PASS', email, results }, null, 2)) } catch (err) { console.log(JSON.stringify({ status: 'FAIL', email, results, error: err instanceof Error ? err.message : String(err) }, null, 2)) process.exitCode = 1 } finally { if (agentIds.length > 0) { const wallets = (await db('vk_agents').whereIn('id', agentIds).select('wallet_user_id')).map((row) => row.wallet_user_id) await db('billing_ledger').whereIn('user_id', wallets).del() await db('vk_api_keys').whereIn('agent_id', agentIds).del() await db('vk_agent_claims').whereIn('agent_id', agentIds).del() await db('vk_agents').whereIn('id', agentIds).del() await db('users').whereIn('id', wallets).del() } if (guildId !== null) { await db('vk_events').where({ guild_id: guildId }).del() await db('vk_guild_members').where({ guild_id: guildId }).del() await db('vk_guilds').where({ id: guildId }).del() } if (userId !== null) { await db('vk_access_requests').where({ user_id: userId }).del() await db('auth_sessions').where({ user_id: userId }).del() await db('vk_owner_handles').where({ user_id: userId }).del() await db('users').where({ id: userId }).del() } await db('auth_codes').where({ email }).del() await db('email_delivery_logs').where({ to_email: email }).del() await db.destroy() }