f0-onboarding.mjs
11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
#!/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' } })
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('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()
}