f0-onboarding.mjs 11.5 KB
#!/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()
}