Files
doodle-store/apps/server/test/auth-database.test.ts
T

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));
},
);
},
);