Files
hermes-agent/apps/desktop/electron/user-account-ipc.test.ts
T
m4 fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
feat: consolidate desktop and provider updates
2026-09-27 17:37:19 +08:00

1092 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import { test } from 'vitest'
import type { FetchLike } from './relay-account'
import { registerUserAccountIpc } from './user-account-ipc'
import { loadRememberedLogin, loadUserAccount, persistRememberedLogin, persistUserAccount, type UserAccountStoreIo } from './user-account-store'
function fakeIo() {
let fileText: string | null = null
const io: UserAccountStoreIo = {
encrypt: plaintext => ({ encoding: 'safeStorage', value: Buffer.from(plaintext, 'utf8').toString('base64') }),
decrypt: secret => (secret?.encoding === 'safeStorage' ? Buffer.from(String(secret.value), 'base64').toString('utf8') : ''),
readStoreText: () => {
if (fileText === null) {throw new Error('ENOENT')}
return fileText
},
writeStoreText: text => {
fileText = text
}
}
return { io, fileText: () => fileText }
}
function fakeIpcMain() {
const handlers = new Map<string, (event: any, payload?: any) => Promise<any>>()
return {
handlers,
ipcMain: { handle: (channel: string, fn: any) => handlers.set(channel, fn) }
}
}
function makeFetcher(behavior: (url: string, init?: any) => { status: number; body?: any; text?: string }) {
const calls: { url: string; init?: any }[] = []
const fetcher: FetchLike = async (url, init) => {
calls.push({ url, init })
const { status, body, text } = behavior(url, init)
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => text ?? JSON.stringify(body ?? '')
}
}
return { fetcher, calls }
}
const TOKENS = { access_token: 'AT-1', refresh_token: 'RT-1', session_id: 'SID-1' }
const ME = { account: { email: 'u@example.com', nickname: '小赫' }, data_as_of: 't0' }
function loginOkFetcher() {
return makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
return { status: 200, body: { status: 'succeeded' } }
})
}
test('store 往返:加密落盘并可复原;空槽读 null;清零后读 null', () => {
const { io } = fakeIo()
assert.equal(loadUserAccount(io), null)
const account = {
session: { site: 'https://r', accessToken: 'AT', refreshToken: 'RT', sessionId: 'SID' },
profile: { email: 'u@example.com', username: '', nickname: '', phone: '', planId: '', periodEnd: '', dataAsOf: 't' },
profileAsOf: 't'
}
persistUserAccount(account, io)
// 落盘文本里绝不出现明文 refresh token(加密纪律,D-18 同口径)
assert.equal(io.readStoreText().includes('RT'), false)
assert.deepEqual(loadUserAccount(io), account)
persistUserAccount(null, io)
assert.equal(loadUserAccount(io), null)
})
test('login 持久化会话并返回 profile;token 不出 IPC 返回值', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
const result = await handlers.get('hermes:account:login')!(null, {
site: 'https://relay.example.com/',
email: 'u@example.com',
password: 'pw'
})
assert.equal(result.ok, true)
assert.equal(result.site, 'https://relay.example.com')
assert.equal(result.profile.nickname, '小赫')
assert.equal(JSON.stringify(result).includes('RT-1'), false)
const stored = loadUserAccount(io)
assert.equal(stored?.session.refreshToken, 'RT-1')
assert.equal(stored?.profile?.email, 'u@example.com')
})
test('status 未登录返回 loggedIn:false', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
test('me 401 → 自动 refresh 一次重试成功,轮换后的令牌落盘', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
let meCalls = 0
const { fetcher, calls } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/auth/refresh')) {
return { status: 200, body: { access_token: 'AT-2', refresh_token: 'RT-2', session_id: 'SID-1' } }
}
if (url.endsWith('/me')) {
meCalls += 1
// login 时那次 /me 成功;之后第一次 401,refresh 后成功
return meCalls === 2 ? { status: 401, text: 'unauthenticated' } : { status: 200, body: ME }
}
return { status: 200, body: {} }
})
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const me = await handlers.get('hermes:account:me')!(null)
assert.equal(me.profile.email, 'u@example.com')
assert.equal(calls.filter(c => c.url.endsWith('/auth/refresh')).length, 1)
assert.equal(loadUserAccount(io)?.session.refreshToken, 'RT-2')
// 重试用的是新 access token
const meAuths = calls.filter(c => c.url.endsWith('/me')).map(c => c.init?.headers?.authorization)
assert.deepEqual(meAuths, ['Bearer AT-1', 'Bearer AT-1', 'Bearer AT-2'])
})
test('refresh 也被拒 = 服务端判过期:上报 sessionExpired,但保留本地会话(U-3 离线优先)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
let meCalls = 0
const { fetcher } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/auth/refresh')) {return { status: 401, text: 'session_inactive' }}
if (url.endsWith('/me')) {
meCalls += 1
return meCalls === 1 ? { status: 200, body: ME } : { status: 401, text: 'unauthenticated' }
}
return { status: 200, body: {} }
})
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const status = await handlers.get('hermes:account:status')!(null)
// 在线行为不变(renderer 照旧落登录页)……
assert.deepEqual(status, { loggedIn: false, sessionExpired: true })
// ……但会话不再被当场清掉:离线时它就是放行的凭证。
assert.notEqual(loadUserAccount(io), null)
})
/**
* 可切换在线的 fetcher:断网时连调用都不记(`calls` 只收在线尝试),
* 「不联网」的断言因此可以直接数 calls。
*/
function toggleableFetcher(isOnline: () => boolean) {
const calls: { url: string; init?: any }[] = []
const fetcher: FetchLike = async (url, init) => {
if (!isOnline()) {throw new Error('ECONNREFUSED')}
calls.push({ url, init })
if (url.endsWith('/auth/login')) {return { ok: true, status: 200, json: async () => TOKENS, text: async () => ''}}
if (url.endsWith('/me')) {return { ok: true, status: 200, json: async () => ME, text: async () => ''}}
return { ok: true, status: 200, json: async () => ({}), text: async () => ''}
}
return { fetcher, calls }
}
test('服务端判过期后断网:本地会话仍在 → status 离线放行并回缓存 profile', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
let meCalls = 0
const { fetcher } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/auth/refresh')) {return { status: 401, text: 'session_inactive' }}
if (url.endsWith('/me')) {
meCalls += 1
return meCalls === 1 ? { status: 200, body: ME } : { status: 401, text: 'unauthenticated' }
}
return { status: 200, body: {} }
})
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
// 在线:判过期,落登录页
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false, sessionExpired: true })
// 断网:这次不再砖——本地会话还在就放行,缓存 profile 与数据时间照给
const offline = toggleableFetcher(() => false)
register({ ipcMain, io, fetcher: offline.fetcher, readMachineBindingState: unbound })
const status = await handlers.get('hermes:account:status')!(null)
assert.equal(status.loggedIn, true)
assert.equal(status.offline, true)
assert.equal(status.profile.email, 'u@example.com')
assert.equal(typeof status.profileAsOf, 'string')
})
test('断网登录:记住的凭据命中且会话在 → 离线放行(不新建会话、不落盘)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
let online = true
const probe = toggleableFetcher(() => online)
register({ ipcMain, io, fetcher: probe.fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const before = loadUserAccount(io)
online = false
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.equal(result.ok, true)
assert.equal(result.offline, true)
assert.equal(result.profile.email, 'u@example.com')
// 会话原样留着
assert.deepEqual(loadUserAccount(io), before)
})
test('断网登录:口令不符 → invalid_credentials;站点/邮箱不命中 → network', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
let online = true
const probe = toggleableFetcher(() => online)
register({ ipcMain, io, fetcher: probe.fetcher, readMachineBindingState: unbound })
// 先在线登录一次,把记住槽写下来(离线登录的前提就是"这台机器上登过")
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
online = false
const attemptsBefore = probe.calls.length
assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'other@example.com', password: 'p' }), {
ok: false,
code: 'network'
})
assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'WRONG' }), {
ok: false,
code: 'invalid_credentials'
})
// 断网期间一次网络尝试都没有
assert.equal(probe.calls.length, attemptsBefore)
})
test('断网登录:本机无会话 → network(离线登入只解锁已有会话,不新建)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
await handlers.get('hermes:account:logout')!(null)
assert.equal(loadUserAccount(io), null)
// 登出不清记住槽(U-6 全清才清),但离线登不回来:没有会话就没有可放行的东西。
const offline = toggleableFetcher(() => false)
register({ ipcMain, io, fetcher: offline.fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }), {
ok: false,
code: 'network'
})
assert.equal(loadUserAccount(io), null)
})
test('断网登录:绑定的站与登录的站不符 → machine_bound_to_other(本地可判的 U-5 部分)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const probe = toggleableFetcher(() => false)
persistUserAccount(
{
session: { site: 'https://other', accessToken: 'AT', refreshToken: 'RT', sessionId: 'SID' },
profile: null,
profileAsOf: null
},
io
)
register({ ipcMain, io, fetcher: probe.fetcher, readMachineBindingState: () => BINDING_STATE })
assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://other', email: 'u', password: 'p' }), {
ok: false,
code: 'machine_bound_to_other',
binderEmail: null
})
})
test('relay 不可达但本地会话在:status 标离线并回缓存 profile + 数据时间(U-3)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
let online = true
const fetcher: FetchLike = async url => {
if (!online) {throw new Error('ECONNREFUSED')}
if (url.endsWith('/auth/login')) {
return { ok: true, status: 200, json: async () => TOKENS, text: async () => '' }
}
return { ok: true, status: 200, json: async () => ME, text: async () => '' }
}
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
online = false
const status = await handlers.get('hermes:account:status')!(null)
assert.equal(status.loggedIn, true)
assert.equal(status.offline, true)
assert.equal(status.profile.email, 'u@example.com')
assert.equal(typeof status.profileAsOf, 'string')
})
test('logout 尽力通知服务端,本地清零;服务端 401 不阻塞', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/auth/logout')) {return { status: 401, text: 'unauthenticated' }}
return { status: 200, body: {} }
})
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const result = await handlers.get('hermes:account:logout')!(null)
assert.deepEqual(result, { ok: true })
assert.equal(loadUserAccount(io), null)
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
function register(deps: {
ipcMain: ReturnType<typeof fakeIpcMain>['ipcMain']
io: UserAccountStoreIo
fetcher: FetchLike
readMachineBindingState: () => string
callPluginApi?: (path: string, body: Record<string, unknown>, method?: 'GET' | 'POST') => Promise<any>
}): void {
registerUserAccountIpc({
...deps,
callPluginApi: deps.callPluginApi ?? (async () => { throw new Error('unexpected plugin api call') }),
machineName: 'test-machine'
})
}
function unbound(): string {
throw new Error('ENOENT')
}
const BINDING_STATE = JSON.stringify({
relay_binding: {
site: 'https://r',
agent_id: 'inst-1',
route: 'route-1',
host_key_fingerprint: 'AAAA-AA',
key_epoch: 1,
host_refresh_token: 'SHOULD-NOT-BE-READ',
protocol_version: 1,
bound_at: '2026-09-19T00:00:00Z'
}
})
function u5Fetcher(ownerEmail: string | null, ownedIds: string[]) {
return makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/installations')) {return { status: 200, body: { items: ownedIds.map(id => ({ id })) } }}
if (url.includes('/agent/binding/owner')) {
return ownerEmail ? { status: 200, body: { email: ownerEmail } } : { status: 404, text: 'not_visible' }
}
if (url.endsWith('/auth/logout')) {return { status: 200, body: { status: 'succeeded' } }}
return { status: 200, body: {} }
})
}
test('U-5:本机绑定属于登录者本人 → 放行', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = u5Fetcher('u@example.com', ['inst-1'])
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u@example.com', password: 'pw' })
assert.equal(result.ok, true)
assert.equal(loadUserAccount(io)?.session.refreshToken, 'RT-1')
})
test('U-5:他账号登录 → 当场销毁会话,返回锁码 + 绑定者邮箱', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = u5Fetcher('owner@example.com', ['inst-other'])
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'intruder@example.com', password: 'pw' })
assert.deepEqual(result, { ok: false, code: 'machine_bound_to_other', binderEmail: 'owner@example.com' })
// 会话当场销毁:通知了服务端 logout,本地无一物落盘。
assert.equal(calls.filter(c => c.url.endsWith('/auth/logout')).length, 1)
assert.equal(loadUserAccount(io), null)
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
test('U-5:绑定在别的站点 → 同样拒绝', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = u5Fetcher(null, [])
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://other', email: 'u@example.com', password: 'pw' })
assert.equal(result.ok, false)
assert.equal(result.code, 'machine_bound_to_other')
assert.equal(loadUserAccount(io), null)
})
test('U-5:绑定状态损坏 fail-closed,新会话一并销毁', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = u5Fetcher(null, [])
register({ ipcMain, io, fetcher, readMachineBindingState: () => '{broken json' })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u@example.com', password: 'pw' })
assert.deepEqual(result, { ok: false, code: 'binding_state_invalid' })
assert.equal(calls.filter(c => c.url.endsWith('/auth/logout')).length, 1)
assert.equal(loadUserAccount(io), null)
})
test('login 预期失败走结构化返回:invalid_credentials / login_rate_limited / network', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(() => ({ status: 401, text: 'invalid_credentials' }))
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }), {
ok: false,
code: 'invalid_credentials'
})
assert.equal(loadUserAccount(io), null)
})
test('register/reset 四通道透传路径与参数', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = makeFetcher(() => ({ status: 202, body: { status: 'verification_sent' } }))
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:register-start')!(null, { site: 'https://r', email: 'u@e.c', password: 'pw' }), { ok: true })
assert.deepEqual(await handlers.get('hermes:account:register-resend')!(null, { site: 'https://r', email: 'u@e.c' }), { ok: true })
assert.deepEqual(await handlers.get('hermes:account:reset-request')!(null, { site: 'https://r', email: 'u@e.c' }), { ok: true })
assert.deepEqual(
await handlers.get('hermes:account:reset-confirm')!(null, { site: 'https://r', email: 'u@e.c', code: '123456', password: 'new' }),
{ ok: true }
)
const paths = calls.map(c => c.url.replace('https://r', ''))
assert.deepEqual(paths, [
'/api/v2/registration/start',
'/api/v2/registration/resend',
'/api/v2/auth/password-reset/request',
'/api/v2/auth/password-reset/confirm'
])
assert.deepEqual(JSON.parse(calls[3].init.body), { email: 'u@e.c', code: '123456', password: 'new' })
})
test('successful password resets clear remembered credentials only for the matching site and email', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
let success = true
const { fetcher } = makeFetcher(() => success
? { status: 200, body: {} }
: { status: 400, text: 'reset_code_invalid_or_expired' })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
const remembered = { site: 'https://r', email: 'owner@example.com', password: 'old-password' }
const reset = handlers.get('hermes:account:reset-confirm')!
persistRememberedLogin(remembered, io)
for (const account of [
{ site: 'https://other', email: remembered.email },
{ site: remembered.site, email: 'other@example.com' }
]) {
assert.deepEqual(await reset(null, { ...account, code: '123456', password: 'new-password' }), { ok: true })
assert.deepEqual(loadRememberedLogin(io), remembered)
}
success = false
assert.deepEqual(await reset(null, { ...remembered, code: 'bad-code' }), {
ok: false, code: 'reset_code_invalid_or_expired'
})
assert.deepEqual(loadRememberedLogin(io), remembered)
success = true
assert.deepEqual(await reset(null, {
site: 'https://r/', email: 'OWNER@example.com', code: '123456', password: 'new-password'
}), { ok: true })
assert.equal(loadRememberedLogin(io), null)
})
test('register-start 失败码透传(weak_password / registration_rate_limited)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(() => ({ status: 400, text: 'weak_password' }))
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:register-start')!(null, { site: 'https://r', email: 'u@e.c', password: 'x' }), {
ok: false,
code: 'weak_password'
})
})
test('update-profile:PATCH /me 白名单三字段,合并回完整快照落盘', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = makeFetcher((url, init) => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me') && init?.method === 'PATCH') {
return { status: 200, body: { profile: { email: 'u@example.com', username: '', nickname: '新小赫', phone: '', status: 'active' } } }
}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
return { status: 200, body: {} }
})
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const result = await handlers.get('hermes:account:update-profile')!(null, {
nickname: '新小赫',
phone: null,
email: 'hacker@example.com',
bogus: 1
})
assert.equal(result.ok, true)
const patchCall = calls.find(c => c.init?.method === 'PATCH')
assert.equal(patchCall?.url, 'https://r/api/v2/me')
assert.equal(patchCall?.init?.headers?.authorization, 'Bearer AT-1')
// email/bogus 被丢弃;null 清空原样转发
assert.deepEqual(JSON.parse(patchCall?.init?.body), { nickname: '新小赫', phone: null })
// 快照合并:PATCH 回的四字段覆盖,plan/periodEnd/dataAsOf 保留
const stored = loadUserAccount(io)
assert.equal(stored?.profile?.nickname, '新小赫')
assert.equal(stored?.profile?.dataAsOf, 't0')
assert.equal(result.profile.dataAsOf, 't0')
})
test('update-profile:空补丁不出网;服务端拒绝码透传', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
return { status: 200, body: {} }
})
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const before = calls.length
assert.deepEqual(await handlers.get('hermes:account:update-profile')!(null, { email: 'x@y.z' }), {
ok: false,
code: 'nothing_to_update'
})
assert.equal(calls.length, before)
})
test('update-profile:服务端 403 profile_self_service_disabled 结构化透传', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher((url, init) => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me') && init?.method === 'PATCH') {return { status: 403, text: 'profile_self_service_disabled' }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
return { status: 200, body: {} }
})
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.deepEqual(await handlers.get('hermes:account:update-profile')!(null, { nickname: 'x' }), {
ok: false,
code: 'profile_self_service_disabled'
})
// 失败不落盘改动
assert.equal(loadUserAccount(io)?.profile?.nickname, '小赫')
})
// ---------- DB-T4b:绑定打通 + U-6 解绑全清(§21) ----------------------------
test('binding-status:未绑定 / 已绑定只回展示字段 / 损坏 fail-closed 标记', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:binding-status')!(null), {
bound: false,
machineId: null,
pending: null,
bindError: null
})
})
test('binding-status:已绑定不含凭据字段;损坏报 stateInvalid', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const bound = await handlers.get('hermes:account:binding-status')!(null)
assert.deepEqual(bound, { bound: true, site: 'https://r', installationId: 'inst-1', machineId: null })
assert.equal(JSON.stringify(bound).includes('SHOULD-NOT-BE-READ'), false)
const { handlers: h2, ipcMain: i2 } = fakeIpcMain()
register({ ipcMain: i2, io: fakeIo().io, fetcher, readMachineBindingState: () => '{broken' })
assert.deepEqual(await h2.get('hermes:account:binding-status')!(null), {
bound: false,
stateInvalid: true,
machineId: null,
pending: null,
bindError: null
})
})
test('bind-machine:插件收到 site+session_token(Bearer 等价物)+suggested_name;不落 email/password', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
const pluginCalls: { path: string; body: any }[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async (path, body) => {
pluginCalls.push({ path, body })
return { state: 'bound', created: true }
}
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const result = await handlers.get('hermes:account:bind-machine')!(null)
assert.deepEqual(result, { ok: true, state: 'bound' })
// DB-T6:login 已自动绑定一次;手动按钮走的是同一条通路(fake 绑定态恒为未绑定,故再次发起)。
assert.equal(pluginCalls.length, 2)
for (const call of pluginCalls) {
assert.equal(call.path, 'identity/login')
assert.equal(call.body.site, 'https://r')
assert.equal(call.body.session_token, 'AT-1')
assert.equal(call.body.suggested_name, 'test-machine')
assert.equal('password' in call.body, false)
}
})
test('bind-machine:已绑定不出网(already_bound);未登录 not_logged_in', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
let pluginCalls = 0
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => {
pluginCalls += 1
return { state: 'bound' }
}
})
assert.deepEqual(await handlers.get('hermes:account:bind-machine')!(null), { ok: false, code: 'already_bound' })
assert.equal(pluginCalls, 0)
})
test('bind-machine:binding_pending 原样透传确认码', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async () => ({ state: 'binding_pending', code: 'MR-7K2P-Q9', expires_at: '2026-09-19T12:00:00Z' })
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.deepEqual(await handlers.get('hermes:account:bind-machine')!(null), {
ok: true,
state: 'binding_pending',
code: 'MR-7K2P-Q9',
expiresAt: '2026-09-19T12:00:00Z'
})
})
test('unbind-machine(U-6):服务端撤销 → 插件 purge → 删所有会话;本地会话清零', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/installations')) {return { status: 200, body: { items: [{ id: 'inst-1' }] } }}
if (url.includes('/installations/')) {return { status: 202, body: { status: 'requested' } }}
return { status: 200, body: {} }
})
const pluginCalls: string[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async path => {
pluginCalls.push(path)
return { state: 'purged' }
}
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.notEqual(loadUserAccount(io), null)
const result = await handlers.get('hermes:account:unbind-machine')!(null)
assert.deepEqual(result, { ok: true })
// 1. 服务端撤销带用户 Bearer
const revoke = calls.find(c => c.url.includes('/installations/'))
assert.equal(revoke?.init?.method, 'DELETE')
assert.equal(revoke?.init?.headers?.authorization, 'Bearer AT-1')
assert.ok(revoke!.url.endsWith('/installations/inst-1'))
// 2. 插件 purge
assert.deepEqual(pluginCalls, ['identity/purge'])
// 3. 会话清零 + 尽力服务端吊销
assert.equal(loadUserAccount(io), null)
assert.ok(calls.some(c => c.url.endsWith('/auth/logout')))
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
test('unbind-machine:服务端撤销失败不阻塞本地全清(尽力语义)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/installations')) {return { status: 200, body: { items: [{ id: 'inst-1' }] } }}
if (url.includes('/installations/')) {return { status: 503, text: 'server' }}
return { status: 200, body: {} }
})
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => ({ state: 'purged' })
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.deepEqual(await handlers.get('hermes:account:unbind-machine')!(null), { ok: true })
assert.equal(loadUserAccount(io), null)
})
test('unbind-machine:插件不可达则结构化失败且会话保留(不留半清假象)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/installations')) {return { status: 200, body: { items: [{ id: 'inst-1' }] } }}
return { status: 200, body: {} }
})
const { PluginApiError } = await import('./user-account-ipc')
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => {
throw new PluginApiError('plugin_unreachable', 0)
}
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.deepEqual(await handlers.get('hermes:account:unbind-machine')!(null), { ok: false, code: 'plugin_unreachable' })
assert.notEqual(loadUserAccount(io), null)
})
test('unbind-machine:未绑定 not_bound,不出网', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = loginOkFetcher()
let pluginCalls = 0
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async () => {
pluginCalls += 1
return {}
}
})
assert.deepEqual(await handlers.get('hermes:account:unbind-machine')!(null), { ok: false, code: 'not_bound' })
assert.equal(pluginCalls, 0)
assert.equal(calls.some(c => c.url.includes('/installations/')), false)
})
test('登出≠解绑:logout 绝不碰插件 API 与 installation 端点', async () => { const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = loginOkFetcher()
let pluginCalls = 0
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => {
pluginCalls += 1
return {}
}
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
calls.length = 0
await handlers.get('hermes:account:logout')!(null)
assert.equal(pluginCalls, 0)
assert.equal(calls.some(c => c.url.includes('/installations/')), false)
})
// ---------- DB-T6:登录后自动绑定 + 机器码/pending 可查 + 失败可见 ------------
test('DB-T6:登录成功即自动绑定,无需点击;bound 后 binding-status 无待办', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
const pluginCalls: string[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async path => {
pluginCalls.push(path)
return { state: 'bound', created: true }
}
})
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.equal(result.ok, true)
assert.deepEqual(pluginCalls, ['identity/login'])
assert.deepEqual(await handlers.get('hermes:account:binding-status')!(null), {
bound: false,
machineId: null,
pending: null,
bindError: null
})
})
test('DB-T6:自动绑定 pending → 确认码随 binding-status 随时可查;logout 清空记录', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async () => ({ state: 'binding_pending', code: 'MR-7K2P-Q9', expires_at: '2026-09-19T12:00:00Z' })
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const status = await handlers.get('hermes:account:binding-status')!(null)
assert.deepEqual(status.pending, { code: 'MR-7K2P-Q9', expiresAt: '2026-09-19T12:00:00Z' })
assert.equal(status.bindError, null)
await handlers.get('hermes:account:logout')!(null)
const after = await handlers.get('hermes:account:binding-status')!(null)
assert.equal(after.pending, null)
assert.equal(after.bindError, null)
})
test('DB-T6:自动绑定失败绝不阻塞登录;失败码随 binding-status 透出', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
const { PluginApiError } = await import('./user-account-ipc')
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async () => {
throw new PluginApiError('plugin_unreachable', 0)
}
})
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.equal(result.ok, true)
assert.notEqual(loadUserAccount(io), null)
const status = await handlers.get('hermes:account:binding-status')!(null)
assert.equal(status.bindError, 'plugin_unreachable')
assert.equal(status.pending, null)
})
test('DB-T6:binding-status 带机器码(插件 GET status 的 relay_machine_id),插件不可达为 null', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
const seen: { path: string; method?: string }[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async (path, _body, method) => {
seen.push({ path, method })
return path === 'status' ? { relay_machine_id: 'MR-ABCD-1234' } : { state: 'bound' }
}
})
const status = await handlers.get('hermes:account:binding-status')!(null)
assert.equal(status.machineId, 'MR-ABCD-1234')
assert.deepEqual(seen, [{ path: 'status', method: 'GET' }])
})
test('DB-T6:恢复出的会话在首次 status 补一次自动绑定,之后不重复', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
persistUserAccount(
{
session: { site: 'https://r', accessToken: 'AT', refreshToken: 'RT', sessionId: 'SID' },
profile: { email: 'u@example.com', username: '', nickname: '', phone: '', planId: '', periodEnd: '', dataAsOf: 't' },
profileAsOf: 't'
},
io
)
const pluginCalls: string[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async path => {
pluginCalls.push(path)
return { state: 'bound', created: true }
}
})
const first = await handlers.get('hermes:account:status')!(null)
assert.equal(first.loggedIn, true)
assert.deepEqual(pluginCalls, ['identity/login'])
await handlers.get('hermes:account:status')!(null)
assert.deepEqual(pluginCalls, ['identity/login'])
})
// ---------- 记住凭据:登录自动存,登出不清,U-6 全清抹掉 -----------------------
test('login 成功自动记住凭据:加密落盘、remembered:load 可取;磁盘无明文密码', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io, fileText } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:remembered:load')!(null), null)
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u@example.com', password: 'pw-secret' })
assert.equal(fileText()!.includes('pw-secret'), false)
assert.deepEqual(loadRememberedLogin(io), { site: 'https://r', email: 'u@example.com', password: 'pw-secret' })
assert.deepEqual(await handlers.get('hermes:account:remembered:load')!(null), {
site: 'https://r',
email: 'u@example.com',
password: 'pw-secret'
})
})
test('登出不清记住凭据;换账号登录覆盖旧记录', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'a@example.com', password: 'pw-a' })
await handlers.get('hermes:account:logout')!(null)
assert.deepEqual(loadRememberedLogin(io), { site: 'https://r', email: 'a@example.com', password: 'pw-a' })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'b@example.com', password: 'pw-b' })
assert.deepEqual(loadRememberedLogin(io), { site: 'https://r', email: 'b@example.com', password: 'pw-b' })
})
test('U-6 解绑全清:记住凭据一并抹掉', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/installations')) {return { status: 200, body: { items: [{ id: 'inst-1' }] } }}
if (url.includes('/installations/')) {return { status: 202, body: { status: 'requested' } }}
return { status: 200, body: {} }
})
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => ({ state: 'purged' })
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.notEqual(loadRememberedLogin(io), null)
assert.deepEqual(await handlers.get('hermes:account:unbind-machine')!(null), { ok: true })
assert.equal(loadRememberedLogin(io), null)
assert.deepEqual(await handlers.get('hermes:account:remembered:load')!(null), null)
})