feat(api): 开发平台、门店相关代码
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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 };
|
||||
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.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',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
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 } };
|
||||
type Session = {
|
||||
access_token: string;
|
||||
user: { id: string };
|
||||
workspace: { id: string };
|
||||
};
|
||||
type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
status: string;
|
||||
credential_status: string;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
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) {
|
||||
return `${label}-${randomUUID()}@example.test`;
|
||||
}
|
||||
|
||||
async function request(path: string, init: RequestInit = {}) {
|
||||
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 success<T>(body: unknown): SuccessResponse<T> {
|
||||
assert.ok(body && typeof body === 'object' && 'data' in body);
|
||||
return body as SuccessResponse<T>;
|
||||
}
|
||||
|
||||
function error(body: unknown): ErrorResponse {
|
||||
assert.ok(body && typeof body === 'object' && 'error' in body);
|
||||
return body as ErrorResponse;
|
||||
}
|
||||
|
||||
async function register(label: string): Promise<Session> {
|
||||
const result = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: email(label),
|
||||
password,
|
||||
password_confirmation: password,
|
||||
}),
|
||||
});
|
||||
assert.equal(result.response.status, 201);
|
||||
const session = success<Session>(result.body).data;
|
||||
testUsers.push({
|
||||
userId: session.user.id,
|
||||
workspaceId: session.workspace.id,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
function headers(session: Session, idempotencyKey?: string) {
|
||||
return {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('门店 HTTP 集成测试', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
AUTH_LOGIN_IP_LIMIT: '100',
|
||||
AUTH_LOGIN_EMAIL_LIMIT: '100',
|
||||
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) {
|
||||
// 门店测试会创建操作和 Outbox;先在工作空间事务内清理这些受 RLS 保护的记录。
|
||||
await database.workspaceTransaction(
|
||||
user.workspaceId,
|
||||
async (tx) => {
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1',
|
||||
[user.workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.outbox_events WHERE workspace_id = $1',
|
||||
[user.workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.operations WHERE workspace_id = $1',
|
||||
[user.workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.stores 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('创建门店时加密保存凭据、创建操作,并能安全重放相同请求', async () => {
|
||||
const owner = await register('stores-owner');
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: headers(owner),
|
||||
});
|
||||
assert.equal(platforms.response.status, 200);
|
||||
const platform = success<
|
||||
Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
default_market_code: string | null;
|
||||
default_locale: string | null;
|
||||
}>
|
||||
>(platforms.body).data.find((item) => item.code === 'OZON');
|
||||
assert.ok(platform);
|
||||
assert.equal(platform.default_market_code, 'RU');
|
||||
assert.equal(platform.default_locale, 'ru-RU');
|
||||
|
||||
const idempotencyKey = randomUUID();
|
||||
const createBody = {
|
||||
platform_id: platform.id,
|
||||
name: '测试 Ozon 主店',
|
||||
client_id: `client-${randomUUID()}`,
|
||||
api_key: 'secret-only-in-request',
|
||||
currency_code: 'RUB',
|
||||
is_default: false,
|
||||
note: '首家门店仍应成为默认门店',
|
||||
};
|
||||
const created = await request('/api/stores', {
|
||||
method: 'POST',
|
||||
headers: headers(owner, idempotencyKey),
|
||||
body: JSON.stringify(createBody),
|
||||
});
|
||||
assert.equal(created.response.status, 202);
|
||||
const createdData = success<{
|
||||
store: Store & { platform_client_id: string };
|
||||
operation: { id: string; status: string };
|
||||
}>(created.body).data;
|
||||
assert.equal(createdData.store.is_default, true);
|
||||
assert.equal(createdData.store.status, 'verifying');
|
||||
assert.equal(createdData.store.credential_status, 'unknown');
|
||||
assert.equal(
|
||||
createdData.store.platform_client_id,
|
||||
createBody.client_id,
|
||||
);
|
||||
assert.equal(createdData.operation.status, 'queued');
|
||||
assert.equal(
|
||||
JSON.stringify(created.body).includes(createBody.api_key),
|
||||
false,
|
||||
);
|
||||
|
||||
const replay = await request('/api/stores', {
|
||||
method: 'POST',
|
||||
headers: headers(owner, idempotencyKey),
|
||||
body: JSON.stringify(createBody),
|
||||
});
|
||||
assert.equal(replay.response.status, 202);
|
||||
assert.deepEqual(replay.body, created.body);
|
||||
|
||||
const mismatchedReplay = await request('/api/stores', {
|
||||
method: 'POST',
|
||||
headers: headers(owner, idempotencyKey),
|
||||
body: JSON.stringify({ ...createBody, name: '不同请求' }),
|
||||
});
|
||||
assert.equal(mismatchedReplay.response.status, 409);
|
||||
assert.equal(
|
||||
error(mismatchedReplay.body).error.code,
|
||||
'IDEMPOTENCY_KEY_REUSED',
|
||||
);
|
||||
|
||||
const context = await request('/api/platform-context', {
|
||||
headers: {
|
||||
...headers(owner),
|
||||
'X-Atlas-Platform-Id': platform.id,
|
||||
},
|
||||
});
|
||||
assert.equal(context.response.status, 200);
|
||||
assert.equal(
|
||||
success<{ store_count: number; default_store: { id: string } }>(
|
||||
context.body,
|
||||
).data.store_count,
|
||||
1,
|
||||
);
|
||||
|
||||
const operation = await request(
|
||||
`/api/operations/${createdData.operation.id}`,
|
||||
{ headers: headers(owner) },
|
||||
);
|
||||
assert.equal(operation.response.status, 200);
|
||||
assert.equal(
|
||||
success<{ status: string }>(operation.body).data.status,
|
||||
'queued',
|
||||
);
|
||||
});
|
||||
|
||||
it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
|
||||
const owner = await register('stores-lifecycle');
|
||||
const otherOwner = await register('stores-isolation');
|
||||
const platforms = await request('/api/platforms', {
|
||||
headers: headers(owner),
|
||||
});
|
||||
const platform = success<Array<{ id: string; code: string }>>(
|
||||
platforms.body,
|
||||
).data.find((item) => item.code === 'OZON');
|
||||
assert.ok(platform);
|
||||
|
||||
const first = await createStore(owner, platform.id, '第一家门店');
|
||||
const updated = await request(`/api/stores/${first.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers(owner, randomUUID()),
|
||||
'If-Match': `"${first.revision}"`,
|
||||
},
|
||||
body: JSON.stringify({ name: '第一家门店(已修改)' }),
|
||||
});
|
||||
assert.equal(updated.response.status, 200);
|
||||
const updatedStore = success<Store>(updated.body).data;
|
||||
assert.equal(updatedStore.revision, first.revision + 1);
|
||||
|
||||
const stale = await request(`/api/stores/${first.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers(owner, randomUUID()),
|
||||
'If-Match': `"${first.revision}"`,
|
||||
},
|
||||
body: JSON.stringify({ name: '过期修改' }),
|
||||
});
|
||||
assert.equal(stale.response.status, 409);
|
||||
assert.equal(error(stale.body).error.code, 'VERSION_CONFLICT');
|
||||
|
||||
const second = await createStore(owner, platform.id, '第二家门店');
|
||||
const makeDefault = await request(
|
||||
`/api/stores/${second.id}/set-default`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers(owner, randomUUID()),
|
||||
},
|
||||
);
|
||||
assert.equal(makeDefault.response.status, 200);
|
||||
assert.equal(success<Store>(makeDefault.body).data.is_default, true);
|
||||
|
||||
const foreignRead = await request(`/api/stores/${first.id}`, {
|
||||
headers: headers(otherOwner),
|
||||
});
|
||||
assert.equal(foreignRead.response.status, 404);
|
||||
|
||||
const deleted = await request(`/api/stores/${first.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers(owner, randomUUID()),
|
||||
});
|
||||
assert.equal(deleted.response.status, 204);
|
||||
const absent = await request(`/api/stores/${first.id}`, {
|
||||
headers: headers(owner),
|
||||
});
|
||||
assert.equal(absent.response.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
async function createStore(owner: Session, platformId: string, name: string) {
|
||||
const result = await request('/api/stores', {
|
||||
method: 'POST',
|
||||
headers: headers(owner, randomUUID()),
|
||||
body: JSON.stringify({
|
||||
platform_id: platformId,
|
||||
name,
|
||||
client_id: `client-${randomUUID()}`,
|
||||
api_key: `key-${randomUUID()}`,
|
||||
currency_code: 'RUB',
|
||||
}),
|
||||
});
|
||||
assert.equal(result.response.status, 202);
|
||||
return success<{ store: Store }>(result.body).data.store;
|
||||
}
|
||||
Reference in New Issue
Block a user