import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { test } from 'node:test'; import vm from 'node:vm'; import ts from 'typescript'; const root = resolve('miniprogram'); const user = (id = 'server-user') => ({ id, nickName: null, avatarUrl: null, avatarUrlExpiresAt: null, createdAt: '2026-10-19T00:00:00Z', }); const result = (token, id) => ({ accessToken: token, tokenType: 'Bearer', expiresIn: 86400, expiresAt: '2026-10-20T00:00:00Z', user: { ...user(id), openid: 'must-not-persist', role: 'admin' }, }); const failure = (reason, statusCode = 401) => ({ statusCode, raw: { code: statusCode, reason }, }); const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }; /** 隔离微信与时间;登录、缓存、HTTP 包装都加载实际源码,不使用真实凭据。 */ function fixture(initialStorage = new Map()) { let now = Date.now(); const storage = initialStorage; const app = { globalData: {} }; let loginCalls = 0; let handler = async (options) => options.url.endsWith('/auth/wechat/login') ? { data: result(`token-${loginCalls}`) } : { data: user() }; const requests = []; const uploads = []; const wx = { getAccountInfoSync: () => ({ miniProgram: { appId: 'wx-test', envVersion: 'develop' }, }), getStorageSync: (key) => storage.get(key), setStorageSync(key, value) { storage.set(key, JSON.parse(JSON.stringify(value))); }, login(options) { loginCalls++; options.success({ code: `one-use-code-${loginCalls}` }); }, uploadFile(options) { uploads.push(options); Promise.resolve() .then(() => handler({ ...options, data: options.formData })) .then((value) => { if (value.networkError) options.fail({}); else options.success({ statusCode: value.statusCode ?? 200, data: JSON.stringify( value.raw ?? { code: 0, data: value.data }, ), }); }); }, request(options) { requests.push(options); Promise.resolve() .then(() => handler(options)) .then((value) => { if (value.networkError) options.fail({}); else options.success({ statusCode: value.statusCode ?? 200, data: value.raw ?? { code: 0, data: value.data }, }); }); }, }; class Clock extends Date { static now() { return now; } } const context = vm.createContext({ wx, getApp: () => app, Date: Clock, console, }); const modules = new Map(); function load(file) { file = resolve(root, file); if (!file.endsWith('.ts')) file += '.ts'; if (modules.has(file)) return modules.get(file); const exports = {}; modules.set(file, exports); const source = readFileSync(file, 'utf8'); const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, }, }).outputText; const run = vm.runInContext( `(function(require,exports){${code}\n})`, context, ); run((name) => load(resolve(dirname(file), name)), exports); return exports; } return { auth: load('utils/auth'), session: load('services/auth-session'), api: load('services/api-request'), member: load('services/member-api'), admin: load('services/admin-api'), site: load('services/site-api'), config: load('config/api').apiConfig, wx, requests, uploads, storage, app, get loginCalls() { return loginCalls; }, handle(fn) { handler = fn; }, advance(ms) { now += ms; }, }; } const loginRequests = (f) => f.requests.filter((r) => r.url.endsWith('/auth/wechat/login')); test('静默登录:首次 start,并发合并、缓存复用,过期后 resume 且不缓存微信身份/角色', async () => { const f = fixture(); await Promise.all(Array.from({ length: 20 }, () => f.auth.getUser())); assert.equal(f.loginCalls, 1); assert.equal(loginRequests(f)[0].data.mode, 'start'); assert.equal(f.app.globalData.user.id, 'server-user'); assert.ok( !JSON.stringify([...f.storage.values()]).includes('must-not-persist'), ); assert.ok(!JSON.stringify([...f.storage.values()]).includes('admin')); assert.equal(f.auth.getCachedUser().id, 'server-user'); const reload = fixture(f.storage); await reload.auth.getUser(); assert.equal(reload.loginCalls, 0); f.advance(86400 * 1000); await f.auth.getUser(); assert.equal(f.loginCalls, 2); assert.equal(loginRequests(f)[1].data.mode, 'resume'); assert.notEqual( loginRequests(f)[0].data.code, loginRequests(f)[1].data.code, ); }); test('公开请求不登录不带凭证;受保护请求登录并 GET /me 刷新用户', async () => { const f = fixture(); await f.api.apiRequest('/categories', { auth: 'none', header: { authorization: 'do-not-forward' }, }); assert.equal(f.loginCalls, 0); assert.equal( Object.keys(f.requests[0].header).some( (k) => k.toLowerCase() === 'authorization', ), false, ); const refreshed = await f.auth.refreshUser(); assert.equal(refreshed.id, 'server-user'); assert.equal(f.requests.at(-1).header.Authorization, 'Bearer token-1'); await f.api.apiRequest('/categories', { auth: 'none' }); assert.equal(f.requests.at(-1).header.Authorization, undefined); }); test('多个并发 401 共用一次 resume,迟到的旧 401 复用新 token', async () => { const f = fixture(); await f.auth.getUser(); const late = deferred(); const receivedLate = deferred(); f.handle(async (options) => { if (options.url.endsWith('/auth/wechat/login')) return { data: result('token-2') }; if (options.header.Authorization === 'Bearer token-1') { if (options.url.endsWith('/late')) { receivedLate.resolve(); await late.promise; } return failure('AUTH_EXPIRED'); } return { data: 'ok' }; }); const delayed = f.api.apiRequest('/late'); await receivedLate.promise; const values = await Promise.all( Array.from({ length: 8 }, () => f.api.apiRequest('/me')), ); assert.ok(values.every((v) => v === 'ok')); assert.equal(f.loginCalls, 2); late.resolve(); assert.equal(await delayed, 'ok'); assert.equal(f.loginCalls, 2); }); test('第二次 401 停止恢复;403、429、503 和网络错误不自动重登', async () => { for (const status of [401, 403, 429, 503, 0]) { const f = fixture(); await f.auth.getUser(); f.handle(async (options) => options.url.endsWith('/auth/wechat/login') ? { data: result('token-2') } : status ? failure('AUTH_EXPIRED', status) : { networkError: true }, ); await assert.rejects(f.api.apiRequest('/me')); assert.equal(f.loginCalls, status === 401 ? 2 : 1); const business = f.requests.filter((r) => r.url.endsWith('/me')); assert.equal(business.length, status === 401 ? 2 : 1); } }); test('账户被删除或禁用时阻止自动重建,标记重启后保留;用户可主动重新开始', async () => { const f = fixture(); await f.auth.getUser(); f.handle(async (options) => options.url.endsWith('/auth/wechat/login') ? failure('ACCOUNT_NOT_FOUND') : failure('AUTH_EXPIRED'), ); await assert.rejects(f.api.apiRequest('/me')); await assert.rejects(f.auth.getUser(), /重新确认/); assert.equal(f.loginCalls, 2); assert.equal(loginRequests(f)[1].data.mode, 'resume'); const reload = fixture(f.storage); await assert.rejects(reload.auth.getUser(), /重新确认/); assert.equal(reload.loginCalls, 0); await reload.auth.startAccount(); assert.equal(loginRequests(reload)[0].data.mode, 'start'); }); test('恢复后身份变化中止原业务写入,不把请求补发到另一个账户', async () => { const f = fixture(); await f.auth.getUser(); f.handle(async (options) => options.url.endsWith('/auth/wechat/login') ? { data: result('token-other', 'another-user') } : failure('AUTH_EXPIRED'), ); await assert.rejects( f.api.apiRequest('/me/action', { method: 'POST', data: { value: 1 } }), (e) => e.reason === 'ACCOUNT_CHANGED', ); assert.equal( f.requests.filter((r) => r.url.endsWith('/me/action')).length, 1, ); assert.equal(f.auth.getCachedUser(), null); }); test('微信无效 code 只补试一次且使用新 code,网络失败不重试登录', async () => { const f = fixture(); let count = 0; f.handle(async () => ++count === 1 ? failure('WECHAT_CODE_INVALID') : { data: result('token-2') }, ); await f.auth.getUser(); assert.equal(f.loginCalls, 2); assert.notEqual( loginRequests(f)[0].data.code, loginRequests(f)[1].data.code, ); const g = fixture(); g.handle(async () => ({ networkError: true })); await assert.rejects(g.auth.getUser()); assert.equal(g.loginCalls, 1); }); test('接口环境与 AppID 隔离;清理或切换环境后迟到登录不得恢复原会话', async () => { const f = fixture(); await f.auth.getUser(); f.config.localBaseUrl = 'http://127.0.0.1:8801/api/v1'; assert.equal(f.auth.getCachedUser(), null); await f.auth.getUser(); assert.equal(loginRequests(f)[1].data.mode, 'start'); assert.equal(f.storage.size, 2); f.wx.getAccountInfoSync = () => ({ miniProgram: { appId: 'wx-other', envVersion: 'develop' }, }); assert.equal(f.auth.getCachedUser(), null); const pending = deferred(); const received = deferred(); f.handle(async () => { received.resolve(); return pending.promise; }); const login = f.auth.getUser(); await received.promise; f.auth.clearUser(); pending.resolve({ data: result('late') }); await assert.rejects(login, /状态已变化/); assert.equal(f.auth.getCachedUser(), null); }); test('畸形登录响应不保存凭证;账户禁用不会触发新的自动注册', async () => { const f = fixture(); f.handle(async () => ({ data: { ...result('invalid'), tokenType: 'Basic' }, })); await assert.rejects(f.auth.getUser(), /格式异常/); assert.equal(f.auth.getCachedUser(), null); assert.equal(f.storage.size, 0); const g = fixture(); await g.auth.getUser(); g.handle(async () => failure('ACCOUNT_DISABLED')); await assert.rejects(g.api.apiRequest('/me')); assert.equal(g.loginCalls, 1); await assert.rejects(g.auth.getUser(), /重新确认/); assert.equal(g.loginCalls, 1); }); test('切换环境后原业务的 401 不向旧接口发起新登录', async () => { const f = fixture(); await f.auth.getUser(); const pending = deferred(); const received = deferred(); f.handle(async () => { received.resolve(); return pending.promise; }); const request = f.api.apiRequest('/me'); await received.promise; f.config.localBaseUrl = 'http://127.0.0.1:8801/api/v1'; pending.resolve(failure('AUTH_EXPIRED')); await assert.rejects(request, /环境已变化/); assert.equal(f.loginCalls, 1); }); test('提前续登后身份变化时,原页面发起的业务请求也不会首次发送到新账户', async () => { const f = fixture(); await f.auth.getUser(); f.advance(86400 * 1000); f.handle(async () => ({ data: result('another-token', 'another-user') })); await assert.rejects( f.api.apiRequest('/me/action', { method: 'POST' }), (e) => e.reason === 'ACCOUNT_CHANGED', ); assert.equal( f.requests.filter((r) => r.url.endsWith('/me/action')).length, 0, ); }); test('七牛头像/预览上传沿用统一鉴权;401 重试沿用 uploadId,不自行设置 multipart boundary', async () => { const f = fixture(); await f.auth.getUser(); f.handle(async (options) => { if (options.url.endsWith('/auth/wechat/login')) return { data: result('token-2') }; if (options.header.Authorization === 'Bearer token-1') return failure('AUTH_EXPIRED'); return { data: { id: 'avatar-resource', url: 'https://cdn.test/avatar.png', mimeType: 'image/png', sizeBytes: 100, }, }; }); const uploaded = await f.member.uploadAsset( 'wxfile://chosen-avatar.png', 'avatar', ); assert.equal(uploaded.id, 'avatar-resource'); assert.equal(f.uploads.length, 2); assert.equal( f.uploads[0].formData.uploadId, f.uploads[1].formData.uploadId, ); assert.equal(f.uploads[0].header['Content-Type'], undefined); assert.equal(f.uploads[1].header.Authorization, 'Bearer token-2'); assert.ok(f.uploads[0].url.endsWith('/me/avatar-upload')); await f.member.uploadAsset('wxfile://preview.jpg', 'preview'); assert.ok(f.uploads.at(-1).url.endsWith('/admin/assets/preview-upload')); }); test('资料更新只提交资源 ID,服务端响应更新当前用户摘要', async () => { const f = fixture(); await f.auth.getUser(); f.handle(async () => ({ data: { ...user(), nickName: '新名字', avatarUrl: 'https://cdn.test/avatar.png', avatarAssetId: 'avatar-resource', role: 'admin', }, })); const updated = await f.auth.updateUserProfile({ nickName: '新名字', avatarAssetId: 'avatar-resource', }); const request = f.requests.at(-1); assert.ok(request.url.endsWith('/me/profile')); assert.equal(request.method, 'PUT'); assert.equal(request.data.avatarAssetId, 'avatar-resource'); assert.equal(request.data.avatarUrl, undefined); assert.equal(updated.avatarAssetId, 'avatar-resource'); assert.equal(f.auth.getCachedUser().nickName, '新名字'); assert.equal(f.auth.getCachedUser().role, undefined); }); test('管理目录完整分页,真实计数和展示数分开;批次与 PUT 状态映射至新接口', async () => { const f = fixture(); const rows = Array.from({ length: 121 }, (_, index) => ({ id: `item-${index}`, likes: '2', downloads: '3', likesDisplay: '22', downloadsDisplay: '33', status: 'active', })); f.handle(async (options) => { if (options.url.endsWith('/auth/wechat/login')) return { data: result('token-1') }; if (options.url.endsWith('/admin/catalog/worksheets')) { const { page } = options.data; return { data: { items: rows.slice((page - 1) * 100, page * 100), total: rows.length, page, pageSize: 100, hasNext: page * 100 < rows.length, }, }; } return { data: {} }; }); const display = await f.admin.managementRequest('worksheets.list'); assert.equal(display.data.length, 121); assert.equal(display.data[0]._id, 'item-0'); assert.equal(display.data[0].downloads, '33'); const raw = await f.admin.managementRequest('worksheets.list', { rawStats: true, }); assert.equal(raw.data[0].downloads, '3'); await f.admin.managementRequest('worksheets.batch', { patches: [{ id: 'item-0', likes_seed: 10, downloads_seed: 20 }], dryRun: false, }); assert.ok(f.requests.at(-1).url.endsWith('/admin/worksheets/batch')); assert.equal(f.requests.at(-1).data.patches[0].likesSeed, 10); assert.equal(f.requests.at(-1).data.patches[0].likes_seed, undefined); await f.admin.managementRequest('worksheets.status', { id: 'item-0', status: 'hidden', }); assert.equal(f.requests.at(-1).method, 'PUT'); await f.admin.managementRequest('categories.update', { id: 'cat-0', name: '分类', }); assert.ok(f.requests.at(-1).url.endsWith('/admin/categories/cat-0')); assert.equal(f.requests.at(-1).data.id, undefined); }); test('首页兜底与分龄推荐不携带个人凭证,也不触发登录', async () => { const f = fixture(); f.handle(async () => ({ data: { home: {}, items: [] } })); await f.site.fetchPageConfig(); await f.site.fetchAgeRecommendations('5-6'); assert.equal(f.loginCalls, 0); assert.ok(f.requests[0].url.endsWith('/page-config')); assert.ok(f.requests[1].url.endsWith('/recommendations/age')); assert.equal(f.requests[1].data.ageBand, '5-6'); assert.ok(f.requests.every((r) => !r.header.Authorization)); }); test('清理账户后迟到的成功响应不能回填旧账户视图', async () => { const f = fixture(); await f.auth.getUser(); const pending = deferred(); const received = deferred(); f.handle(async () => { received.resolve(); return pending.promise; }); const request = f.api.apiRequest('/me/stats'); await received.promise; f.auth.clearUser(true); pending.resolve({ data: { favoriteCount: 10 } }); await assert.rejects(request, /账户状态已变化/); });