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,201 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { hashPassword, verifyPassword } from '../../src/modules/auth/domain/password.js';
|
||||
import { createAuthenticationService } from '../../src/modules/auth/services/authentication-service.js';
|
||||
|
||||
test('verifies a password hash without accepting a different password', async () => {
|
||||
const passwordHash = await hashPassword('correct-password');
|
||||
|
||||
assert.equal(await verifyPassword('correct-password', passwordHash), true);
|
||||
assert.equal(await verifyPassword('incorrect-password', passwordHash), false);
|
||||
});
|
||||
|
||||
test('creates only a hashed session token after valid credentials', async () => {
|
||||
let createdSession;
|
||||
const auditEvents = [];
|
||||
const service = createAuthenticationService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
passwordVerifier: async (password) => password === 'correct-password',
|
||||
sessionRepository: {
|
||||
async create(session) {
|
||||
createdSession = session;
|
||||
},
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
userRepository: {
|
||||
async findAuthenticationUserByEmail() {
|
||||
return {
|
||||
id: 'user-id',
|
||||
sellerId: 'seller-id',
|
||||
role: 'ADMIN',
|
||||
passwordHash: 'stored-hash',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.login({
|
||||
email: 'admin@example.test',
|
||||
password: 'correct-password',
|
||||
});
|
||||
|
||||
assert.notEqual(createdSession.sessionTokenHash, result.sessionToken);
|
||||
assert.notEqual(createdSession.csrfTokenHash, result.csrfToken);
|
||||
assert.equal(createdSession.userId, 'user-id');
|
||||
assert.equal(result.principal.sellerId, 'seller-id');
|
||||
assert.deepEqual(auditEvents, [
|
||||
{ userId: 'user-id', event: 'LOGIN_SUCCESS', ipAddress: null, userAgent: null },
|
||||
{ userId: 'user-id', event: 'SESSION_CREATED', ipAddress: null, userAgent: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not create a session for invalid credentials', async () => {
|
||||
let sessionCreated = false;
|
||||
const auditEvents = [];
|
||||
const service = createAuthenticationService({
|
||||
passwordVerifier: async () => false,
|
||||
sessionRepository: {
|
||||
async create() {
|
||||
sessionCreated = true;
|
||||
},
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
userRepository: {
|
||||
async findAuthenticationUserByEmail() {
|
||||
return { id: 'user-id', passwordHash: 'stored-hash' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.login({ email: 'admin@example.test', password: 'incorrect-password' }),
|
||||
(error) => error.code === 'UNAUTHORIZED',
|
||||
);
|
||||
assert.equal(sessionCreated, false);
|
||||
assert.deepEqual(auditEvents, [{ event: 'LOGIN_FAILURE', ipAddress: null, userAgent: null }]);
|
||||
});
|
||||
|
||||
test('does not create a session until enabled MFA verifies the login code', async () => {
|
||||
let sessionCreated = false;
|
||||
let receivedMfaInput;
|
||||
const service = createAuthenticationService({
|
||||
mfaService: {
|
||||
async verifyLogin(input) {
|
||||
receivedMfaInput = input;
|
||||
const error = new Error('MFA code is invalid.');
|
||||
error.code = 'MFA_CODE_INVALID';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
passwordVerifier: async () => true,
|
||||
sessionRepository: { async create() { sessionCreated = true; } },
|
||||
userRepository: {
|
||||
async findAuthenticationUserByEmail() {
|
||||
return {
|
||||
id: 'user-id',
|
||||
sellerId: 'seller-id',
|
||||
role: 'ADMIN',
|
||||
passwordHash: 'stored-hash',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.login({ email: 'admin@example.test', password: 'correct-password' }),
|
||||
(error) => error.code === 'MFA_CODE_INVALID',
|
||||
);
|
||||
|
||||
assert.deepEqual(receivedMfaInput, { userId: 'user-id', code: undefined });
|
||||
assert.equal(sessionCreated, false);
|
||||
});
|
||||
|
||||
test('rejects an unsafe request without the session-bound CSRF token', async () => {
|
||||
const service = createAuthenticationService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
sessionRepository: {
|
||||
async findActiveAuthenticationSession() {
|
||||
return { csrfTokenHash: 'different-token-hash' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.authorizeUnsafeRequest('session-token', 'csrf-token'),
|
||||
(error) => error.code === 'FORBIDDEN',
|
||||
);
|
||||
});
|
||||
|
||||
test('lists and revokes only sessions owned by the authenticated user', async () => {
|
||||
let revokedSession;
|
||||
let revokedOthers;
|
||||
const auditEvents = [];
|
||||
const service = createAuthenticationService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
sessionRepository: {
|
||||
async findActiveAuthenticationSession() {
|
||||
return { id: 'current-session', userId: 'user-id', sellerId: 'seller-id', role: 'ADMIN' };
|
||||
},
|
||||
async listActiveByUserId(userId) {
|
||||
assert.equal(userId, 'user-id');
|
||||
return [{ id: 'current-session' }, { id: 'other-session' }];
|
||||
},
|
||||
async revokeByIdAndUserId(id, userId) {
|
||||
revokedSession = { id, userId };
|
||||
return true;
|
||||
},
|
||||
async revokeOtherByUserId(userId, currentSessionId) {
|
||||
revokedOthers = { userId, currentSessionId };
|
||||
},
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
});
|
||||
|
||||
const sessions = await service.listActiveSessions('session-token');
|
||||
const revoked = await service.revokeSession('session-token', 'other-session');
|
||||
await service.revokeOtherSessions('session-token');
|
||||
|
||||
assert.equal(sessions.currentSessionId, 'current-session');
|
||||
assert.deepEqual(revokedSession, { id: 'other-session', userId: 'user-id' });
|
||||
assert.equal(revoked.currentSessionRevoked, false);
|
||||
assert.deepEqual(revokedOthers, { userId: 'user-id', currentSessionId: 'current-session' });
|
||||
assert.deepEqual(auditEvents, [
|
||||
{
|
||||
userId: 'user-id',
|
||||
event: 'SESSION_REVOKED',
|
||||
metadata: { targetSessionId: 'other-session' },
|
||||
},
|
||||
{
|
||||
userId: 'user-id',
|
||||
event: 'SESSION_REVOKED',
|
||||
metadata: { scope: 'OTHER_SESSIONS' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('renews stale active session activity without updating recent activity', async () => {
|
||||
let touchedAt;
|
||||
const currentTime = new Date('2026-09-07T01:00:00.000Z');
|
||||
const service = createAuthenticationService({
|
||||
now: () => currentTime,
|
||||
sessionRepository: {
|
||||
async findActiveAuthenticationSession() {
|
||||
return {
|
||||
id: 'current-session',
|
||||
userId: 'user-id',
|
||||
sellerId: 'seller-id',
|
||||
role: 'ADMIN',
|
||||
lastActivityAt: new Date('2026-09-07T00:50:00.000Z'),
|
||||
};
|
||||
},
|
||||
async touchById(id, activityTime) {
|
||||
touchedAt = { id, activityTime };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.authenticate('session-token');
|
||||
|
||||
assert.deepEqual(touchedAt, { id: 'current-session', activityTime: currentTime });
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createImageStorageContract } from '../../src/infrastructure/images/image-storage.js';
|
||||
import { createLocalImageStorage } from '../../src/infrastructure/images/local-image-storage.js';
|
||||
|
||||
test('local image storage round-trips and deletes binary data', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'pokemon-images-'));
|
||||
const storage = createLocalImageStorage(root);
|
||||
|
||||
try {
|
||||
await storage.put({ key: 'inventory/item-1/front.jpg', data: Buffer.from('image-bytes') });
|
||||
assert.deepEqual(await storage.get('inventory/item-1/front.jpg'), Buffer.from('image-bytes'));
|
||||
await storage.delete('inventory/item-1/front.jpg');
|
||||
await assert.rejects(() => storage.get('inventory/item-1/front.jpg'), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('local image storage rejects traversal and duplicate writes', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'pokemon-images-'));
|
||||
const storage = createLocalImageStorage(root);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => storage.put({ key: '../outside.jpg', data: Buffer.from('image-bytes') }),
|
||||
/escapes the storage root/,
|
||||
);
|
||||
await storage.put({ key: 'same.jpg', data: Buffer.from('first') });
|
||||
await assert.rejects(
|
||||
() => storage.put({ key: 'same.jpg', data: Buffer.from('second') }),
|
||||
{ code: 'EEXIST' },
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('image storage contracts reject incomplete adapters', () => {
|
||||
assert.throws(
|
||||
() => createImageStorageContract({ put() {} }),
|
||||
/must implement get\(\)/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createInitialAdminService } from '../../src/modules/auth/services/initial-admin-service.js';
|
||||
|
||||
test('provisions a normalized administrator with a linked seller', async () => {
|
||||
let provisionedInput;
|
||||
const service = createInitialAdminService({
|
||||
passwordHasher: async (password) => `hashed:${password}`,
|
||||
userRepository: {
|
||||
async createInitialAdmin(input) {
|
||||
provisionedInput = input;
|
||||
return {
|
||||
user: { id: 'user-id', email: input.email },
|
||||
seller: { id: 'seller-id' },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const administrator = await service.provision({
|
||||
email: ' ADMIN@EXAMPLE.TEST ',
|
||||
password: 'local-password',
|
||||
sellerName: ' Catalog Owner ',
|
||||
});
|
||||
|
||||
assert.deepEqual(administrator, {
|
||||
userId: 'user-id',
|
||||
sellerId: 'seller-id',
|
||||
email: 'admin@example.test',
|
||||
});
|
||||
assert.deepEqual(provisionedInput, {
|
||||
email: 'admin@example.test',
|
||||
passwordHash: 'hashed:local-password',
|
||||
sellerName: 'Catalog Owner',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects provisioning after an administrator already exists', async () => {
|
||||
const service = createInitialAdminService({
|
||||
passwordHasher: async () => 'password-hash',
|
||||
userRepository: { async createInitialAdmin() { return null; } },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.provision({
|
||||
email: 'admin@example.test',
|
||||
password: 'local-password',
|
||||
sellerName: 'Catalog Owner',
|
||||
}),
|
||||
(error) => error.code === 'INITIAL_ADMIN_ALREADY_EXISTS',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js';
|
||||
|
||||
function createService({ product, condition }) {
|
||||
return createInventoryItemService({
|
||||
productRepository: { async findById() { return product; } },
|
||||
conditionRepository: { async findById() { return condition; } },
|
||||
inventoryItemRepository: { async create(inventoryItem) { return inventoryItem; } },
|
||||
});
|
||||
}
|
||||
|
||||
test('creates an inventory item when its condition belongs to the product category', async () => {
|
||||
const service = createService({
|
||||
product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' },
|
||||
condition: { id: 'condition-id', categoryId: 'cards' },
|
||||
});
|
||||
|
||||
const inventoryItem = await service.create({
|
||||
productId: 'product-id',
|
||||
conditionId: 'condition-id',
|
||||
sellerId: 'seller-id',
|
||||
notes: ' Near mint ',
|
||||
});
|
||||
|
||||
assert.deepEqual(inventoryItem, {
|
||||
productId: 'product-id',
|
||||
conditionId: 'condition-id',
|
||||
notes: 'Near mint',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects conditions from another category', async () => {
|
||||
const service = createService({
|
||||
product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' },
|
||||
condition: { id: 'condition-id', categoryId: 'merchandise' },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.create({
|
||||
productId: 'product-id',
|
||||
conditionId: 'condition-id',
|
||||
sellerId: 'seller-id',
|
||||
}),
|
||||
(error) => error.code === 'CONDITION_CATEGORY_MISMATCH',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createListingService } from '../../src/modules/listings/services/listing-service.js';
|
||||
|
||||
test('creates a draft listing only after the listing publish requirements are met', async () => {
|
||||
const created = [];
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async create(listing) {
|
||||
const record = { id: 'listing-1', ...listing };
|
||||
created.push(record);
|
||||
return record;
|
||||
},
|
||||
},
|
||||
inventoryImageRepository: {
|
||||
async countByInventoryItemId() {
|
||||
return 2;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listing = await service.create({
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: ' Charizard ex ',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
notes: ' Near mint ',
|
||||
});
|
||||
|
||||
assert.deepEqual(listing, {
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
notes: 'Near mint',
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.create({
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 0,
|
||||
imageCount: 2,
|
||||
}),
|
||||
(error) => error.code === 'LISTING_REQUIREMENTS_NOT_MET',
|
||||
);
|
||||
|
||||
assert.equal(created.length, 1);
|
||||
});
|
||||
|
||||
test('publishes an owned draft only when its persisted requirements are met', async () => {
|
||||
const updated = [];
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async findByIdForSeller() {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
};
|
||||
},
|
||||
async updateStatus(id, status) {
|
||||
updated.push({ id, status });
|
||||
return { id, status };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await service.transition({
|
||||
listingId: 'listing-1',
|
||||
sellerId: 'seller-1',
|
||||
nextStatus: 'AVAILABLE',
|
||||
}),
|
||||
{ id: 'listing-1', status: 'AVAILABLE' },
|
||||
);
|
||||
assert.deepEqual(updated, [{ id: 'listing-1', status: 'AVAILABLE' }]);
|
||||
});
|
||||
|
||||
test('does not transition a listing owned by another seller', async () => {
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async findByIdForSeller() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.transition({ listingId: 'listing-1', sellerId: 'seller-2', nextStatus: 'AVAILABLE' }),
|
||||
(error) => error.code === 'LISTING_NOT_FOUND' && error.statusCode === 404,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not publish a listing for sold or archived inventory', async () => {
|
||||
for (const inventoryStatus of ['SOLD', 'ARCHIVED']) {
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async findByIdForSeller() {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
inventoryStatus,
|
||||
status: 'DRAFT',
|
||||
};
|
||||
},
|
||||
async updateStatus() {
|
||||
throw new Error('A blocked listing must not be updated.');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.transition({ listingId: 'listing-1', sellerId: 'seller-1', nextStatus: 'AVAILABLE' }),
|
||||
(error) => error.code === 'LISTING_INVENTORY_UNAVAILABLE' && error.statusCode === 409,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('lists and retrieves only seller-owned listings', async () => {
|
||||
const ownedListing = { id: 'listing-1', status: 'DRAFT' };
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async listBySellerId(sellerId) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return [ownedListing];
|
||||
},
|
||||
async findByIdForSeller(listingId, sellerId) {
|
||||
assert.equal(listingId, 'listing-1');
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return ownedListing;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await service.list({ sellerId: 'seller-1' }), [ownedListing]);
|
||||
assert.deepEqual(await service.get({ listingId: 'listing-1', sellerId: 'seller-1' }), ownedListing);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createSecretProtector } from '../../src/modules/auth/domain/secret-protection.js';
|
||||
import { createMfaService } from '../../src/modules/auth/services/mfa-service.js';
|
||||
|
||||
function createService({ method, isCodeValid = true } = {}) {
|
||||
const createdMethods = [];
|
||||
const auditEvents = [];
|
||||
const repository = {
|
||||
async create(input) {
|
||||
const createdMethod = { id: 'method-id', isEnabled: false, ...input };
|
||||
createdMethods.push(createdMethod);
|
||||
return createdMethod;
|
||||
},
|
||||
async findActiveByIdAndUserId() {
|
||||
return method ?? createdMethods[0] ?? null;
|
||||
},
|
||||
async enable(id) {
|
||||
return { id, name: 'Authenticator', type: 'TOTP', isEnabled: true };
|
||||
},
|
||||
async disable(id) {
|
||||
return { id, isEnabled: false };
|
||||
},
|
||||
};
|
||||
const service = createMfaService({
|
||||
mfaMethodRepository: repository,
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
secretProtector: {
|
||||
encrypt(secret) { return `encrypted:${secret}`; },
|
||||
decrypt(ciphertext) { return ciphertext.replace('encrypted:', ''); },
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
totpEnrollment: () => ({ secret: 'TOTPSECRET', provisioningUri: 'otpauth://test' }),
|
||||
totpVerifier: () => isCodeValid,
|
||||
});
|
||||
|
||||
return { auditEvents, createdMethods, service };
|
||||
}
|
||||
|
||||
test('encrypts the TOTP secret and returns the provisioning URI only during enrollment', async () => {
|
||||
const { createdMethods, service } = createService();
|
||||
|
||||
const enrollment = await service.enroll({ userId: 'user-id', name: 'Authenticator' });
|
||||
|
||||
assert.deepEqual(enrollment, { id: 'method-id', provisioningUri: 'otpauth://test' });
|
||||
assert.equal(createdMethods[0].secretCiphertext, 'encrypted:TOTPSECRET');
|
||||
assert.equal(createdMethods[0].isEnabled, false);
|
||||
assert.equal('secret' in enrollment, false);
|
||||
});
|
||||
|
||||
test('encrypts MFA secrets with authenticated encryption', () => {
|
||||
const protector = createSecretProtector(Buffer.alloc(32, 7).toString('base64'));
|
||||
const ciphertext = protector.encrypt('TOTPSECRET');
|
||||
|
||||
assert.notEqual(ciphertext, 'TOTPSECRET');
|
||||
assert.equal(protector.decrypt(ciphertext), 'TOTPSECRET');
|
||||
});
|
||||
|
||||
test('enables an owned MFA method only after a valid code and records enrollment', async () => {
|
||||
const { auditEvents, service } = createService({
|
||||
method: {
|
||||
id: 'method-id',
|
||||
userId: 'user-id',
|
||||
type: 'TOTP',
|
||||
name: 'Authenticator',
|
||||
secretCiphertext: 'encrypted:TOTPSECRET',
|
||||
isEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const method = await service.verifyEnrollment({
|
||||
userId: 'user-id',
|
||||
methodId: 'method-id',
|
||||
code: '123456',
|
||||
});
|
||||
|
||||
assert.deepEqual(method, { id: 'method-id', name: 'Authenticator', type: 'TOTP' });
|
||||
assert.deepEqual(auditEvents, [{ userId: 'user-id', event: 'MFA_ENROLLED' }]);
|
||||
});
|
||||
|
||||
test('rejects invalid MFA codes without enabling or disabling a method', async () => {
|
||||
const { auditEvents, service } = createService({
|
||||
isCodeValid: false,
|
||||
method: {
|
||||
id: 'method-id',
|
||||
userId: 'user-id',
|
||||
type: 'TOTP',
|
||||
name: 'Authenticator',
|
||||
secretCiphertext: 'encrypted:TOTPSECRET',
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.disable({ userId: 'user-id', methodId: 'method-id', code: '000000' }),
|
||||
(error) => error.code === 'MFA_CODE_INVALID',
|
||||
);
|
||||
assert.deepEqual(auditEvents, [{ userId: 'user-id', event: 'MFA_FAILURE' }]);
|
||||
});
|
||||
|
||||
test('requires a valid TOTP code when an enabled MFA method exists at login', async () => {
|
||||
let markedMethod;
|
||||
const repository = {
|
||||
async listEnabledByUserId(userId) {
|
||||
assert.equal(userId, 'user-id');
|
||||
return [{ id: 'method-id', secretCiphertext: 'encrypted:TOTPSECRET' }];
|
||||
},
|
||||
async markUsed(id) {
|
||||
markedMethod = id;
|
||||
},
|
||||
};
|
||||
const service = createMfaService({
|
||||
mfaMethodRepository: repository,
|
||||
secretProtector: { decrypt() { return 'TOTPSECRET'; } },
|
||||
totpVerifier: ({ code }) => code === '123456',
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.verifyLogin({ userId: 'user-id', code: undefined }),
|
||||
(error) => error.code === 'MFA_CODE_INVALID',
|
||||
);
|
||||
await service.verifyLogin({ userId: 'user-id', code: '123456' });
|
||||
|
||||
assert.equal(markedMethod, 'method-id');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createPasswordRecoveryService } from '../../src/modules/auth/services/password-recovery-service.js';
|
||||
|
||||
test('does not issue a token for an unknown email while recording a generic request', async () => {
|
||||
let resetIssued = false;
|
||||
const auditEvents = [];
|
||||
const service = createPasswordRecoveryService({
|
||||
passwordResetRepository: { async issue() { resetIssued = true; } },
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
userRepository: { async findAuthenticationUserByEmail() { return null; } },
|
||||
});
|
||||
|
||||
await service.requestReset({ email: 'unknown@example.test' });
|
||||
|
||||
assert.equal(resetIssued, false);
|
||||
assert.deepEqual(auditEvents, [
|
||||
{ event: 'PASSWORD_RESET_REQUESTED', ipAddress: null, userAgent: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('delivers a reset token without persisting the raw token', async () => {
|
||||
let issuedReset;
|
||||
let deliveredReset;
|
||||
const service = createPasswordRecoveryService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
passwordResetNotifier: { async send(reset) { deliveredReset = reset; } },
|
||||
passwordResetRepository: { async issue(reset) { issuedReset = reset; } },
|
||||
userRepository: {
|
||||
async findAuthenticationUserByEmail() {
|
||||
return { id: 'user-id', email: 'admin@example.test' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.requestReset({ email: 'admin@example.test' });
|
||||
|
||||
assert.equal(issuedReset.userId, 'user-id');
|
||||
assert.notEqual(issuedReset.tokenHash, deliveredReset.token);
|
||||
assert.equal(deliveredReset.email, 'admin@example.test');
|
||||
assert.equal(deliveredReset.expiresAt.toISOString(), '2026-09-07T01:00:00.000Z');
|
||||
});
|
||||
|
||||
test('consumes a reset token once and audits a successful password reset', async () => {
|
||||
let consumedReset;
|
||||
const auditEvents = [];
|
||||
const service = createPasswordRecoveryService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
passwordHasher: async (password) => `hashed:${password}`,
|
||||
passwordResetRepository: {
|
||||
async consume(reset) {
|
||||
consumedReset = reset;
|
||||
return 'user-id';
|
||||
},
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
});
|
||||
|
||||
await service.resetPassword({ token: 'a-valid-reset-token', password: 'new-password-value' });
|
||||
|
||||
assert.equal(consumedReset.passwordHash, 'hashed:new-password-value');
|
||||
assert.notEqual(consumedReset.tokenHash, 'a-valid-reset-token');
|
||||
assert.deepEqual(auditEvents, [
|
||||
{ userId: 'user-id', event: 'PASSWORD_RESET', ipAddress: null, userAgent: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects expired or previously used reset tokens', async () => {
|
||||
const service = createPasswordRecoveryService({
|
||||
passwordHasher: async () => 'new-password-hash',
|
||||
passwordResetRepository: { async consume() { return null; } },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.resetPassword({ token: 'invalid-reset-token', password: 'new-password-value' }),
|
||||
(error) => error.code === 'PASSWORD_RESET_TOKEN_INVALID',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductService } from '../../src/modules/products/services/product-service.js';
|
||||
|
||||
test('creates seller-scoped slugs and increments duplicate names', async () => {
|
||||
const createdProducts = [];
|
||||
const productService = createProductService({
|
||||
productRepository: {
|
||||
async create(product) {
|
||||
createdProducts.push(product);
|
||||
return product;
|
||||
},
|
||||
async findBySellerIdAndSlug(sellerId, slug) {
|
||||
return (
|
||||
createdProducts.find(
|
||||
(product) => product.sellerId === sellerId && product.slug === slug,
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'category-id',
|
||||
sellerId: 'seller-id',
|
||||
};
|
||||
|
||||
await productService.create(input);
|
||||
const secondProduct = await productService.create(input);
|
||||
|
||||
assert.equal(secondProduct.slug, 'pikachu-plush-2');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assertInventoryItemEditable,
|
||||
INVENTORY_STATUS,
|
||||
transitionInventoryStatus,
|
||||
} from '../../src/modules/inventory/domain/inventory-item.js';
|
||||
|
||||
test('allows an active inventory item to be sold', () => {
|
||||
assert.equal(
|
||||
transitionInventoryStatus(INVENTORY_STATUS.ACTIVE, INVENTORY_STATUS.SOLD),
|
||||
INVENTORY_STATUS.SOLD,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects reopening sold inventory', () => {
|
||||
assert.throws(
|
||||
() => transitionInventoryStatus(INVENTORY_STATUS.SOLD, INVENTORY_STATUS.ACTIVE),
|
||||
/cannot transition/,
|
||||
);
|
||||
});
|
||||
|
||||
test('locks sold and archived items against ordinary edits', () => {
|
||||
assert.throws(
|
||||
() => assertInventoryItemEditable(INVENTORY_STATUS.SOLD),
|
||||
/locked against ordinary edits/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertInventoryItemEditable(INVENTORY_STATUS.ARCHIVED),
|
||||
/locked against ordinary edits/,
|
||||
);
|
||||
assert.doesNotThrow(() => assertInventoryItemEditable(INVENTORY_STATUS.ACTIVE));
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
LISTING_STATUS,
|
||||
canPublishListing,
|
||||
createListing,
|
||||
transitionListingStatus,
|
||||
} from '../../src/modules/listings/domain/listing.js';
|
||||
|
||||
test('creates a draft listing with the required public offer fields', () => {
|
||||
const listing = createListing({
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: ' Charizard ex ',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
notes: ' Near mint ',
|
||||
});
|
||||
|
||||
assert.deepEqual(listing, {
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: LISTING_STATUS.DRAFT,
|
||||
notes: 'Near mint',
|
||||
});
|
||||
});
|
||||
|
||||
test('requires the publication prerequisites before a listing can be made available', () => {
|
||||
const valid = {
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => canPublishListing(valid));
|
||||
|
||||
assert.throws(
|
||||
() => canPublishListing({ ...valid, askingPriceMinor: 0 }),
|
||||
(error) => error.code === 'LISTING_REQUIREMENTS_NOT_MET',
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => canPublishListing({ ...valid, imageCount: 0 }),
|
||||
(error) => error.code === 'LISTING_REQUIREMENTS_NOT_MET',
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => canPublishListing({ ...valid, inventoryStatus: 'SOLD' }),
|
||||
(error) => error.code === 'LISTING_INVENTORY_UNAVAILABLE' && error.statusCode === 409,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => canPublishListing({ ...valid, inventoryStatus: 'ARCHIVED' }),
|
||||
(error) => error.code === 'LISTING_INVENTORY_UNAVAILABLE',
|
||||
);
|
||||
});
|
||||
|
||||
test('allows only status transitions permitted by the listing lifecycle', () => {
|
||||
assert.equal(transitionListingStatus(LISTING_STATUS.DRAFT, LISTING_STATUS.AVAILABLE), LISTING_STATUS.AVAILABLE);
|
||||
assert.throws(
|
||||
() => transitionListingStatus(LISTING_STATUS.DRAFT, LISTING_STATUS.ARCHIVED),
|
||||
/Listing status cannot transition from DRAFT to ARCHIVED/,
|
||||
);
|
||||
assert.equal(transitionListingStatus(LISTING_STATUS.CLOSED, LISTING_STATUS.ARCHIVED), LISTING_STATUS.ARCHIVED);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProduct } from '../../src/modules/products/domain/product.js';
|
||||
|
||||
test('creates a product with required product identity fields', () => {
|
||||
const product = createProduct({
|
||||
name: ' Charizard ex ',
|
||||
categoryId: 'category-id',
|
||||
sellerId: 'seller-id',
|
||||
});
|
||||
|
||||
assert.deepEqual(product, {
|
||||
name: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
sellerId: 'seller-id',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a product without a name or category', () => {
|
||||
assert.throws(
|
||||
() => createProduct({ name: ' ', categoryId: 'category-id', sellerId: 'seller-id' }),
|
||||
/Product name is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createProduct({ name: 'Pikachu', categoryId: '', sellerId: 'seller-id' }),
|
||||
/Category ID is required/,
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after, beforeEach } from 'node:test';
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { createDatabase } from '../../src/infrastructure/database/client.js';
|
||||
import {
|
||||
categories,
|
||||
conditionProfiles,
|
||||
conditions,
|
||||
mfaMethods,
|
||||
passwordResetTokens,
|
||||
securityAuditEvents,
|
||||
sellers,
|
||||
sessions,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.js';
|
||||
import { createSessionRepository } from '../../src/modules/auth/repositories/session-repository.js';
|
||||
import { createSecurityAuditRepository } from '../../src/modules/auth/repositories/security-audit-repository.js';
|
||||
import { createUserRepository } from '../../src/modules/auth/repositories/user-repository.js';
|
||||
import { createPasswordResetRepository } from '../../src/modules/auth/repositories/password-reset-repository.js';
|
||||
import { createMfaMethodRepository } from '../../src/modules/auth/repositories/mfa-method-repository.js';
|
||||
import { createInventoryItemRepository } from '../../src/modules/inventory/repositories/inventory-item-repository.js';
|
||||
import { createProductRepository } from '../../src/modules/products/repositories/product-repository.js';
|
||||
|
||||
const connectionString = process.env.TEST_DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error('TEST_DATABASE_URL is required to run integration tests.');
|
||||
}
|
||||
|
||||
if (!new URL(connectionString).pathname.endsWith('_test')) {
|
||||
throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".');
|
||||
}
|
||||
|
||||
const database = createDatabase(connectionString);
|
||||
const productRepository = createProductRepository(database.db);
|
||||
const inventoryItemRepository = createInventoryItemRepository(database.db);
|
||||
const sessionRepository = createSessionRepository(database.db);
|
||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||
const mfaMethodRepository = createMfaMethodRepository(database.db);
|
||||
const securityAuditRepository = createSecurityAuditRepository(database.db);
|
||||
const userRepository = createUserRepository(database.db);
|
||||
|
||||
async function createCatalogPrerequisites() {
|
||||
const [user] = await database.db
|
||||
.insert(users)
|
||||
.values({ email: 'admin@example.test', passwordHash: 'not-a-real-password' })
|
||||
.returning();
|
||||
const [seller] = await database.db
|
||||
.insert(sellers)
|
||||
.values({ userId: user.id, displayName: 'Catalog Owner' })
|
||||
.returning();
|
||||
const [category] = await database.db
|
||||
.insert(categories)
|
||||
.values({ name: 'Trading Cards', slug: 'trading-cards' })
|
||||
.returning();
|
||||
const [conditionProfile] = await database.db
|
||||
.insert(conditionProfiles)
|
||||
.values({ categoryId: category.id, name: 'Card' })
|
||||
.returning();
|
||||
const [condition] = await database.db
|
||||
.insert(conditions)
|
||||
.values({
|
||||
conditionProfileId: conditionProfile.id,
|
||||
name: 'Near Mint',
|
||||
code: 'NM',
|
||||
qualityRank: 1,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { user, seller, category, condition };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await database.db.execute(
|
||||
sql`TRUNCATE security_audit_events, password_reset_tokens, mfa_methods, sessions, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`,
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
test('persists distinct products and physical inventory copies', async () => {
|
||||
const { seller, category, condition } = await createCatalogPrerequisites();
|
||||
const product = await productRepository.create({
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: 'Charizard ex',
|
||||
slug: 'charizard-ex-sv151-199',
|
||||
setName: 'Scarlet & Violet 151',
|
||||
cardNumber: '199/165',
|
||||
});
|
||||
const inventoryItem = await inventoryItemRepository.create({
|
||||
productId: product.id,
|
||||
conditionId: condition.id,
|
||||
notes: 'Clean front and back.',
|
||||
});
|
||||
|
||||
assert.equal(inventoryItem.productId, product.id);
|
||||
assert.equal(inventoryItem.status, 'ACTIVE');
|
||||
assert.equal(
|
||||
(await productRepository.listBySellerId(seller.id)).map(({ id }) => id).join(),
|
||||
product.id,
|
||||
);
|
||||
assert.equal(
|
||||
(await inventoryItemRepository.listByProductId(product.id)).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces seller-scoped product slugs', async () => {
|
||||
const { seller, category } = await createCatalogPrerequisites();
|
||||
const product = {
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: 'Pikachu Plush',
|
||||
slug: 'pikachu-plush',
|
||||
};
|
||||
|
||||
await productRepository.create(product);
|
||||
|
||||
await assert.rejects(
|
||||
() => productRepository.create(product),
|
||||
(error) => error.cause?.code === '23505',
|
||||
);
|
||||
});
|
||||
|
||||
test('finds a product by both its seller and slug', async () => {
|
||||
const { seller, category } = await createCatalogPrerequisites();
|
||||
const product = await productRepository.create({
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: 'Pikachu Plush',
|
||||
slug: 'pikachu-plush',
|
||||
});
|
||||
const [otherUser] = await database.db
|
||||
.insert(users)
|
||||
.values({ email: 'other-admin@example.test', passwordHash: 'not-a-real-password' })
|
||||
.returning();
|
||||
const [otherSeller] = await database.db
|
||||
.insert(sellers)
|
||||
.values({ userId: otherUser.id, displayName: 'Other Catalog Owner' })
|
||||
.returning();
|
||||
|
||||
assert.equal(
|
||||
(await productRepository.findBySellerIdAndSlug(seller.id, product.slug))?.id,
|
||||
product.id,
|
||||
);
|
||||
assert.equal(
|
||||
await productRepository.findBySellerIdAndSlug(otherSeller.id, product.slug),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('retrieves only non-revoked, non-expired sessions', async () => {
|
||||
const { user, seller } = await createCatalogPrerequisites();
|
||||
const tokenHash = 'a hashed session token';
|
||||
const expiresAt = new Date('2026-09-08T00:00:00.000Z');
|
||||
|
||||
const createdSession = await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash: tokenHash,
|
||||
csrfTokenHash: 'a hashed CSRF token',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const activeSession = await sessionRepository.findActiveAuthenticationSession(
|
||||
tokenHash,
|
||||
new Date('2026-09-07T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
assert.equal(activeSession.id, createdSession.id);
|
||||
assert.equal(activeSession.userId, user.id);
|
||||
assert.equal(activeSession.role, 'ADMIN');
|
||||
assert.equal(activeSession.sellerId, seller.id);
|
||||
assert.equal(activeSession.csrfTokenHash, 'a hashed CSRF token');
|
||||
assert.ok(activeSession.lastActivityAt instanceof Date);
|
||||
|
||||
await sessionRepository.revokeByTokenHash(
|
||||
tokenHash,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await sessionRepository.findActiveAuthenticationSession(
|
||||
tokenHash,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('provisions only one initial administrator with a linked seller', async () => {
|
||||
const createdAdmin = await userRepository.createInitialAdmin({
|
||||
email: 'admin@example.test',
|
||||
passwordHash: 'scrypt$test-hash',
|
||||
sellerName: 'Catalog Owner',
|
||||
});
|
||||
|
||||
assert.equal(createdAdmin.user.email, 'admin@example.test');
|
||||
assert.equal(createdAdmin.seller.userId, createdAdmin.user.id);
|
||||
assert.equal(
|
||||
await userRepository.createInitialAdmin({
|
||||
email: 'other@example.test',
|
||||
passwordHash: 'scrypt$test-hash',
|
||||
sellerName: 'Other Owner',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('lists active sessions and revokes all except the current session', async () => {
|
||||
const { user } = await createCatalogPrerequisites();
|
||||
const expiresAt = new Date('2026-09-08T00:00:00.000Z');
|
||||
const currentSession = await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash: 'current session hash',
|
||||
csrfTokenHash: 'current CSRF hash',
|
||||
expiresAt,
|
||||
});
|
||||
const otherSession = await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash: 'other session hash',
|
||||
csrfTokenHash: 'other CSRF hash',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
(await sessionRepository.listActiveByUserId(
|
||||
user.id,
|
||||
new Date('2026-09-07T00:00:00.000Z'),
|
||||
))
|
||||
.map((session) => session.id)
|
||||
.sort(),
|
||||
[currentSession.id, otherSession.id].sort(),
|
||||
);
|
||||
|
||||
await sessionRepository.revokeOtherByUserId(
|
||||
user.id,
|
||||
currentSession.id,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
(
|
||||
await sessionRepository.listActiveByUserId(
|
||||
user.id,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
)
|
||||
).map((session) => session.id),
|
||||
[currentSession.id],
|
||||
);
|
||||
});
|
||||
|
||||
test('records private authentication audit events without credentials', async () => {
|
||||
const { user } = await createCatalogPrerequisites();
|
||||
const event = await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'LOGIN_SUCCESS',
|
||||
ipAddress: '198.51.100.8',
|
||||
userAgent: 'Test Browser',
|
||||
});
|
||||
|
||||
const [storedEvent] = await database.db
|
||||
.select()
|
||||
.from(securityAuditEvents)
|
||||
.where(sql`${securityAuditEvents.id} = ${event.id}`);
|
||||
|
||||
assert.equal(storedEvent.userId, user.id);
|
||||
assert.equal(storedEvent.event, 'LOGIN_SUCCESS');
|
||||
assert.equal(storedEvent.ipAddress, '198.51.100.8');
|
||||
assert.ok(storedEvent.createdAt instanceof Date);
|
||||
assert.deepEqual(storedEvent.metadata, {});
|
||||
assert.equal('sessionTokenHash' in storedEvent, false);
|
||||
assert.equal('passwordHash' in storedEvent, false);
|
||||
});
|
||||
|
||||
test('consumes a password reset token once, replaces the password, and revokes sessions', async () => {
|
||||
const { user } = await createCatalogPrerequisites();
|
||||
const now = new Date('2026-09-07T00:00:00.000Z');
|
||||
const tokenHash = 'reset token hash';
|
||||
await passwordResetRepository.issue({
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt: new Date('2026-09-07T01:00:00.000Z'),
|
||||
});
|
||||
await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash: 'active session hash',
|
||||
csrfTokenHash: 'active CSRF hash',
|
||||
expiresAt: new Date('2026-09-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await passwordResetRepository.consume({
|
||||
tokenHash,
|
||||
passwordHash: 'replacement password hash',
|
||||
now,
|
||||
}),
|
||||
user.id,
|
||||
);
|
||||
assert.equal(
|
||||
await passwordResetRepository.consume({
|
||||
tokenHash,
|
||||
passwordHash: 'another password hash',
|
||||
now,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
const updatedUser = await userRepository.findAuthenticationUserByEmail('admin@example.test');
|
||||
const [resetToken] = await database.db
|
||||
.select()
|
||||
.from(passwordResetTokens)
|
||||
.where(sql`${passwordResetTokens.tokenHash} = ${tokenHash}`);
|
||||
|
||||
assert.equal(updatedUser.passwordHash, 'replacement password hash');
|
||||
assert.ok(resetToken.usedAt instanceof Date);
|
||||
assert.equal(
|
||||
await sessionRepository.findActiveAuthenticationSession(
|
||||
'active session hash',
|
||||
now,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('persists, enables, and disables an owned MFA method', async () => {
|
||||
const { user } = await createCatalogPrerequisites();
|
||||
const method = await mfaMethodRepository.create({
|
||||
userId: user.id,
|
||||
type: 'TOTP',
|
||||
name: 'Authenticator',
|
||||
secretCiphertext: 'encrypted-secret',
|
||||
});
|
||||
|
||||
assert.equal(method.isEnabled, false);
|
||||
assert.equal(
|
||||
(
|
||||
await mfaMethodRepository.findActiveByIdAndUserId(method.id, user.id)
|
||||
).secretCiphertext,
|
||||
'encrypted-secret',
|
||||
);
|
||||
|
||||
const enabledMethod = await mfaMethodRepository.enable(
|
||||
method.id,
|
||||
new Date('2026-09-07T00:00:00.000Z'),
|
||||
);
|
||||
const disabledMethod = await mfaMethodRepository.disable(
|
||||
method.id,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
);
|
||||
|
||||
assert.equal(enabledMethod.isEnabled, true);
|
||||
assert.equal(disabledMethod.isEnabled, false);
|
||||
assert.equal(
|
||||
await mfaMethodRepository.findActiveByIdAndUserId(method.id, user.id),
|
||||
null,
|
||||
);
|
||||
assert.equal((await database.db.select().from(mfaMethods)).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after, beforeEach } from 'node:test';
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { createDatabase } from '../../src/infrastructure/database/client.js';
|
||||
import {
|
||||
categories,
|
||||
conditionProfiles,
|
||||
conditions,
|
||||
inventoryItems,
|
||||
listings,
|
||||
products,
|
||||
sellers,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.js';
|
||||
import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js';
|
||||
import { createListingRepository } from '../../src/modules/listings/repositories/listing-repository.js';
|
||||
|
||||
const connectionString = process.env.TEST_DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error('TEST_DATABASE_URL is required to run integration tests.');
|
||||
}
|
||||
|
||||
if (!new URL(connectionString).pathname.endsWith('_test')) {
|
||||
throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".');
|
||||
}
|
||||
|
||||
const database = createDatabase(connectionString);
|
||||
const listingRepository = createListingRepository(database.db);
|
||||
const inventoryImageRepository = createInventoryImageRepository(database.db);
|
||||
|
||||
async function createInventoryFixture() {
|
||||
const [user] = await database.db
|
||||
.insert(users)
|
||||
.values({ email: 'listing-admin@example.test', passwordHash: 'hashed-password' })
|
||||
.returning();
|
||||
const [seller] = await database.db
|
||||
.insert(sellers)
|
||||
.values({ userId: user.id, displayName: 'Listing Owner' })
|
||||
.returning();
|
||||
const [category] = await database.db
|
||||
.insert(categories)
|
||||
.values({ name: 'Trading Cards', slug: 'trading-cards' })
|
||||
.returning();
|
||||
const [conditionProfile] = await database.db
|
||||
.insert(conditionProfiles)
|
||||
.values({ categoryId: category.id, name: 'Card' })
|
||||
.returning();
|
||||
const [condition] = await database.db
|
||||
.insert(conditions)
|
||||
.values({
|
||||
conditionProfileId: conditionProfile.id,
|
||||
name: 'Near Mint',
|
||||
code: 'NM',
|
||||
qualityRank: 1,
|
||||
})
|
||||
.returning();
|
||||
const [product] = await database.db
|
||||
.insert(products)
|
||||
.values({
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: 'Charizard ex',
|
||||
slug: 'charizard-ex-sv151-199',
|
||||
})
|
||||
.returning();
|
||||
const [inventoryItem] = await database.db
|
||||
.insert(inventoryItems)
|
||||
.values({
|
||||
productId: product.id,
|
||||
conditionId: condition.id,
|
||||
notes: 'Original box included',
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { seller, category, condition, product, inventoryItem };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await database.db.execute(
|
||||
sql`TRUNCATE listings, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`,
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
test('persists a draft listing for a physical inventory item', async () => {
|
||||
const { category, condition, inventoryItem } = await createInventoryFixture();
|
||||
|
||||
const listing = await listingRepository.create({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
productName: 'Charizard ex',
|
||||
categoryId: category.id,
|
||||
conditionId: condition.id,
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
notes: 'Near mint',
|
||||
});
|
||||
|
||||
assert.equal(listing.inventoryItemId, inventoryItem.id);
|
||||
assert.equal(listing.status, 'DRAFT');
|
||||
assert.equal(listing.askingPriceMinor, 2499);
|
||||
assert.ok(listing.id);
|
||||
});
|
||||
|
||||
test('lists the most recent draft entries for an inventory item', async () => {
|
||||
const { category, condition, inventoryItem } = await createInventoryFixture();
|
||||
|
||||
await listingRepository.create({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
productName: 'Charizard ex',
|
||||
categoryId: category.id,
|
||||
conditionId: condition.id,
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
|
||||
const listingsForItem = await listingRepository.listByInventoryItemId(inventoryItem.id);
|
||||
|
||||
assert.equal(listingsForItem.length, 1);
|
||||
assert.equal(listingsForItem[0].inventoryItemId, inventoryItem.id);
|
||||
});
|
||||
|
||||
test('persists image metadata only for the owning seller and counts item images', async () => {
|
||||
const { seller, inventoryItem } = await createInventoryFixture();
|
||||
const image = await inventoryImageRepository.createForSeller({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
storageKey: 'inventory/front.jpg',
|
||||
originalFilename: 'front.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: 12000,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
altText: 'Front image',
|
||||
sortOrder: 0,
|
||||
isPrimary: true,
|
||||
}, seller.id);
|
||||
|
||||
assert.equal(image.processingStatus, 'PENDING');
|
||||
assert.equal(await inventoryImageRepository.countByInventoryItemId(inventoryItem.id), 1);
|
||||
assert.equal(
|
||||
await inventoryImageRepository.createForSeller({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
storageKey: 'inventory/other.jpg',
|
||||
originalFilename: 'other.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: 12000,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
}, '00000000-0000-0000-0000-000000000000'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('reads and updates listings only through the owning seller scope', async () => {
|
||||
const { seller, category, condition, inventoryItem } = await createInventoryFixture();
|
||||
|
||||
const createdListing = await listingRepository.create({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
productName: 'Charizard ex',
|
||||
categoryId: category.id,
|
||||
conditionId: condition.id,
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
|
||||
const ownedListing = await listingRepository.findByIdForSeller(createdListing.id, seller.id);
|
||||
const sellerListings = await listingRepository.listBySellerId(seller.id);
|
||||
|
||||
assert.equal(ownedListing.id, createdListing.id);
|
||||
assert.equal(ownedListing.inventoryStatus, 'ACTIVE');
|
||||
assert.equal(sellerListings.length, 1);
|
||||
assert.equal(
|
||||
await listingRepository.findByIdForSeller(
|
||||
createdListing.id,
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED');
|
||||
assert.equal(archivedListing.status, 'ARCHIVED');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRedisConnection } from '../../src/infrastructure/redis/client.js';
|
||||
import { createRedisLoginRateLimiter } from '../../src/modules/auth/services/login-rate-limiter.js';
|
||||
|
||||
const redisUrl = process.env.TEST_REDIS_URL;
|
||||
|
||||
if (!redisUrl) {
|
||||
throw new Error('TEST_REDIS_URL is required to run integration tests.');
|
||||
}
|
||||
|
||||
test('shares login attempts across limiter instances', async () => {
|
||||
const redis = await createRedisConnection(redisUrl);
|
||||
const keyPrefix = `pokemon-inventory:test:auth:${randomUUID()}:`;
|
||||
|
||||
try {
|
||||
const firstLimiter = createRedisLoginRateLimiter({
|
||||
keyPrefix,
|
||||
maxAttempts: 2,
|
||||
redisClient: redis.client,
|
||||
});
|
||||
const secondLimiter = createRedisLoginRateLimiter({
|
||||
keyPrefix,
|
||||
maxAttempts: 2,
|
||||
redisClient: redis.client,
|
||||
});
|
||||
|
||||
assert.deepEqual(await firstLimiter.consume('198.51.100.8'), { allowed: true });
|
||||
assert.deepEqual(await secondLimiter.consume('198.51.100.8'), { allowed: true });
|
||||
|
||||
const blockedAttempt = await firstLimiter.consume('198.51.100.8');
|
||||
|
||||
assert.equal(blockedAttempt.allowed, false);
|
||||
assert.ok(blockedAttempt.retryAfterSeconds > 0);
|
||||
assert.deepEqual(await secondLimiter.consume('198.51.100.9'), { allowed: true });
|
||||
} finally {
|
||||
await redis.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user