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
1092 lines
40 KiB
TypeScript
1092 lines
40 KiB
TypeScript
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)
|
||
})
|