403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
import 'reflect-metadata';
|
|
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { Controller, Get } from '@nestjs/common';
|
|
import { Test } from '@nestjs/testing';
|
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
|
import type { ApiSuccess, WechatLoginResult } from '@doodle/contracts';
|
|
import { AppModule } from '../src/app.module';
|
|
import { configureApplication } from '../src/app.setup';
|
|
import { AppLogger } from '../src/logging/app-logger';
|
|
import { PrismaService } from '../src/database/prisma.service';
|
|
import { WECHAT_FETCH, type WechatFetch } from '../src/auth/wechat-client';
|
|
import { Roles } from '../src/auth/auth.decorators';
|
|
import { JwtTokenService } from '../src/auth/jwt-token.service';
|
|
import { AccountRepository } from '../src/auth/account.repository';
|
|
import { testConfig } from './test-config';
|
|
|
|
@Controller('test-admin')
|
|
class AdminProbe {
|
|
@Get()
|
|
@Roles('admin')
|
|
get() {
|
|
return { ok: true };
|
|
}
|
|
}
|
|
|
|
const databaseUrl = process.env.TEST_DATABASE_URL;
|
|
test(
|
|
'真实 PostgreSQL:微信登录、并发、本人隔离及账户状态鉴权',
|
|
{ skip: !databaseUrl },
|
|
async (context) => {
|
|
// 即使手工运行测试,也只允许本机脚本创建的短期库,禁止误用开发/生产数据库。
|
|
const parsed = new URL(databaseUrl!);
|
|
assert.ok(['127.0.0.1', 'localhost'].includes(parsed.hostname));
|
|
assert.match(parsed.pathname, /^\/doodle_auth_test_[a-f0-9]{32}$/);
|
|
const config = { ...testConfig, databaseUrl: databaseUrl! };
|
|
const logs: string[] = [];
|
|
const logger = new AppLogger('info', (line) => logs.push(line));
|
|
const requests: string[] = [];
|
|
const http: WechatFetch = async (input) => {
|
|
const code = new URL(String(input)).searchParams.get('js_code')!;
|
|
requests.push(code);
|
|
if (code === 'bad-code')
|
|
return Response.json({
|
|
errcode: 40029,
|
|
errmsg: 'private-upstream-message',
|
|
});
|
|
if (code === 'risk-code') return Response.json({ errcode: 40226 });
|
|
if (code === 'quota-code') return Response.json({ errcode: 45011 });
|
|
if (code === 'network-code')
|
|
throw new Error('private-network-secret');
|
|
return Response.json({
|
|
openid: code.startsWith('concurrent-')
|
|
? 'ConcurrentOpenID'
|
|
: code,
|
|
session_key: 'private-session-key',
|
|
...(code === 'LegacyOpenID' ? { unionid: 'FreshUnion' } : {}),
|
|
});
|
|
};
|
|
const module = await Test.createTestingModule({
|
|
imports: [AppModule.forRoot(config)],
|
|
controllers: [AdminProbe],
|
|
})
|
|
.overrideProvider(WECHAT_FETCH)
|
|
.useValue(http)
|
|
.compile();
|
|
const app = module.createNestApplication<NestExpressApplication>({
|
|
logger,
|
|
bodyParser: false,
|
|
});
|
|
context.after(() => app.close());
|
|
configureApplication(app, config, logger);
|
|
await app.listen(0, '127.0.0.1');
|
|
const origin = await app.getUrl();
|
|
const prisma = app.get(PrismaService).client;
|
|
const tokens = app.get(JwtTokenService);
|
|
const repository = app.get(AccountRepository);
|
|
const request = (path: string, token?: string) =>
|
|
fetch(`${origin}/api/v1/${path}`, {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const login = (code: string, mode = 'start', extra: object = {}) =>
|
|
fetch(`${origin}/api/v1/auth/wechat/login`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Forwarded-For': 'untrusted-client-ip',
|
|
},
|
|
body: JSON.stringify({ code, mode, ...extra }),
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
const reason = async (
|
|
response: Response,
|
|
status: number,
|
|
expected: string,
|
|
) => {
|
|
assert.equal(response.status, status);
|
|
const body = (await response.json()) as {
|
|
reason: string;
|
|
requestId: string;
|
|
};
|
|
assert.equal(body.reason, expected);
|
|
assert.ok(body.requestId);
|
|
};
|
|
const loginData = async (response: Response) => {
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
return ((await response.json()) as ApiSuccess<WechatLoginResult>)
|
|
.data;
|
|
};
|
|
|
|
await context.test('初始配置和只读就绪检查可用', async () => {
|
|
assert.equal((await request('health/ready')).status, 200);
|
|
assert.equal(await prisma.pageConfig.count(), 1);
|
|
assert.equal(await prisma.setting.count(), 1);
|
|
assert.equal(await prisma.user.count(), 0);
|
|
await reason(await request('me'), 401, 'AUTH_REQUIRED');
|
|
});
|
|
|
|
await context.test(
|
|
'首次静默登录默认普通用户,续期不改变版本,响应只含白名单',
|
|
async () => {
|
|
const data = await loginData(await login('FirstOpenID'));
|
|
const claims = await tokens.verifyAccessToken(data.accessToken);
|
|
assert.equal(data.user.id, claims.sub);
|
|
assert.equal(data.expiresIn, 86400);
|
|
assert.equal(
|
|
data.expiresAt,
|
|
new Date(claims.exp * 1000).toISOString(),
|
|
);
|
|
assert.deepEqual(
|
|
Object.keys(data.user).sort(),
|
|
[
|
|
'id',
|
|
'nickName',
|
|
'avatarUrl',
|
|
'avatarUrlExpiresAt',
|
|
'createdAt',
|
|
].sort(),
|
|
);
|
|
const stored = await prisma.user.findUniqueOrThrow({
|
|
where: { id: data.user.id },
|
|
});
|
|
assert.equal(stored.role, 'user');
|
|
assert.equal(stored.status, 'active');
|
|
assert.equal(stored.authVersion, 1);
|
|
const second = await loginData(
|
|
await login('FirstOpenID', 'resume'),
|
|
);
|
|
assert.equal(second.user.id, data.user.id);
|
|
const me = await request(
|
|
'me?userId=somebody-else',
|
|
data.accessToken,
|
|
);
|
|
assert.equal(me.status, 200);
|
|
assert.equal(me.headers.get('cache-control'), 'no-store');
|
|
assert.equal(
|
|
((await me.json()) as { data: { id: string } }).data.id,
|
|
data.user.id,
|
|
);
|
|
assert.equal(
|
|
(await request('test-admin', data.accessToken)).status,
|
|
403,
|
|
);
|
|
},
|
|
);
|
|
|
|
await context.test(
|
|
'老 ID、资料、版本及时间保留,UnionID 只补空不覆盖',
|
|
async () => {
|
|
const createdAt = new Date('2025-01-01T00:00:00Z');
|
|
const updatedAt = new Date('2025-01-02T00:00:00Z');
|
|
await prisma.user.create({
|
|
data: {
|
|
id: 'legacy_cloud_id',
|
|
openid: 'LegacyOpenID',
|
|
nickName: '老用户',
|
|
authVersion: 3,
|
|
createdAt,
|
|
updatedAt,
|
|
},
|
|
});
|
|
const data = await loginData(
|
|
await login('LegacyOpenID', 'resume'),
|
|
);
|
|
assert.equal(data.user.id, 'legacy_cloud_id');
|
|
assert.equal(data.user.nickName, '老用户');
|
|
const stored = await prisma.user.findUniqueOrThrow({
|
|
where: { id: 'legacy_cloud_id' },
|
|
});
|
|
assert.equal(stored.unionid, 'FreshUnion');
|
|
assert.equal(stored.authVersion, 3);
|
|
assert.equal(
|
|
stored.createdAt.toISOString(),
|
|
createdAt.toISOString(),
|
|
);
|
|
await repository.login(
|
|
{ openid: 'LegacyOpenID', unionid: 'ConflictingUnion' },
|
|
'resume',
|
|
);
|
|
await repository.login(
|
|
{ openid: 'LegacyOpenID', unionid: null },
|
|
'resume',
|
|
);
|
|
const after = await prisma.user.findUniqueOrThrow({
|
|
where: { id: 'legacy_cloud_id' },
|
|
});
|
|
assert.equal(after.unionid, 'FreshUnion');
|
|
assert.equal(
|
|
after.updatedAt.toISOString(),
|
|
stored.updatedAt.toISOString(),
|
|
);
|
|
assert.ok(after.lastActiveAt > createdAt);
|
|
},
|
|
);
|
|
|
|
await context.test(
|
|
'并发首次登录只生成一个账户,OpenID 区分大小写',
|
|
async () => {
|
|
const responses = await Promise.all(
|
|
Array.from({ length: 8 }, (_, i) =>
|
|
login(`concurrent-${i}`),
|
|
),
|
|
);
|
|
const users = await Promise.all(
|
|
responses.map(async (r) => (await loginData(r)).user.id),
|
|
);
|
|
assert.equal(new Set(users).size, 1);
|
|
assert.equal(
|
|
await prisma.user.count({
|
|
where: { openid: 'ConcurrentOpenID' },
|
|
}),
|
|
1,
|
|
);
|
|
const lower = await repository.login(
|
|
{ openid: 'concurrentopenid', unionid: null },
|
|
'start',
|
|
);
|
|
assert.notEqual(lower.account.id, users[0]);
|
|
},
|
|
);
|
|
|
|
await context.test(
|
|
'resume 不建号,禁用不能被 start 重置,撤销/降权/删除即时生效',
|
|
async () => {
|
|
await reason(
|
|
await login('MissingOpenID', 'resume'),
|
|
401,
|
|
'ACCOUNT_NOT_FOUND',
|
|
);
|
|
assert.equal(
|
|
await prisma.user.count({
|
|
where: { openid: 'MissingOpenID' },
|
|
}),
|
|
0,
|
|
);
|
|
const data = await loginData(await login('LifecycleOpenID'));
|
|
const id = data.user.id;
|
|
await prisma.user.update({
|
|
where: { id },
|
|
data: { role: 'admin' },
|
|
});
|
|
assert.equal(
|
|
(await request('test-admin', data.accessToken)).status,
|
|
200,
|
|
);
|
|
await prisma.user.update({
|
|
where: { id },
|
|
data: { role: 'user' },
|
|
});
|
|
assert.equal(
|
|
(await request('test-admin', data.accessToken)).status,
|
|
403,
|
|
);
|
|
await prisma.user.update({
|
|
where: { id },
|
|
data: { authVersion: { increment: 1 } },
|
|
});
|
|
await reason(
|
|
await request('me', data.accessToken),
|
|
401,
|
|
'AUTH_REVOKED',
|
|
);
|
|
const renewed = await loginData(
|
|
await login('LifecycleOpenID', 'resume'),
|
|
);
|
|
await prisma.user.update({
|
|
where: { id },
|
|
data: { status: 'disabled' },
|
|
});
|
|
await reason(
|
|
await request('me', renewed.accessToken),
|
|
401,
|
|
'ACCOUNT_DISABLED',
|
|
);
|
|
await reason(
|
|
await login('LifecycleOpenID'),
|
|
401,
|
|
'ACCOUNT_DISABLED',
|
|
);
|
|
await prisma.user.delete({ where: { id } });
|
|
await reason(
|
|
await request('me', renewed.accessToken),
|
|
401,
|
|
'ACCOUNT_NOT_FOUND',
|
|
);
|
|
await reason(
|
|
await login('LifecycleOpenID', 'resume'),
|
|
401,
|
|
'ACCOUNT_NOT_FOUND',
|
|
);
|
|
const fresh = await loginData(await login('LifecycleOpenID'));
|
|
assert.notEqual(fresh.user.id, id);
|
|
},
|
|
);
|
|
|
|
await context.test(
|
|
'拒绝额外身份参数和坏 code,错误不变成成功响应',
|
|
async () => {
|
|
const before = requests.length;
|
|
for (const input of [
|
|
{ openid: 'forged' },
|
|
{ appid: 'forged' },
|
|
{ userId: 'forged' },
|
|
{ role: 'admin' },
|
|
])
|
|
assert.equal(
|
|
(await login('valid', 'start', input)).status,
|
|
400,
|
|
);
|
|
assert.equal((await login(' ', 'start')).status, 400);
|
|
assert.equal((await login('valid', 'unknown')).status, 400);
|
|
assert.equal(requests.length, before);
|
|
await reason(
|
|
await login('bad-code'),
|
|
400,
|
|
'WECHAT_CODE_INVALID',
|
|
);
|
|
await reason(
|
|
await login('risk-code'),
|
|
403,
|
|
'WECHAT_LOGIN_BLOCKED',
|
|
);
|
|
const quota = await login('quota-code');
|
|
assert.equal(quota.headers.get('retry-after'), '60');
|
|
await reason(quota, 429, 'RATE_LIMITED');
|
|
await reason(
|
|
await login('network-code'),
|
|
503,
|
|
'AUTH_UPSTREAM_UNAVAILABLE',
|
|
);
|
|
},
|
|
);
|
|
|
|
await context.test(
|
|
'认证失败原因及日志不泄露凭据,伪造转发头不能绕过限流',
|
|
async () => {
|
|
await reason(
|
|
await request('me', 'invalid-token'),
|
|
401,
|
|
'AUTH_INVALID',
|
|
);
|
|
const jwt = app.get(JwtTokenService);
|
|
const expired = await app
|
|
.get((await import('@nestjs/jwt')).JwtService)
|
|
.signAsync(
|
|
{ sub: 'legacy_cloud_id', authVersion: 3 },
|
|
{
|
|
secret: config.jwtSecret,
|
|
issuer: config.jwtIssuer,
|
|
audience: config.jwtAudience,
|
|
expiresIn: -1,
|
|
},
|
|
);
|
|
await reason(await request('me', expired), 401, 'AUTH_EXPIRED');
|
|
const token = await jwt.signAccessToken('legacy_cloud_id', 3);
|
|
assert.equal((await request('me', token)).status, 200);
|
|
let limited = false;
|
|
for (let i = 0; i < 35; i++) {
|
|
const result = await login(`rate-${i}`, 'resume');
|
|
if (result.status === 429) {
|
|
assert.ok(result.headers.get('retry-after'));
|
|
limited = true;
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(limited);
|
|
const allLogs = logs.join('\n');
|
|
for (const secret of [
|
|
config.wechatMiniAppSecret,
|
|
'private-session-key',
|
|
'private-upstream-message',
|
|
'private-network-secret',
|
|
token,
|
|
'LegacyOpenID',
|
|
])
|
|
assert.ok(!allLogs.includes(secret));
|
|
},
|
|
);
|
|
},
|
|
);
|