431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { randomUUID } from 'node:crypto';
|
|
import type { AddressInfo } from 'node:net';
|
|
import type { Database } from '@atlas/database';
|
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
|
import { afterAll, beforeAll, describe, it } from 'vitest';
|
|
import { DATABASE } from '../src/database/database.module.js';
|
|
import { createApplication } from '../src/main.js';
|
|
|
|
type SuccessResponse<T> = { data: T };
|
|
type ErrorResponse = {
|
|
error: {
|
|
code: string;
|
|
message: string;
|
|
request_id: string;
|
|
details?: Array<{ field: string; code: string; message: string }>;
|
|
};
|
|
};
|
|
type SessionPayload = {
|
|
access_token: string;
|
|
access_token_expires_in: number;
|
|
user: {
|
|
id: string;
|
|
email: string;
|
|
display_name: string | null;
|
|
avatar_asset_id: string | null;
|
|
revision: number;
|
|
};
|
|
workspace: { id: string; name: string; role: string };
|
|
};
|
|
|
|
const password = 'correct-horse-battery-staple-123';
|
|
const testUsers: Array<{ userId: string; workspaceId: string }> = [];
|
|
let app: NestExpressApplication;
|
|
let database: Database;
|
|
let baseUrl: string;
|
|
|
|
function email(label: string): string {
|
|
return `${label}-${randomUUID()}@example.test`;
|
|
}
|
|
|
|
async function request(
|
|
path: string,
|
|
init: RequestInit = {},
|
|
): Promise<{ response: Response; body: unknown }> {
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
...init,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...init.headers,
|
|
},
|
|
});
|
|
return {
|
|
response,
|
|
body: response.status === 204 ? undefined : await response.json(),
|
|
};
|
|
}
|
|
|
|
function expectSuccess<T>(body: unknown): SuccessResponse<T> {
|
|
assert.ok(body && typeof body === 'object' && 'data' in body);
|
|
return body as SuccessResponse<T>;
|
|
}
|
|
|
|
function expectError(body: unknown): ErrorResponse {
|
|
assert.ok(body && typeof body === 'object' && 'error' in body);
|
|
return body as ErrorResponse;
|
|
}
|
|
|
|
function refreshCookie(response: Response): string {
|
|
const cookie = response.headers.get('set-cookie');
|
|
assert.ok(cookie, '认证成功响应必须写入 refresh cookie');
|
|
assert.match(cookie, /HttpOnly/i);
|
|
const match = cookie.match(/atlas_refresh=[^;]+/);
|
|
assert.ok(match, '响应必须包含 atlas_refresh cookie');
|
|
return match[0];
|
|
}
|
|
|
|
describe('认证 HTTP 集成测试', () => {
|
|
beforeAll(async () => {
|
|
Object.assign(process.env, {
|
|
NODE_ENV: 'test',
|
|
SWAGGER_ENABLED: 'false',
|
|
AUTH_LOGIN_IP_LIMIT: '100',
|
|
AUTH_LOGIN_EMAIL_LIMIT: '2',
|
|
AUTH_REGISTER_IP_LIMIT: '100',
|
|
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
|
AUTH_REFRESH_IP_LIMIT: '100',
|
|
});
|
|
const application = await createApplication();
|
|
app = application.app;
|
|
database = app.get<Database>(DATABASE);
|
|
await app.listen(0, '127.0.0.1');
|
|
const address = app.getHttpServer().address() as AddressInfo;
|
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const user of testUsers) {
|
|
await database.workspaceTransaction(
|
|
user.workspaceId,
|
|
async (tx) => {
|
|
await tx.query(
|
|
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
|
[user.workspaceId],
|
|
);
|
|
},
|
|
);
|
|
await database.query(
|
|
'DELETE FROM atlas.workspace_memberships WHERE user_id = $1',
|
|
[user.userId],
|
|
);
|
|
await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [
|
|
user.workspaceId,
|
|
]);
|
|
await database.query('DELETE FROM atlas.users WHERE id = $1', [
|
|
user.userId,
|
|
]);
|
|
}
|
|
await app.close();
|
|
});
|
|
|
|
it('健康与 readiness 端点保持公开', async () => {
|
|
const health = await request('/health');
|
|
assert.equal(health.response.status, 200);
|
|
assert.deepEqual(health.body, { status: 'ok', service: 'api' });
|
|
|
|
const readiness = await request('/ready');
|
|
assert.equal(readiness.response.status, 200);
|
|
assert.deepEqual(readiness.body, { status: 'ready', service: 'api' });
|
|
});
|
|
|
|
it('注册、重复邮箱和参数校验遵守响应契约', async () => {
|
|
const registrationEmail = email('register');
|
|
const registration = await request('/api/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
email: registrationEmail,
|
|
password,
|
|
password_confirmation: password,
|
|
display_name: '集成测试用户',
|
|
}),
|
|
});
|
|
assert.equal(registration.response.status, 201);
|
|
const payload = expectSuccess<SessionPayload>(registration.body).data;
|
|
assert.equal(payload.user.email, registrationEmail);
|
|
assert.equal(payload.workspace.role, 'owner');
|
|
assert.ok(payload.access_token);
|
|
assert.ok(payload.access_token_expires_in > 0);
|
|
refreshCookie(registration.response);
|
|
testUsers.push({
|
|
userId: payload.user.id,
|
|
workspaceId: payload.workspace.id,
|
|
});
|
|
|
|
const duplicate = await request('/api/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
email: registrationEmail,
|
|
password,
|
|
password_confirmation: password,
|
|
}),
|
|
});
|
|
assert.equal(duplicate.response.status, 409);
|
|
assert.equal(
|
|
expectError(duplicate.body).error.code,
|
|
'EMAIL_ALREADY_REGISTERED',
|
|
);
|
|
|
|
const invalid = await request('/api/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
email: 'not-an-email',
|
|
password,
|
|
password_confirmation: password,
|
|
}),
|
|
});
|
|
assert.equal(invalid.response.status, 422);
|
|
assert.equal(expectError(invalid.body).error.code, 'VALIDATION_FAILED');
|
|
});
|
|
|
|
it('登录、刷新、退出和当前用户读取可形成完整会话闭环', async () => {
|
|
const accountEmail = email('session');
|
|
const registration = await request('/api/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
email: accountEmail,
|
|
password,
|
|
password_confirmation: password,
|
|
}),
|
|
});
|
|
const registered = expectSuccess<SessionPayload>(
|
|
registration.body,
|
|
).data;
|
|
testUsers.push({
|
|
userId: registered.user.id,
|
|
workspaceId: registered.workspace.id,
|
|
});
|
|
|
|
const login = await request('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email: accountEmail, password }),
|
|
});
|
|
assert.equal(login.response.status, 200);
|
|
const loggedIn = expectSuccess<SessionPayload>(login.body).data;
|
|
const loginRefreshCookie = refreshCookie(login.response);
|
|
|
|
const currentUser = await request('/api/auth/me', {
|
|
headers: { Authorization: `Bearer ${loggedIn.access_token}` },
|
|
});
|
|
assert.equal(currentUser.response.status, 200);
|
|
const me = expectSuccess<{
|
|
user: { id: string };
|
|
workspace: { id: string };
|
|
}>(currentUser.body).data;
|
|
assert.equal(me.user.id, registered.user.id);
|
|
assert.equal(me.workspace.id, registered.workspace.id);
|
|
|
|
const refresh = await request('/api/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { Cookie: loginRefreshCookie },
|
|
});
|
|
assert.equal(refresh.response.status, 200);
|
|
const refreshed = expectSuccess<{
|
|
access_token: string;
|
|
access_token_expires_in: number;
|
|
}>(refresh.body).data;
|
|
assert.ok(refreshed.access_token);
|
|
const refreshedCookie = refreshCookie(refresh.response);
|
|
|
|
const reusedRefresh = await request('/api/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { Cookie: loginRefreshCookie },
|
|
});
|
|
assert.equal(reusedRefresh.response.status, 401);
|
|
assert.equal(
|
|
expectError(reusedRefresh.body).error.code,
|
|
'SESSION_EXPIRED',
|
|
);
|
|
|
|
const logout = await request('/api/auth/logout', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${refreshed.access_token}`,
|
|
Cookie: refreshedCookie,
|
|
},
|
|
});
|
|
assert.equal(logout.response.status, 204);
|
|
assert.equal(logout.body, undefined);
|
|
|
|
const revoked = await request('/api/auth/me', {
|
|
headers: { Authorization: `Bearer ${refreshed.access_token}` },
|
|
});
|
|
assert.equal(revoked.response.status, 401);
|
|
});
|
|
|
|
it('拒绝错误密码、无效访问令牌和超过邮箱限额的登录请求', async () => {
|
|
const accountEmail = email('rejected');
|
|
const wrongPassword = await request('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email: accountEmail, password }),
|
|
});
|
|
assert.equal(wrongPassword.response.status, 401);
|
|
const wrongPasswordError = expectError(wrongPassword.body).error;
|
|
assert.equal(wrongPasswordError.code, 'INVALID_CREDENTIALS');
|
|
assert.equal(
|
|
wrongPasswordError.request_id,
|
|
wrongPassword.response.headers.get('x-request-id'),
|
|
);
|
|
|
|
const invalidToken = await request('/api/auth/me', {
|
|
headers: { Authorization: 'Bearer invalid-access-token' },
|
|
});
|
|
assert.equal(invalidToken.response.status, 401);
|
|
assert.equal(expectError(invalidToken.body).error.code, 'HTTP_ERROR');
|
|
|
|
const rateLimitedEmail = email('rate-limit');
|
|
for (const expectedStatus of [401, 401, 429]) {
|
|
const response = await request('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email: rateLimitedEmail, password }),
|
|
});
|
|
assert.equal(response.response.status, expectedStatus);
|
|
if (expectedStatus === 429) {
|
|
assert.equal(
|
|
expectError(response.body).error.code,
|
|
'AUTH_RATE_LIMITED',
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('更新资料遵守版本与幂等约束,改密会撤销旧会话并轮换当前会话', async () => {
|
|
const accountEmail = email('profile-password');
|
|
const registration = await request('/api/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
email: accountEmail,
|
|
password,
|
|
password_confirmation: password,
|
|
display_name: '原始名称',
|
|
}),
|
|
});
|
|
const registered = expectSuccess<SessionPayload>(
|
|
registration.body,
|
|
).data;
|
|
testUsers.push({
|
|
userId: registered.user.id,
|
|
workspaceId: registered.workspace.id,
|
|
});
|
|
|
|
const profileKey = randomUUID();
|
|
const update = await request('/api/auth/me', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
Authorization: `Bearer ${registered.access_token}`,
|
|
'If-Match': `"${registered.user.revision}"`,
|
|
'Idempotency-Key': profileKey,
|
|
},
|
|
body: JSON.stringify({ display_name: '更新后的名称' }),
|
|
});
|
|
assert.equal(update.response.status, 200);
|
|
const updatedUser = expectSuccess<SessionPayload['user']>(
|
|
update.body,
|
|
).data;
|
|
assert.equal(updatedUser.display_name, '更新后的名称');
|
|
assert.equal(updatedUser.avatar_asset_id, null);
|
|
assert.equal(updatedUser.revision, registered.user.revision + 1);
|
|
|
|
const replay = await request('/api/auth/me', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
Authorization: `Bearer ${registered.access_token}`,
|
|
'If-Match': `"${registered.user.revision}"`,
|
|
'Idempotency-Key': profileKey,
|
|
},
|
|
body: JSON.stringify({ display_name: '更新后的名称' }),
|
|
});
|
|
assert.equal(replay.response.status, 200);
|
|
assert.deepEqual(replay.body, update.body);
|
|
|
|
const stale = await request('/api/auth/me', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
Authorization: `Bearer ${registered.access_token}`,
|
|
'If-Match': `"${registered.user.revision}"`,
|
|
'Idempotency-Key': randomUUID(),
|
|
},
|
|
body: JSON.stringify({ display_name: '过期修改' }),
|
|
});
|
|
assert.equal(stale.response.status, 409);
|
|
assert.equal(expectError(stale.body).error.code, 'VERSION_CONFLICT');
|
|
|
|
const invalidAvatar = await request('/api/auth/me', {
|
|
method: 'PATCH',
|
|
headers: {
|
|
Authorization: `Bearer ${registered.access_token}`,
|
|
'If-Match': `"${updatedUser.revision}"`,
|
|
'Idempotency-Key': randomUUID(),
|
|
},
|
|
body: JSON.stringify({ avatar_asset_id: randomUUID() }),
|
|
});
|
|
assert.equal(invalidAvatar.response.status, 422);
|
|
assert.equal(
|
|
expectError(invalidAvatar.body).error.code,
|
|
'AVATAR_ASSET_UNAVAILABLE',
|
|
);
|
|
|
|
const secondLogin = await request('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email: accountEmail, password }),
|
|
});
|
|
const otherSession = expectSuccess<SessionPayload>(
|
|
secondLogin.body,
|
|
).data;
|
|
|
|
const changed = await request('/api/auth/change-password', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${registered.access_token}` },
|
|
body: JSON.stringify({
|
|
old_password: password,
|
|
password: 'new-correct-horse-battery-staple-456',
|
|
password_confirmation: 'new-correct-horse-battery-staple-456',
|
|
}),
|
|
});
|
|
assert.equal(changed.response.status, 200);
|
|
const rotated = expectSuccess<{
|
|
access_token: string;
|
|
access_token_expires_in: number;
|
|
}>(changed.body).data;
|
|
assert.ok(rotated.access_token);
|
|
refreshCookie(changed.response);
|
|
|
|
const formerCurrent = await request('/api/auth/me', {
|
|
headers: { Authorization: `Bearer ${registered.access_token}` },
|
|
});
|
|
assert.equal(formerCurrent.response.status, 401);
|
|
const revokedOther = await request('/api/auth/me', {
|
|
headers: { Authorization: `Bearer ${otherSession.access_token}` },
|
|
});
|
|
assert.equal(revokedOther.response.status, 401);
|
|
const current = await request('/api/auth/me', {
|
|
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
|
});
|
|
assert.equal(current.response.status, 200);
|
|
|
|
const oldPasswordChange = await request('/api/auth/change-password', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${rotated.access_token}` },
|
|
body: JSON.stringify({
|
|
old_password: password,
|
|
password: 'another-correct-horse-battery-staple-789',
|
|
password_confirmation:
|
|
'another-correct-horse-battery-staple-789',
|
|
}),
|
|
});
|
|
assert.equal(oldPasswordChange.response.status, 422);
|
|
assert.equal(
|
|
expectError(oldPasswordChange.body).error.code,
|
|
'VALIDATION_FAILED',
|
|
);
|
|
const newPasswordLogin = await request('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
email: accountEmail,
|
|
password: 'new-correct-horse-battery-staple-456',
|
|
}),
|
|
});
|
|
assert.equal(newPasswordLogin.response.status, 200);
|
|
});
|
|
});
|