feat: establish auth listing and image vertical slices
Includes MFA-capable authentication, session and CSRF security, password recovery, listing lifecycle and ownership rules, inventory image metadata, local ImageStorage, migrations, tests, and implementation status documentation. Verification: npm test (62 passing) and integration suite (14 passing).
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js';
|
||||
import { createProductService } from '../../src/modules/products/services/product-service.js';
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
const products = [];
|
||||
const productRepository = {
|
||||
async create(product) {
|
||||
const createdProduct = {
|
||||
id: '8a0a836a-a597-454d-8c5e-e146d27dbbf1',
|
||||
...product,
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
};
|
||||
products.push(createdProduct);
|
||||
return createdProduct;
|
||||
},
|
||||
async findBySellerIdAndSlug(sellerId, slug) {
|
||||
return products.find(
|
||||
(product) => product.sellerId === sellerId && product.slug === slug,
|
||||
);
|
||||
},
|
||||
async listBySellerId(sellerId) {
|
||||
return products.filter((product) => product.sellerId === sellerId);
|
||||
},
|
||||
};
|
||||
|
||||
return buildApp({
|
||||
authenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
inventoryItemService: createInventoryItemService({
|
||||
productRepository: {
|
||||
async findById(id) {
|
||||
return products.find((product) => product.id === id) ?? null;
|
||||
},
|
||||
},
|
||||
conditionRepository: {
|
||||
async findById(id) {
|
||||
return id === '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e'
|
||||
? {
|
||||
id,
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
},
|
||||
inventoryItemRepository: {
|
||||
async create(inventoryItem) {
|
||||
return {
|
||||
id: 'inventory-1',
|
||||
...inventoryItem,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
productService: createProductService({ productRepository }),
|
||||
});
|
||||
}
|
||||
|
||||
test('creates and lists products for the authenticated seller', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
payload: {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(createResponse.statusCode, 201);
|
||||
assert.equal(createResponse.json().data.slug, 'pikachu-plush');
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/products',
|
||||
});
|
||||
|
||||
assert.equal(listResponse.statusCode, 200);
|
||||
assert.equal(listResponse.json().data.length, 1);
|
||||
});
|
||||
|
||||
test('rejects invalid product requests', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
payload: { name: '' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error.code, 'VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
test('rejects unauthenticated admin requests by default', async (testContext) => {
|
||||
const app = createTestApp();
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/products',
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 401);
|
||||
assert.equal(response.json().error.code, 'UNAUTHORIZED');
|
||||
});
|
||||
|
||||
test('creates inventory only for the authenticated sellers product', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
const productResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
payload: {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory',
|
||||
payload: {
|
||||
productId: productResponse.json().data.id,
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.status, 'ACTIVE');
|
||||
});
|
||||
|
||||
test('rejects an authenticated product write without a CSRF token', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
payload: {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.json().error.code, 'FORBIDDEN');
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { createRedisLoginRateLimiter } from '../../src/modules/auth/services/login-rate-limiter.js';
|
||||
|
||||
function createTestApp(authenticationService, dependencies = {}) {
|
||||
return buildApp({
|
||||
authenticationService,
|
||||
loginRateLimiter: { async consume() { return { allowed: true }; } },
|
||||
...dependencies,
|
||||
});
|
||||
}
|
||||
|
||||
test('places a successful login session only in a strict HttpOnly cookie', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async login() {
|
||||
return {
|
||||
sessionToken: 'raw-session-token',
|
||||
csrfToken: 'raw-csrf-token',
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
};
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@example.test', password: 'correct-password' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.sessionToken, undefined);
|
||||
const cookies = response.headers['set-cookie'].join('\n');
|
||||
assert.match(cookies, /HttpOnly/);
|
||||
assert.match(cookies, /SameSite=Strict/);
|
||||
assert.match(cookies, /pokemon_inventory_session=raw-session-token/);
|
||||
assert.match(cookies, /pokemon_inventory_csrf=raw-csrf-token/);
|
||||
});
|
||||
|
||||
test('rejects malformed login bodies before authentication', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async login() {
|
||||
throw new Error('Authentication should not run.');
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'not-an-email', password: '' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error.code, 'VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
test('reads the authenticated principal from the session cookie', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async authenticate(sessionToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
return { userId: 'user-id', sellerId: 'seller-id', role: 'ADMIN' };
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.sellerId, 'seller-id');
|
||||
});
|
||||
|
||||
test('revokes the session and clears its cookie on logout', async (testContext) => {
|
||||
let revokedToken;
|
||||
const app = createTestApp({
|
||||
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
assert.equal(csrfToken, 'raw-csrf-token');
|
||||
return { userId: 'user-id' };
|
||||
},
|
||||
async logout(sessionToken) {
|
||||
revokedToken = sessionToken;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: {
|
||||
cookie: 'pokemon_inventory_session=raw-session-token',
|
||||
'x-csrf-token': 'raw-csrf-token',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(revokedToken, 'raw-session-token');
|
||||
assert.match(response.headers['set-cookie'].join('\n'), /Max-Age=0/);
|
||||
});
|
||||
|
||||
test('rejects logout without a session-bound CSRF token', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async authorizeUnsafeRequest() {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.json().error.code, 'FORBIDDEN');
|
||||
});
|
||||
|
||||
test('limits login attempts by client before credential verification', async (testContext) => {
|
||||
let loginCalls = 0;
|
||||
const attemptsByKey = new Map();
|
||||
const redisClient = {
|
||||
async eval(script, { arguments: scriptArguments, keys }) {
|
||||
assert.match(script, /INCR/);
|
||||
assert.equal(scriptArguments[0], String(15 * 60 * 1000));
|
||||
const key = keys[0];
|
||||
const count = (attemptsByKey.get(key) ?? 0) + 1;
|
||||
attemptsByKey.set(key, count);
|
||||
return [count, 15 * 60 * 1000];
|
||||
},
|
||||
};
|
||||
const app = createTestApp(
|
||||
{
|
||||
async login() {
|
||||
loginCalls += 1;
|
||||
throw new Error('Invalid credentials');
|
||||
},
|
||||
},
|
||||
{
|
||||
loginRateLimiter: createRedisLoginRateLimiter({
|
||||
maxAttempts: 2,
|
||||
redisClient,
|
||||
}),
|
||||
},
|
||||
);
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const request = {
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@example.test', password: 'incorrect-password' },
|
||||
remoteAddress: '198.51.100.8',
|
||||
};
|
||||
|
||||
const firstResponse = await app.inject(request);
|
||||
const secondResponse = await app.inject(request);
|
||||
const blockedResponse = await app.inject(request);
|
||||
const otherClientResponse = await app.inject({
|
||||
...request,
|
||||
remoteAddress: '198.51.100.9',
|
||||
});
|
||||
|
||||
assert.equal(firstResponse.statusCode, 500);
|
||||
assert.equal(secondResponse.statusCode, 500);
|
||||
assert.equal(blockedResponse.statusCode, 429);
|
||||
assert.equal(blockedResponse.json().error.code, 'AUTH_RATE_LIMITED');
|
||||
assert.equal(blockedResponse.headers['retry-after'], '900');
|
||||
assert.equal(otherClientResponse.statusCode, 500);
|
||||
assert.equal(loginCalls, 3);
|
||||
});
|
||||
|
||||
test('lists active sessions without exposing credential material', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async listActiveSessions(sessionToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
return {
|
||||
currentSessionId: '3d0a3713-5b8f-4080-af25-b2e6eb4150b1',
|
||||
sessions: [
|
||||
{
|
||||
id: '3d0a3713-5b8f-4080-af25-b2e6eb4150b1',
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
expiresAt: new Date('2026-09-08T00:00:00.000Z'),
|
||||
lastActivityAt: new Date('2026-09-07T00:30:00.000Z'),
|
||||
ipAddress: '198.51.100.8',
|
||||
userAgent: 'Test Browser',
|
||||
sessionTokenHash: 'must-not-be-serialized',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/sessions',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data[0].isCurrent, true);
|
||||
assert.equal(response.json().data[0].sessionTokenHash, undefined);
|
||||
});
|
||||
|
||||
test('requires CSRF validation to revoke a session and clears current-session cookies', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
assert.equal(csrfToken, 'raw-csrf-token');
|
||||
},
|
||||
async revokeSession(sessionToken, sessionId) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
assert.equal(sessionId, '3d0a3713-5b8f-4080-af25-b2e6eb4150b1');
|
||||
return { currentSessionRevoked: true };
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/auth/sessions/3d0a3713-5b8f-4080-af25-b2e6eb4150b1',
|
||||
headers: {
|
||||
cookie: 'pokemon_inventory_session=raw-session-token',
|
||||
'x-csrf-token': 'raw-csrf-token',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(response.headers['set-cookie'].join('\n'), /pokemon_inventory_session=; Max-Age=0/);
|
||||
assert.match(response.headers['set-cookie'].join('\n'), /pokemon_inventory_csrf=; Max-Age=0/);
|
||||
});
|
||||
|
||||
test('revokes other sessions while retaining the current session', async (testContext) => {
|
||||
let revokedOtherSessions = false;
|
||||
const app = createTestApp({
|
||||
async authorizeUnsafeRequest() {},
|
||||
async revokeOtherSessions(sessionToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
revokedOtherSessions = true;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/auth/sessions',
|
||||
headers: {
|
||||
cookie: 'pokemon_inventory_session=raw-session-token',
|
||||
'x-csrf-token': 'raw-csrf-token',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(revokedOtherSessions, true);
|
||||
assert.equal(response.headers['set-cookie'], undefined);
|
||||
});
|
||||
|
||||
test('accepts password reset requests without returning a reset token', async (testContext) => {
|
||||
let resetRequest;
|
||||
const app = createTestApp(
|
||||
{},
|
||||
{
|
||||
passwordRecoveryService: {
|
||||
async requestReset(input) {
|
||||
resetRequest = input;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: 'admin@example.test' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 202);
|
||||
assert.equal(response.json().data, null);
|
||||
assert.equal('token' in response.json(), false);
|
||||
assert.equal(resetRequest.email, 'admin@example.test');
|
||||
});
|
||||
|
||||
test('resets a password only with a valid-shaped recovery token', async (testContext) => {
|
||||
let resetInput;
|
||||
const app = createTestApp(
|
||||
{},
|
||||
{
|
||||
passwordRecoveryService: {
|
||||
async resetPassword(input) {
|
||||
resetInput = input;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: { token: 'a'.repeat(32), password: 'new-password-value' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(resetInput.password, 'new-password-value');
|
||||
});
|
||||
|
||||
test('limits password reset requests independently from login attempts', async (testContext) => {
|
||||
const app = createTestApp(
|
||||
{},
|
||||
{
|
||||
passwordRecoveryService: { async requestReset() {} },
|
||||
passwordResetRateLimiter: {
|
||||
async consume() {
|
||||
return { allowed: false, retryAfterSeconds: 60 };
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: 'admin@example.test' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 429);
|
||||
assert.equal(response.json().error.code, 'AUTH_RATE_LIMITED');
|
||||
assert.equal(response.headers['retry-after'], '60');
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { createListingService } from '../../src/modules/listings/services/listing-service.js';
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } = {}) {
|
||||
const app = buildApp({
|
||||
authenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
listingService: createListingService({
|
||||
listingRepository: {
|
||||
async create(listing) {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
...listing,
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
};
|
||||
},
|
||||
async findByIdForSeller() {
|
||||
return listing ?? {
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-1',
|
||||
conditionId: 'condition-1',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
};
|
||||
},
|
||||
async listBySellerId() {
|
||||
return [{
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-1',
|
||||
conditionId: 'condition-1',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
}];
|
||||
},
|
||||
async updateStatus(id, status) {
|
||||
return {
|
||||
...(listing ?? {
|
||||
id,
|
||||
inventoryItemId: 'inventory-1',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-1',
|
||||
conditionId: 'condition-1',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
}),
|
||||
id,
|
||||
status,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
inventoryImageService: {
|
||||
async create(input) {
|
||||
return {
|
||||
id: 'image-1',
|
||||
...input,
|
||||
processingStatus: 'PENDING',
|
||||
createdAt: new Date('2026-09-08T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-08T00:00:00.000Z'),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
test('creates a draft listing for the authenticated seller', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/listings',
|
||||
payload: {
|
||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
notes: ' Near mint ',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.status, 'DRAFT');
|
||||
assert.equal(response.json().data.productName, 'Charizard ex');
|
||||
});
|
||||
|
||||
test('rejects a listing write without CSRF validation', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/listings',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
payload: {
|
||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.json().error.code, 'FORBIDDEN');
|
||||
});
|
||||
|
||||
test('publishes a listing through the authenticated, CSRF-protected lifecycle route', async (testContext) => {
|
||||
let csrfChecks = 0;
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/listings/listing-1/publish',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.status, 'AVAILABLE');
|
||||
assert.equal(csrfChecks, 1);
|
||||
});
|
||||
|
||||
test('reads seller-owned listings without requiring CSRF validation', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
throw new Error('CSRF should not be checked for reads.');
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const collectionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/listings',
|
||||
});
|
||||
const detailResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/listings/listing-1',
|
||||
});
|
||||
|
||||
assert.equal(collectionResponse.statusCode, 200);
|
||||
assert.equal(collectionResponse.json().data[0].id, 'listing-1');
|
||||
assert.equal(detailResponse.statusCode, 200);
|
||||
assert.equal(detailResponse.json().data.status, 'DRAFT');
|
||||
});
|
||||
|
||||
test('adds inventory image metadata for the authenticated seller', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory/inventory-1/images',
|
||||
payload: {
|
||||
storageKey: 'inventory/inventory-1/front.jpg',
|
||||
originalFilename: 'front.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: 12000,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
altText: 'Front of Charizard ex card',
|
||||
isPrimary: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.inventoryItemId, 'inventory-1');
|
||||
assert.equal(response.json().data.processingStatus, 'PENDING');
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
|
||||
const userId = '464e9b49-8617-4d00-af3c-78b3294e1d70';
|
||||
const methodId = '6833868a-2a1b-4d33-90d1-fc9f2b7be869';
|
||||
const headers = {
|
||||
cookie: 'pokemon_inventory_session=session-token',
|
||||
'x-csrf-token': 'csrf-token',
|
||||
};
|
||||
|
||||
function createTestApp(mfaService) {
|
||||
return buildApp({
|
||||
authenticationService: {
|
||||
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
||||
assert.equal(sessionToken, 'session-token');
|
||||
assert.equal(csrfToken, 'csrf-token');
|
||||
return { userId };
|
||||
},
|
||||
},
|
||||
loginRateLimiter: { async consume() { return { allowed: true }; } },
|
||||
mfaService,
|
||||
});
|
||||
}
|
||||
|
||||
test('enrolls MFA only for the authenticated user and returns its provisioning URI', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async enroll(input) {
|
||||
assert.deepEqual(input, { userId, name: 'Authenticator' });
|
||||
return { id: methodId, provisioningUri: 'otpauth://totp/example' };
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/mfa/enroll',
|
||||
headers,
|
||||
payload: { name: 'Authenticator' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.provisioningUri, 'otpauth://totp/example');
|
||||
});
|
||||
|
||||
test('verifies MFA with CSRF protection and does not expose secret material', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async verifyEnrollment(input) {
|
||||
assert.deepEqual(input, { userId, methodId, code: '123456' });
|
||||
return { id: methodId, name: 'Authenticator', type: 'TOTP', secretCiphertext: 'hidden' };
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/mfa/verify',
|
||||
headers,
|
||||
payload: { methodId, code: '123456' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.secretCiphertext, undefined);
|
||||
assert.equal(response.json().data.type, 'TOTP');
|
||||
});
|
||||
|
||||
test('rejects MFA enrollment without CSRF validation', async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticationService: {
|
||||
async authorizeUnsafeRequest() {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
loginRateLimiter: { async consume() { return { allowed: true }; } },
|
||||
mfaService: { async enroll() {} },
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/mfa/enroll',
|
||||
payload: { name: 'Authenticator' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.json().error.code, 'FORBIDDEN');
|
||||
});
|
||||
Reference in New Issue
Block a user