Files
doodle-mini/tests/auth-api.test.mjs

514 lines
18 KiB
JavaScript

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, /账户状态已变化/);
});