feat: 完成分类、worksheet 接口开发,资源迁移到七牛
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user