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,25 @@
|
||||
{
|
||||
"name": "@pokemon-inventory/api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"provision:admin": "node src/modules/auth/scripts/provision-initial-admin.js",
|
||||
"start": "node src/app/server.js",
|
||||
"test:application": "node --test tests/application/*.test.js",
|
||||
"test:domain": "node --test tests/domain/*.test.js",
|
||||
"test:http": "node --test tests/http/*.test.js",
|
||||
"test:integration": "node --test --test-concurrency=1 tests/integration/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@pokemon-inventory/contracts": "*",
|
||||
"@pokemon-inventory/validation": "*",
|
||||
"dotenv": "^16.6.1",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"fastify": "^5.6.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"otpauth": "^9.4.1",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
|
||||
import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js';
|
||||
import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js';
|
||||
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js';
|
||||
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js';
|
||||
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js';
|
||||
|
||||
export function buildApp(dependencies) {
|
||||
const app = Fastify();
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
const message = statusCode >= 500 ? 'Internal server error.' : error.message;
|
||||
|
||||
if (statusCode >= 500) {
|
||||
request.log.error(error);
|
||||
}
|
||||
|
||||
return reply.code(statusCode).send({
|
||||
error: {
|
||||
code: error.code ?? 'INTERNAL_SERVER_ERROR',
|
||||
message,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ data: { status: 'ok' } }));
|
||||
app.register(cookie);
|
||||
app.register(registerAuthRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/auth',
|
||||
});
|
||||
app.register(registerAdminProductRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
app.register(registerAdminInventoryRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
app.register(registerAdminImageRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
app.register(registerAdminListingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { buildApp } from './build-app.js';
|
||||
import { createDatabase } from '../infrastructure/database/client.js';
|
||||
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js';
|
||||
import { createRedisConnection } from '../infrastructure/redis/client.js';
|
||||
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js';
|
||||
import { createMfaService } from '../modules/auth/services/mfa-service.js';
|
||||
import { createSecretProtector } from '../modules/auth/domain/secret-protection.js';
|
||||
import { createMfaMethodRepository } from '../modules/auth/repositories/mfa-method-repository.js';
|
||||
import { createSecurityAuditRepository } from '../modules/auth/repositories/security-audit-repository.js';
|
||||
import { createSessionRepository } from '../modules/auth/repositories/session-repository.js';
|
||||
import { createUserRepository } from '../modules/auth/repositories/user-repository.js';
|
||||
import { createPasswordResetRepository } from '../modules/auth/repositories/password-reset-repository.js';
|
||||
import { createRedisLoginRateLimiter } from '../modules/auth/services/login-rate-limiter.js';
|
||||
import { createPasswordRecoveryService } from '../modules/auth/services/password-recovery-service.js';
|
||||
import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js';
|
||||
import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js';
|
||||
import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js';
|
||||
import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js';
|
||||
import { createConditionRepository } from '../modules/inventory/repositories/condition-repository.js';
|
||||
import { createListingRepository } from '../modules/listings/repositories/listing-repository.js';
|
||||
import { createListingService } from '../modules/listings/services/listing-service.js';
|
||||
import { createProductRepository } from '../modules/products/repositories/product-repository.js';
|
||||
import { createProductService } from '../modules/products/services/product-service.js';
|
||||
|
||||
function parsePort(value) {
|
||||
const port = Number.parseInt(value ?? '3000', 10);
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('API_PORT must be an integer between 1 and 65535.');
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const database = createDatabase();
|
||||
const redis = await createRedisConnection();
|
||||
const productRepository = createProductRepository(database.db);
|
||||
const inventoryItemRepository = createInventoryItemRepository(database.db);
|
||||
const inventoryImageRepository = createInventoryImageRepository(database.db);
|
||||
const listingRepository = createListingRepository(database.db);
|
||||
const conditionRepository = createConditionRepository(database.db);
|
||||
const userRepository = createUserRepository(database.db);
|
||||
const sessionRepository = createSessionRepository(database.db);
|
||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||
const mfaMethodRepository = createMfaMethodRepository(database.db);
|
||||
const securityAuditRepository = createSecurityAuditRepository(database.db);
|
||||
const passwordResetNotifier = createPasswordResetNotifier();
|
||||
const authenticationService = createAuthenticationService({
|
||||
mfaService,
|
||||
sessionRepository,
|
||||
securityAuditRepository,
|
||||
userRepository,
|
||||
});
|
||||
const app = buildApp({
|
||||
authenticationService,
|
||||
authenticateAdmin: (request) =>
|
||||
authenticationService.authenticate(request.cookies.pokemon_inventory_session),
|
||||
authorizeUnsafeRequest: (sessionToken, csrfToken) =>
|
||||
authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken),
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
loginRateLimiter: createRedisLoginRateLimiter({ redisClient: redis.client }),
|
||||
mfaService,
|
||||
passwordRecoveryService: createPasswordRecoveryService({
|
||||
passwordResetNotifier,
|
||||
passwordResetRepository,
|
||||
securityAuditRepository,
|
||||
userRepository,
|
||||
}),
|
||||
passwordResetRateLimiter: createRedisLoginRateLimiter({
|
||||
keyPrefix: 'pokemon-inventory:auth:password-reset:',
|
||||
redisClient: redis.client,
|
||||
}),
|
||||
inventoryItemService: createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
}),
|
||||
inventoryImageService: createInventoryImageService({ inventoryImageRepository }),
|
||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||
productService: createProductService({ productRepository }),
|
||||
});
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await database.close();
|
||||
await redis.close();
|
||||
});
|
||||
|
||||
await app.listen({ host: '0.0.0.0', port: parsePort(process.env.API_PORT) });
|
||||
}
|
||||
|
||||
startServer().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
function requiredConnectionString(connectionString) {
|
||||
if (typeof connectionString !== 'string' || connectionString.length === 0) {
|
||||
throw new Error('DATABASE_URL is required.');
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
export function createDatabase(connectionString = process.env.DATABASE_URL) {
|
||||
const pool = new Pool({ connectionString: requiredConnectionString(connectionString) });
|
||||
|
||||
return {
|
||||
db: drizzle({ client: pool }),
|
||||
close: () => pool.end(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const userRole = pgEnum('user_role', ['ADMIN']);
|
||||
export const inventoryStatus = pgEnum('inventory_status', [
|
||||
'ACTIVE',
|
||||
'SOLD',
|
||||
'ARCHIVED',
|
||||
]);
|
||||
export const listingStatus = pgEnum('listing_status', [
|
||||
'DRAFT',
|
||||
'AVAILABLE',
|
||||
'PENDING',
|
||||
'CLOSED',
|
||||
'ARCHIVED',
|
||||
]);
|
||||
|
||||
const timestamps = {
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
};
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
email: text('email').notNull(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
role: userRole('role').default('ADMIN').notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex('users_email_unique').on(table.email)],
|
||||
);
|
||||
|
||||
export const sellers = pgTable(
|
||||
'sellers',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
displayName: text('display_name').notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex('sellers_user_id_unique').on(table.userId)],
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
sessionTokenHash: text('session_token_hash').notNull(),
|
||||
csrfTokenHash: text('csrf_token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('sessions_token_hash_unique').on(table.sessionTokenHash),
|
||||
index('sessions_user_id_index').on(table.userId),
|
||||
index('sessions_expires_at_index').on(table.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const securityAuditEvents = pgTable(
|
||||
'security_audit_events',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id),
|
||||
event: text('event').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
metadata: jsonb('metadata').default({}).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('security_audit_events_user_id_index').on(table.userId),
|
||||
index('security_audit_events_event_created_at_index').on(
|
||||
table.event,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const passwordResetTokens = pgTable(
|
||||
'password_reset_tokens',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('password_reset_tokens_token_hash_unique').on(table.tokenHash),
|
||||
index('password_reset_tokens_user_id_index').on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const mfaMethods = pgTable(
|
||||
'mfa_methods',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
type: text('type').notNull(),
|
||||
secretCiphertext: text('secret_ciphertext').notNull(),
|
||||
name: text('name').notNull(),
|
||||
isEnabled: boolean('is_enabled').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => [index('mfa_methods_user_id_index').on(table.userId)],
|
||||
);
|
||||
|
||||
export const categories = pgTable(
|
||||
'categories',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('categories_name_unique').on(table.name),
|
||||
uniqueIndex('categories_slug_unique').on(table.slug),
|
||||
],
|
||||
);
|
||||
|
||||
export const conditionProfiles = pgTable('condition_profiles', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
categoryId: uuid('category_id')
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
name: text('name').notNull(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const conditions = pgTable(
|
||||
'conditions',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
conditionProfileId: uuid('condition_profile_id')
|
||||
.notNull()
|
||||
.references(() => conditionProfiles.id),
|
||||
name: text('name').notNull(),
|
||||
code: text('code').notNull(),
|
||||
qualityRank: integer('quality_rank').notNull(),
|
||||
description: text('description'),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('conditions_profile_code_unique').on(
|
||||
table.conditionProfileId,
|
||||
table.code,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const products = pgTable(
|
||||
'products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
sellerId: uuid('seller_id')
|
||||
.notNull()
|
||||
.references(() => sellers.id),
|
||||
categoryId: uuid('category_id')
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
description: text('description'),
|
||||
brand: text('brand'),
|
||||
setName: text('set_name'),
|
||||
cardNumber: text('card_number'),
|
||||
eventName: text('event_name'),
|
||||
releaseYear: integer('release_year'),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('products_seller_slug_unique').on(table.sellerId, table.slug),
|
||||
check(
|
||||
'products_release_year_valid',
|
||||
sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const inventoryItems = pgTable('inventory_items', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id),
|
||||
conditionId: uuid('condition_id')
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
status: inventoryStatus('status').default('ACTIVE').notNull(),
|
||||
notes: text('notes'),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const inventoryImages = pgTable(
|
||||
'inventory_images',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
storageKey: text('storage_key').notNull(),
|
||||
originalFilename: text('original_filename').notNull(),
|
||||
mimeType: text('mime_type').notNull(),
|
||||
fileSize: integer('file_size').notNull(),
|
||||
width: integer('width').notNull(),
|
||||
height: integer('height').notNull(),
|
||||
altText: text('alt_text'),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
isPrimary: boolean('is_primary').default(false).notNull(),
|
||||
processingStatus: text('processing_status').default('PENDING').notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [index('inventory_images_inventory_item_id_index').on(table.inventoryItemId)],
|
||||
);
|
||||
|
||||
export const listings = pgTable('listings', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
productName: text('product_name').notNull(),
|
||||
categoryId: uuid('category_id').notNull(),
|
||||
conditionId: uuid('condition_id')
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
askingPriceMinor: integer('asking_price_minor').notNull(),
|
||||
imageCount: integer('image_count').notNull(),
|
||||
status: listingStatus('status').default('DRAFT').notNull(),
|
||||
notes: text('notes'),
|
||||
...timestamps,
|
||||
}, (table) => [
|
||||
index('listings_inventory_item_id_index').on(table.inventoryItemId),
|
||||
index('listings_status_index').on(table.status),
|
||||
]);
|
||||
@@ -0,0 +1,33 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
function requiredValue(value, name) {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createPasswordResetNotifier({
|
||||
from = process.env.SMTP_FROM,
|
||||
resetUrl = process.env.PASSWORD_RESET_URL,
|
||||
smtpUrl = process.env.SMTP_URL,
|
||||
transport = nodemailer.createTransport({ url: requiredValue(smtpUrl, 'SMTP_URL') }),
|
||||
} = {}) {
|
||||
const sender = requiredValue(from, 'SMTP_FROM');
|
||||
const resetPasswordUrl = requiredValue(resetUrl, 'PASSWORD_RESET_URL');
|
||||
|
||||
return {
|
||||
async send({ email, token }) {
|
||||
const url = new URL(resetPasswordUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
await transport.sendMail({
|
||||
from: sender,
|
||||
to: email,
|
||||
subject: 'Reset your Pokemon Inventory password',
|
||||
text: `Use this link to reset your password: ${url.toString()}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function createImageStorageContract(implementation) {
|
||||
for (const method of ['put', 'get', 'delete']) {
|
||||
if (typeof implementation?.[method] !== 'function') {
|
||||
throw new TypeError(`ImageStorage must implement ${method}().`);
|
||||
}
|
||||
}
|
||||
|
||||
return implementation;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createImageStorageContract } from './image-storage.js';
|
||||
|
||||
function safePath(rootDirectory, key) {
|
||||
if (typeof key !== 'string' || key.length === 0 || path.isAbsolute(key)) {
|
||||
throw new Error('Image storage key must be a relative non-empty path.');
|
||||
}
|
||||
|
||||
const root = path.resolve(rootDirectory);
|
||||
const target = path.resolve(root, key);
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error('Image storage key escapes the storage root.');
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
export function createLocalImageStorage(rootDirectory) {
|
||||
if (typeof rootDirectory !== 'string' || rootDirectory.length === 0) {
|
||||
throw new TypeError('Image storage root directory is required.');
|
||||
}
|
||||
|
||||
return createImageStorageContract({
|
||||
async put({ key, data }) {
|
||||
if (!Buffer.isBuffer(data)) {
|
||||
throw new TypeError('Image storage data must be a Buffer.');
|
||||
}
|
||||
|
||||
const target = safePath(rootDirectory, key);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, data, { flag: 'wx' });
|
||||
return { key };
|
||||
},
|
||||
|
||||
async get(key) {
|
||||
return readFile(safePath(rootDirectory, key));
|
||||
},
|
||||
|
||||
async delete(key) {
|
||||
await rm(safePath(rootDirectory, key), { force: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createClient } from 'redis';
|
||||
|
||||
function requiredRedisUrl(redisUrl) {
|
||||
if (typeof redisUrl !== 'string' || redisUrl.length === 0) {
|
||||
throw new Error('REDIS_URL is required.');
|
||||
}
|
||||
|
||||
return redisUrl;
|
||||
}
|
||||
|
||||
export async function createRedisConnection(redisUrl = process.env.REDIS_URL) {
|
||||
const client = createClient({ url: requiredRedisUrl(redisUrl) });
|
||||
client.on('error', (error) => console.error('Redis client error.', error));
|
||||
await client.connect();
|
||||
|
||||
return {
|
||||
client,
|
||||
close: () => client.close(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
const keyLength = 64;
|
||||
const scryptParameters = Object.freeze({ N: 16384, r: 8, p: 1 });
|
||||
|
||||
function requiredPassword(password) {
|
||||
if (typeof password !== 'string' || password.length === 0) {
|
||||
throw new Error('Password is required.');
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
export async function hashPassword(password) {
|
||||
const salt = randomBytes(16);
|
||||
const derivedKey = await scryptAsync(
|
||||
requiredPassword(password),
|
||||
salt,
|
||||
keyLength,
|
||||
scryptParameters,
|
||||
);
|
||||
|
||||
return [
|
||||
'scrypt',
|
||||
scryptParameters.N,
|
||||
scryptParameters.r,
|
||||
scryptParameters.p,
|
||||
salt.toString('base64url'),
|
||||
Buffer.from(derivedKey).toString('base64url'),
|
||||
].join('$');
|
||||
}
|
||||
|
||||
export async function verifyPassword(password, passwordHash) {
|
||||
if (typeof password !== 'string' || typeof passwordHash !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [algorithm, cost, blockSize, parallelization, salt, expectedKey] =
|
||||
passwordHash.split('$');
|
||||
|
||||
if (
|
||||
algorithm !== 'scrypt' ||
|
||||
cost !== String(scryptParameters.N) ||
|
||||
blockSize !== String(scryptParameters.r) ||
|
||||
parallelization !== String(scryptParameters.p) ||
|
||||
!salt ||
|
||||
!expectedKey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const derivedKey = Buffer.from(
|
||||
await scryptAsync(password, Buffer.from(salt, 'base64url'), keyLength, scryptParameters),
|
||||
);
|
||||
const expectedBuffer = Buffer.from(expectedKey, 'base64url');
|
||||
|
||||
return (
|
||||
derivedKey.length === expectedBuffer.length &&
|
||||
timingSafeEqual(derivedKey, expectedBuffer)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
function encryptionKey(value) {
|
||||
const key = Buffer.from(value ?? '', 'base64');
|
||||
|
||||
if (key.length !== 32) {
|
||||
throw new Error('MFA_ENCRYPTION_KEY must be a base64-encoded 32-byte key.');
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export function createSecretProtector(keyValue = process.env.MFA_ENCRYPTION_KEY) {
|
||||
const key = encryptionKey(keyValue);
|
||||
|
||||
return {
|
||||
encrypt(secret) {
|
||||
const initializationVector = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, initializationVector);
|
||||
const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
||||
|
||||
return [
|
||||
initializationVector.toString('base64url'),
|
||||
cipher.getAuthTag().toString('base64url'),
|
||||
ciphertext.toString('base64url'),
|
||||
].join('.');
|
||||
},
|
||||
|
||||
decrypt(encryptedSecret) {
|
||||
const [initializationVector, authTag, ciphertext] = encryptedSecret.split('.');
|
||||
|
||||
if (!initializationVector || !authTag || !ciphertext) {
|
||||
throw new Error('Encrypted MFA secret is invalid.');
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
key,
|
||||
Buffer.from(initializationVector, 'base64url'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(authTag, 'base64url'));
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertext, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
export function createSessionToken() {
|
||||
return createToken();
|
||||
}
|
||||
|
||||
export function createCsrfToken() {
|
||||
return createToken();
|
||||
}
|
||||
|
||||
export function createPasswordResetToken() {
|
||||
return createToken();
|
||||
}
|
||||
|
||||
function createToken() {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
|
||||
return { token, tokenHash: hashSessionToken(token) };
|
||||
}
|
||||
|
||||
export function hashSessionToken(token) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Secret, TOTP } from 'otpauth';
|
||||
|
||||
const issuer = 'Pokemon Inventory';
|
||||
|
||||
function createTotp(secret, label) {
|
||||
return new TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
issuer,
|
||||
label,
|
||||
period: 30,
|
||||
secret: Secret.fromBase32(secret),
|
||||
});
|
||||
}
|
||||
|
||||
export function createTotpEnrollment(label) {
|
||||
const secret = new Secret({ size: 20 }).base32;
|
||||
const totp = createTotp(secret, label);
|
||||
|
||||
return { secret, provisioningUri: totp.toString() };
|
||||
}
|
||||
|
||||
export function verifyTotp({ code, secret, timestamp }) {
|
||||
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return createTotp(secret, 'Pokemon Inventory').validate({
|
||||
token: code,
|
||||
window: 1,
|
||||
timestamp,
|
||||
}) !== null;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { mfaMethods } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createMfaMethodRepository(db) {
|
||||
return {
|
||||
async create(method) {
|
||||
const [createdMethod] = await db.insert(mfaMethods).values(method).returning();
|
||||
return createdMethod;
|
||||
},
|
||||
|
||||
async findActiveByIdAndUserId(id, userId) {
|
||||
const [method] = await db
|
||||
.select()
|
||||
.from(mfaMethods)
|
||||
.where(
|
||||
and(
|
||||
eq(mfaMethods.id, id),
|
||||
eq(mfaMethods.userId, userId),
|
||||
isNull(mfaMethods.disabledAt),
|
||||
),
|
||||
);
|
||||
|
||||
return method ?? null;
|
||||
},
|
||||
|
||||
async listEnabledByUserId(userId) {
|
||||
return db
|
||||
.select()
|
||||
.from(mfaMethods)
|
||||
.where(
|
||||
and(
|
||||
eq(mfaMethods.userId, userId),
|
||||
eq(mfaMethods.isEnabled, true),
|
||||
isNull(mfaMethods.disabledAt),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async markUsed(id, lastUsedAt) {
|
||||
await db
|
||||
.update(mfaMethods)
|
||||
.set({ lastUsedAt })
|
||||
.where(and(eq(mfaMethods.id, id), eq(mfaMethods.isEnabled, true)));
|
||||
},
|
||||
|
||||
async enable(id, lastUsedAt) {
|
||||
const [method] = await db
|
||||
.update(mfaMethods)
|
||||
.set({ isEnabled: true, lastUsedAt })
|
||||
.where(and(eq(mfaMethods.id, id), eq(mfaMethods.isEnabled, false)))
|
||||
.returning();
|
||||
|
||||
return method ?? null;
|
||||
},
|
||||
|
||||
async disable(id, disabledAt) {
|
||||
const [method] = await db
|
||||
.update(mfaMethods)
|
||||
.set({ isEnabled: false, disabledAt })
|
||||
.where(and(eq(mfaMethods.id, id), isNull(mfaMethods.disabledAt)))
|
||||
.returning();
|
||||
|
||||
return method ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
passwordResetTokens,
|
||||
sessions,
|
||||
users,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createPasswordResetRepository(db) {
|
||||
return {
|
||||
async issue({ userId, tokenHash, expiresAt }) {
|
||||
return db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(passwordResetTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(passwordResetTokens.userId, userId),
|
||||
isNull(passwordResetTokens.usedAt),
|
||||
),
|
||||
);
|
||||
const [resetToken] = await transaction
|
||||
.insert(passwordResetTokens)
|
||||
.values({ userId, tokenHash, expiresAt })
|
||||
.returning();
|
||||
|
||||
return resetToken;
|
||||
});
|
||||
},
|
||||
|
||||
async consume({ tokenHash, passwordHash, now }) {
|
||||
return db.transaction(async (transaction) => {
|
||||
const [resetToken] = await transaction
|
||||
.update(passwordResetTokens)
|
||||
.set({ usedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(passwordResetTokens.tokenHash, tokenHash),
|
||||
isNull(passwordResetTokens.usedAt),
|
||||
gt(passwordResetTokens.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.returning({ userId: passwordResetTokens.userId });
|
||||
|
||||
if (!resetToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await transaction
|
||||
.update(users)
|
||||
.set({ passwordHash, updatedAt: now })
|
||||
.where(eq(users.id, resetToken.userId));
|
||||
await transaction
|
||||
.update(sessions)
|
||||
.set({ revokedAt: now })
|
||||
.where(and(eq(sessions.userId, resetToken.userId), isNull(sessions.revokedAt)));
|
||||
|
||||
return resetToken.userId;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { securityAuditEvents } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createSecurityAuditRepository(db) {
|
||||
return {
|
||||
async record(event) {
|
||||
const [createdEvent] = await db
|
||||
.insert(securityAuditEvents)
|
||||
.values(event)
|
||||
.returning();
|
||||
|
||||
return createdEvent;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { and, desc, eq, gt, isNull, ne } from 'drizzle-orm';
|
||||
|
||||
import { sellers, sessions, users } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createSessionRepository(db) {
|
||||
return {
|
||||
async create(session) {
|
||||
const [createdSession] = await db.insert(sessions).values(session).returning();
|
||||
return createdSession;
|
||||
},
|
||||
|
||||
async findActiveAuthenticationSession(tokenHash, now) {
|
||||
const [session] = await db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
userId: users.id,
|
||||
role: users.role,
|
||||
sellerId: sellers.id,
|
||||
csrfTokenHash: sessions.csrfTokenHash,
|
||||
lastActivityAt: sessions.lastActivityAt,
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.id))
|
||||
.leftJoin(sellers, eq(sellers.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.sessionTokenHash, tokenHash),
|
||||
isNull(sessions.revokedAt),
|
||||
gt(sessions.expiresAt, now),
|
||||
),
|
||||
);
|
||||
|
||||
return session ?? null;
|
||||
},
|
||||
|
||||
async listActiveByUserId(userId, now) {
|
||||
return db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
createdAt: sessions.createdAt,
|
||||
expiresAt: sessions.expiresAt,
|
||||
lastActivityAt: sessions.lastActivityAt,
|
||||
ipAddress: sessions.ipAddress,
|
||||
userAgent: sessions.userAgent,
|
||||
})
|
||||
.from(sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, userId),
|
||||
isNull(sessions.revokedAt),
|
||||
gt(sessions.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sessions.lastActivityAt));
|
||||
},
|
||||
|
||||
async revokeByTokenHash(tokenHash, revokedAt) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt })
|
||||
.where(and(eq(sessions.sessionTokenHash, tokenHash), isNull(sessions.revokedAt)));
|
||||
},
|
||||
|
||||
async touchById(id, lastActivityAt) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ lastActivityAt })
|
||||
.where(and(eq(sessions.id, id), isNull(sessions.revokedAt)));
|
||||
},
|
||||
|
||||
async revokeByIdAndUserId(id, userId, revokedAt) {
|
||||
const revokedSessions = await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt })
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.id, id),
|
||||
eq(sessions.userId, userId),
|
||||
isNull(sessions.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
return revokedSessions.length === 1;
|
||||
},
|
||||
|
||||
async revokeOtherByUserId(userId, currentSessionId, revokedAt) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt })
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, userId),
|
||||
ne(sessions.id, currentSessionId),
|
||||
isNull(sessions.revokedAt),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { sellers, users } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createUserRepository(db) {
|
||||
return {
|
||||
async createInitialAdmin({ email, passwordHash, sellerName }) {
|
||||
return db.transaction(async (transaction) => {
|
||||
await transaction.execute(sql`SELECT pg_advisory_xact_lock(45873129)`);
|
||||
const [{ userCount }] = await transaction
|
||||
.select({ userCount: sql`count(*)` })
|
||||
.from(users);
|
||||
|
||||
if (Number(userCount) > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [user] = await transaction
|
||||
.insert(users)
|
||||
.values({ email, passwordHash, role: 'ADMIN' })
|
||||
.returning();
|
||||
const [seller] = await transaction
|
||||
.insert(sellers)
|
||||
.values({ userId: user.id, displayName: sellerName })
|
||||
.returning();
|
||||
|
||||
return { user, seller };
|
||||
});
|
||||
},
|
||||
|
||||
async findAuthenticationUserByEmail(email) {
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
passwordHash: users.passwordHash,
|
||||
role: users.role,
|
||||
sellerId: sellers.id,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(sellers, eq(sellers.userId, users.id))
|
||||
.where(sql`lower(${users.email}) = lower(${email})`);
|
||||
|
||||
return user ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
enrollMfaSchema,
|
||||
loginSchema,
|
||||
requestPasswordResetSchema,
|
||||
resetPasswordSchema,
|
||||
sessionIdSchema,
|
||||
verifyMfaSchema,
|
||||
} from '@pokemon-inventory/validation/auth';
|
||||
import { toMfaMethodDto, toSessionDto } from '@pokemon-inventory/contracts/auth';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sessionCookieOptions(expiresAt, isProduction) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
function csrfCookieOptions(expiresAt, isProduction) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app, options) {
|
||||
const {
|
||||
authenticationService,
|
||||
isProduction = false,
|
||||
loginRateLimiter,
|
||||
mfaService,
|
||||
passwordRecoveryService,
|
||||
passwordResetRateLimiter = loginRateLimiter,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
} = options;
|
||||
|
||||
app.post('/login', async (request, reply) => {
|
||||
const rateLimit = await loginRateLimiter.consume(request.ip);
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
reply.header('retry-after', String(rateLimit.retryAfterSeconds));
|
||||
return reply.code(429).send({
|
||||
error: {
|
||||
code: 'AUTH_RATE_LIMITED',
|
||||
message: 'Too many login attempts. Try again later.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = loginSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const session = await authenticationService.login({
|
||||
...result.data,
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
});
|
||||
|
||||
reply.setCookie(
|
||||
sessionCookieName,
|
||||
session.sessionToken,
|
||||
sessionCookieOptions(session.expiresAt, isProduction),
|
||||
);
|
||||
reply.setCookie(
|
||||
csrfCookieName,
|
||||
session.csrfToken,
|
||||
csrfCookieOptions(session.expiresAt, isProduction),
|
||||
);
|
||||
|
||||
return reply.code(200).send({ data: { expiresAt: session.expiresAt } });
|
||||
});
|
||||
|
||||
app.post('/forgot-password', async (request, reply) => {
|
||||
const rateLimit = await passwordResetRateLimiter.consume(request.ip);
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
reply.header('retry-after', String(rateLimit.retryAfterSeconds));
|
||||
return reply.code(429).send({
|
||||
error: {
|
||||
code: 'AUTH_RATE_LIMITED',
|
||||
message: 'Too many password reset requests. Try again later.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = requestPasswordResetSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
await passwordRecoveryService.requestReset({
|
||||
...result.data,
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
});
|
||||
|
||||
return reply.code(202).send({ data: null });
|
||||
});
|
||||
|
||||
app.post('/reset-password', async (request, reply) => {
|
||||
const result = resetPasswordSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
await passwordRecoveryService.resetPassword({
|
||||
...result.data,
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
|
||||
app.post('/mfa/enroll', async (request, reply) => {
|
||||
const result = enrollMfaSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const principal = await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const enrollment = await mfaService.enroll({ ...result.data, userId: principal.userId });
|
||||
|
||||
return reply.code(201).send({ data: enrollment });
|
||||
});
|
||||
|
||||
app.post('/mfa/verify', async (request, reply) => {
|
||||
const result = verifyMfaSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const principal = await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const method = await mfaService.verifyEnrollment({
|
||||
...result.data,
|
||||
userId: principal.userId,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ data: toMfaMethodDto(method) });
|
||||
});
|
||||
|
||||
app.post('/mfa/disable', async (request, reply) => {
|
||||
const result = verifyMfaSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const principal = await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
await mfaService.disable({ ...result.data, userId: principal.userId });
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
|
||||
app.post('/logout', async (request, reply) => {
|
||||
const principal = await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
await authenticationService.logout(
|
||||
request.cookies[sessionCookieName],
|
||||
principal.userId,
|
||||
);
|
||||
reply.clearCookie(sessionCookieName, { path: '/' });
|
||||
reply.clearCookie(csrfCookieName, { path: '/' });
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
|
||||
app.get('/me', async (request) => ({
|
||||
data: await authenticationService.authenticate(request.cookies[sessionCookieName]),
|
||||
}));
|
||||
|
||||
app.get('/sessions', async (request) => {
|
||||
const activeSessions = await authenticationService.listActiveSessions(
|
||||
request.cookies[sessionCookieName],
|
||||
);
|
||||
|
||||
return {
|
||||
data: activeSessions.sessions.map((session) =>
|
||||
toSessionDto(session, activeSessions.currentSessionId),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/sessions/:id', async (request, reply) => {
|
||||
const result = sessionIdSchema.safeParse(request.params.id);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const revokedSession = await authenticationService.revokeSession(
|
||||
request.cookies[sessionCookieName],
|
||||
result.data,
|
||||
);
|
||||
|
||||
if (revokedSession.currentSessionRevoked) {
|
||||
reply.clearCookie(sessionCookieName, { path: '/' });
|
||||
reply.clearCookie(csrfCookieName, { path: '/' });
|
||||
}
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
|
||||
app.delete('/sessions', async (request, reply) => {
|
||||
await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
await authenticationService.revokeOtherSessions(
|
||||
request.cookies[sessionCookieName],
|
||||
);
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { createDatabase } from '../../../infrastructure/database/client.js';
|
||||
import { createUserRepository } from '../repositories/user-repository.js';
|
||||
import { createInitialAdminService } from '../services/initial-admin-service.js';
|
||||
|
||||
function requiredEnvironmentVariable(name) {
|
||||
const value = process.env[name];
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required to provision the initial administrator.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
async function provisionInitialAdmin() {
|
||||
const database = createDatabase();
|
||||
|
||||
try {
|
||||
const initialAdminService = createInitialAdminService({
|
||||
userRepository: createUserRepository(database.db),
|
||||
});
|
||||
const administrator = await initialAdminService.provision({
|
||||
email: requiredEnvironmentVariable('INITIAL_ADMIN_EMAIL'),
|
||||
password: requiredEnvironmentVariable('INITIAL_ADMIN_PASSWORD'),
|
||||
sellerName: requiredEnvironmentVariable('INITIAL_SELLER_NAME'),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Initial administrator created for ${administrator.email} (user ${administrator.userId}, seller ${administrator.sellerId}).`,
|
||||
);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
provisionInitialAdmin().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { verifyPassword } from '../domain/password.js';
|
||||
import {
|
||||
createCsrfToken,
|
||||
createSessionToken,
|
||||
hashSessionToken,
|
||||
} from '../domain/session.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
const ADMIN_ROLE = 'ADMIN';
|
||||
const noOpSecurityAuditRepository = Object.freeze({ async record() {} });
|
||||
|
||||
function authenticationError() {
|
||||
return createApplicationError('UNAUTHORIZED', 'Invalid email or password.', 401);
|
||||
}
|
||||
|
||||
function sessionNotFoundError() {
|
||||
return createApplicationError('SESSION_NOT_FOUND', 'Session was not found.', 404);
|
||||
}
|
||||
|
||||
export function createAuthenticationService({
|
||||
now = () => new Date(),
|
||||
passwordVerifier = verifyPassword,
|
||||
rememberMeSessionDurationHours = 24 * 30,
|
||||
sessionActivityRenewalMinutes = 5,
|
||||
sessionDurationHours = 8,
|
||||
sessionRepository,
|
||||
securityAuditRepository = noOpSecurityAuditRepository,
|
||||
mfaService = { async verifyLogin() {} },
|
||||
userRepository,
|
||||
}) {
|
||||
return {
|
||||
async login({
|
||||
email,
|
||||
password,
|
||||
mfaCode,
|
||||
rememberMe = false,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
}) {
|
||||
const user = await userRepository.findAuthenticationUserByEmail(
|
||||
typeof email === 'string' ? email.trim() : '',
|
||||
);
|
||||
|
||||
if (!user || !(await passwordVerifier(password, user.passwordHash))) {
|
||||
await securityAuditRepository.record({
|
||||
event: 'LOGIN_FAILURE',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
throw authenticationError();
|
||||
}
|
||||
|
||||
if (user.role !== ADMIN_ROLE || !user.sellerId) {
|
||||
await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'LOGIN_FAILURE',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
throw authenticationError();
|
||||
}
|
||||
|
||||
await mfaService.verifyLogin({ userId: user.id, code: mfaCode });
|
||||
|
||||
const { token: sessionToken, tokenHash: sessionTokenHash } = createSessionToken();
|
||||
const { token: csrfToken, tokenHash: csrfTokenHash } = createCsrfToken();
|
||||
const issuedAt = now();
|
||||
const sessionHours = rememberMe
|
||||
? rememberMeSessionDurationHours
|
||||
: sessionDurationHours;
|
||||
const expiresAt = new Date(
|
||||
issuedAt.getTime() + sessionHours * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash,
|
||||
csrfTokenHash,
|
||||
expiresAt,
|
||||
lastActivityAt: issuedAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'LOGIN_SUCCESS',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'SESSION_CREATED',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionToken,
|
||||
csrfToken,
|
||||
expiresAt,
|
||||
principal: { userId: user.id, sellerId: user.sellerId, role: user.role },
|
||||
};
|
||||
},
|
||||
|
||||
async authenticate(sessionToken) {
|
||||
const session = await activeAdminSession(sessionToken, sessionRepository, now);
|
||||
await renewSessionActivity(
|
||||
session,
|
||||
sessionRepository,
|
||||
now,
|
||||
sessionActivityRenewalMinutes,
|
||||
);
|
||||
|
||||
return { userId: session.userId, sellerId: session.sellerId, role: session.role };
|
||||
},
|
||||
|
||||
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
||||
if (typeof sessionToken !== 'string' || typeof csrfToken !== 'string') {
|
||||
throw createApplicationError('FORBIDDEN', 'CSRF validation failed.', 403);
|
||||
}
|
||||
|
||||
const session = await sessionRepository.findActiveAuthenticationSession(
|
||||
hashSessionToken(sessionToken),
|
||||
now(),
|
||||
);
|
||||
|
||||
if (!session || session.csrfTokenHash !== hashSessionToken(csrfToken)) {
|
||||
throw createApplicationError('FORBIDDEN', 'CSRF validation failed.', 403);
|
||||
}
|
||||
|
||||
await renewSessionActivity(
|
||||
session,
|
||||
sessionRepository,
|
||||
now,
|
||||
sessionActivityRenewalMinutes,
|
||||
);
|
||||
|
||||
return { userId: session.userId, sellerId: session.sellerId, sessionId: session.id };
|
||||
},
|
||||
|
||||
async logout(sessionToken, userId = null) {
|
||||
if (typeof sessionToken !== 'string' || sessionToken.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionRepository.revokeByTokenHash(hashSessionToken(sessionToken), now());
|
||||
await securityAuditRepository.record({ userId, event: 'SESSION_REVOKED' });
|
||||
},
|
||||
|
||||
async listActiveSessions(sessionToken) {
|
||||
const session = await activeAdminSession(sessionToken, sessionRepository, now);
|
||||
const activeSessions = await sessionRepository.listActiveByUserId(
|
||||
session.userId,
|
||||
now(),
|
||||
);
|
||||
|
||||
return { currentSessionId: session.id, sessions: activeSessions };
|
||||
},
|
||||
|
||||
async revokeSession(sessionToken, sessionId) {
|
||||
const session = await activeAdminSession(sessionToken, sessionRepository, now);
|
||||
const revoked = await sessionRepository.revokeByIdAndUserId(
|
||||
sessionId,
|
||||
session.userId,
|
||||
now(),
|
||||
);
|
||||
|
||||
if (!revoked) {
|
||||
throw sessionNotFoundError();
|
||||
}
|
||||
|
||||
await securityAuditRepository.record({
|
||||
userId: session.userId,
|
||||
event: 'SESSION_REVOKED',
|
||||
metadata: { targetSessionId: sessionId },
|
||||
});
|
||||
|
||||
return { currentSessionRevoked: session.id === sessionId };
|
||||
},
|
||||
|
||||
async revokeOtherSessions(sessionToken) {
|
||||
const session = await activeAdminSession(sessionToken, sessionRepository, now);
|
||||
|
||||
await sessionRepository.revokeOtherByUserId(
|
||||
session.userId,
|
||||
session.id,
|
||||
now(),
|
||||
);
|
||||
await securityAuditRepository.record({
|
||||
userId: session.userId,
|
||||
event: 'SESSION_REVOKED',
|
||||
metadata: { scope: 'OTHER_SESSIONS' },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function activeAdminSession(sessionToken, sessionRepository, now) {
|
||||
if (typeof sessionToken !== 'string' || sessionToken.length === 0) {
|
||||
throw authenticationError();
|
||||
}
|
||||
|
||||
const session = await sessionRepository.findActiveAuthenticationSession(
|
||||
hashSessionToken(sessionToken),
|
||||
now(),
|
||||
);
|
||||
|
||||
if (!session || session.role !== ADMIN_ROLE || !session.sellerId) {
|
||||
throw authenticationError();
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function renewSessionActivity(
|
||||
session,
|
||||
sessionRepository,
|
||||
now,
|
||||
sessionActivityRenewalMinutes,
|
||||
) {
|
||||
if (!(session.lastActivityAt instanceof Date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = now();
|
||||
const renewalMilliseconds = sessionActivityRenewalMinutes * 60 * 1000;
|
||||
|
||||
if (currentTime.getTime() - session.lastActivityAt.getTime() < renewalMilliseconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionRepository.touchById?.(session.id, currentTime);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { hashPassword } from '../domain/password.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function requiredText(value, fieldName) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizedEmail(email) {
|
||||
const value = requiredText(email, 'Administrator email').toLowerCase();
|
||||
|
||||
if (!value.includes('@')) {
|
||||
throw new Error('Administrator email must be valid.');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createInitialAdminService({
|
||||
passwordHasher = hashPassword,
|
||||
userRepository,
|
||||
}) {
|
||||
return {
|
||||
async provision({ email, password, sellerName }) {
|
||||
const createdAdmin = await userRepository.createInitialAdmin({
|
||||
email: normalizedEmail(email),
|
||||
passwordHash: await passwordHasher(requiredText(password, 'Administrator password')),
|
||||
sellerName: requiredText(sellerName, 'Seller name'),
|
||||
});
|
||||
|
||||
if (!createdAdmin) {
|
||||
throw createApplicationError(
|
||||
'INITIAL_ADMIN_ALREADY_EXISTS',
|
||||
'An administrator already exists.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
userId: createdAdmin.user.id,
|
||||
sellerId: createdAdmin.seller.id,
|
||||
email: createdAdmin.user.email,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const defaultWindowMilliseconds = 15 * 60 * 1000;
|
||||
const consumeScript = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return { count, redis.call('PTTL', KEYS[1]) }
|
||||
`;
|
||||
|
||||
export function createRedisLoginRateLimiter({
|
||||
keyPrefix = 'pokemon-inventory:auth:login:',
|
||||
maxAttempts = 5,
|
||||
redisClient,
|
||||
windowMilliseconds = defaultWindowMilliseconds,
|
||||
} = {}) {
|
||||
if (!redisClient) {
|
||||
throw new Error('redisClient is required.');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
||||
throw new Error('maxAttempts must be a positive integer.');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(windowMilliseconds) || windowMilliseconds < 1) {
|
||||
throw new Error('windowMilliseconds must be a positive integer.');
|
||||
}
|
||||
|
||||
return {
|
||||
async consume(clientId) {
|
||||
const [count, remainingMilliseconds] = await redisClient.eval(consumeScript, {
|
||||
keys: [`${keyPrefix}${clientId}`],
|
||||
arguments: [String(windowMilliseconds)],
|
||||
});
|
||||
|
||||
if (Number(count) > maxAttempts) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterSeconds: Math.max(
|
||||
1,
|
||||
Math.ceil(Number(remainingMilliseconds) / 1000),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createTotpEnrollment, verifyTotp } from '../domain/totp.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
const noOpSecurityAuditRepository = Object.freeze({ async record() {} });
|
||||
|
||||
function mfaMethodNotFoundError() {
|
||||
return createApplicationError('MFA_METHOD_NOT_FOUND', 'MFA method was not found.', 404);
|
||||
}
|
||||
|
||||
function invalidMfaCodeError() {
|
||||
return createApplicationError('MFA_CODE_INVALID', 'MFA code is invalid.', 400);
|
||||
}
|
||||
|
||||
export function createMfaService({
|
||||
mfaMethodRepository,
|
||||
now = () => new Date(),
|
||||
secretProtector,
|
||||
securityAuditRepository = noOpSecurityAuditRepository,
|
||||
totpEnrollment = createTotpEnrollment,
|
||||
totpVerifier = verifyTotp,
|
||||
}) {
|
||||
return {
|
||||
async verifyLogin({ userId, code }) {
|
||||
const methods = await mfaMethodRepository.listEnabledByUserId(userId);
|
||||
|
||||
if (methods.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usedMethod = methods.find((method) =>
|
||||
totpVerifier({
|
||||
code,
|
||||
secret: secretProtector.decrypt(method.secretCiphertext),
|
||||
timestamp: now(),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!usedMethod) {
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_FAILURE' });
|
||||
throw invalidMfaCodeError();
|
||||
}
|
||||
|
||||
await mfaMethodRepository.markUsed(usedMethod.id, now());
|
||||
},
|
||||
|
||||
async enroll({ userId, name }) {
|
||||
const enrollment = totpEnrollment(name);
|
||||
const method = await mfaMethodRepository.create({
|
||||
userId,
|
||||
type: 'TOTP',
|
||||
name,
|
||||
secretCiphertext: secretProtector.encrypt(enrollment.secret),
|
||||
});
|
||||
|
||||
return { id: method.id, provisioningUri: enrollment.provisioningUri };
|
||||
},
|
||||
|
||||
async verifyEnrollment({ userId, methodId, code }) {
|
||||
const method = await mfaMethodRepository.findActiveByIdAndUserId(methodId, userId);
|
||||
|
||||
if (!method) {
|
||||
throw mfaMethodNotFoundError();
|
||||
}
|
||||
|
||||
if (!totpVerifier({ code, secret: secretProtector.decrypt(method.secretCiphertext), timestamp: now() })) {
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_FAILURE' });
|
||||
throw invalidMfaCodeError();
|
||||
}
|
||||
|
||||
const enabledMethod = await mfaMethodRepository.enable(method.id, now());
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_ENROLLED' });
|
||||
|
||||
return { id: enabledMethod.id, name: enabledMethod.name, type: enabledMethod.type };
|
||||
},
|
||||
|
||||
async disable({ userId, methodId, code }) {
|
||||
const method = await mfaMethodRepository.findActiveByIdAndUserId(methodId, userId);
|
||||
|
||||
if (!method) {
|
||||
throw mfaMethodNotFoundError();
|
||||
}
|
||||
|
||||
if (!method.isEnabled || !totpVerifier({ code, secret: secretProtector.decrypt(method.secretCiphertext), timestamp: now() })) {
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_FAILURE' });
|
||||
throw invalidMfaCodeError();
|
||||
}
|
||||
|
||||
const disabledMethod = await mfaMethodRepository.disable(method.id, now());
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_DISABLED' });
|
||||
|
||||
return { id: disabledMethod.id };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { hashPassword } from '../domain/password.js';
|
||||
import { createPasswordResetToken, hashSessionToken } from '../domain/session.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
const noOpNotifier = Object.freeze({ async send() {} });
|
||||
const noOpSecurityAuditRepository = Object.freeze({ async record() {} });
|
||||
const resetDurationMilliseconds = 60 * 60 * 1000;
|
||||
|
||||
export function createPasswordRecoveryService({
|
||||
now = () => new Date(),
|
||||
passwordHasher = hashPassword,
|
||||
passwordResetNotifier = noOpNotifier,
|
||||
passwordResetRepository,
|
||||
securityAuditRepository = noOpSecurityAuditRepository,
|
||||
userRepository,
|
||||
}) {
|
||||
return {
|
||||
async requestReset({ email, ipAddress = null, userAgent = null }) {
|
||||
const normalizedEmail = typeof email === 'string' ? email.trim().toLowerCase() : '';
|
||||
const user = await userRepository.findAuthenticationUserByEmail(normalizedEmail);
|
||||
|
||||
if (!user) {
|
||||
await securityAuditRepository.record({
|
||||
event: 'PASSWORD_RESET_REQUESTED',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { token, tokenHash } = createPasswordResetToken();
|
||||
const issuedAt = now();
|
||||
const expiresAt = new Date(issuedAt.getTime() + resetDurationMilliseconds);
|
||||
|
||||
await passwordResetRepository.issue({
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
});
|
||||
await passwordResetNotifier.send({ email: user.email, token, expiresAt });
|
||||
await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'PASSWORD_RESET_REQUESTED',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
},
|
||||
|
||||
async resetPassword({ token, password, ipAddress = null, userAgent = null }) {
|
||||
const resetAt = now();
|
||||
const userId = await passwordResetRepository.consume({
|
||||
tokenHash: hashSessionToken(token),
|
||||
passwordHash: await passwordHasher(password),
|
||||
now: resetAt,
|
||||
});
|
||||
|
||||
if (!userId) {
|
||||
throw createApplicationError('PASSWORD_RESET_TOKEN_INVALID', 'Reset token is invalid or expired.', 400);
|
||||
}
|
||||
|
||||
await securityAuditRepository.record({
|
||||
userId,
|
||||
event: 'PASSWORD_RESET',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export const INVENTORY_STATUS = Object.freeze({
|
||||
ACTIVE: 'ACTIVE',
|
||||
SOLD: 'SOLD',
|
||||
ARCHIVED: 'ARCHIVED',
|
||||
});
|
||||
|
||||
const permittedTransitions = Object.freeze({
|
||||
[INVENTORY_STATUS.ACTIVE]: new Set([
|
||||
INVENTORY_STATUS.SOLD,
|
||||
INVENTORY_STATUS.ARCHIVED,
|
||||
]),
|
||||
[INVENTORY_STATUS.SOLD]: new Set(),
|
||||
[INVENTORY_STATUS.ARCHIVED]: new Set(),
|
||||
});
|
||||
|
||||
export function transitionInventoryStatus(currentStatus, nextStatus) {
|
||||
if (!Object.values(INVENTORY_STATUS).includes(currentStatus)) {
|
||||
throw new Error('Unknown current inventory status.');
|
||||
}
|
||||
|
||||
if (!Object.values(INVENTORY_STATUS).includes(nextStatus)) {
|
||||
throw new Error('Unknown next inventory status.');
|
||||
}
|
||||
|
||||
if (!permittedTransitions[currentStatus].has(nextStatus)) {
|
||||
throw new Error(
|
||||
`Inventory status cannot transition from ${currentStatus} to ${nextStatus}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
export function assertInventoryItemEditable(status) {
|
||||
if (status === INVENTORY_STATUS.SOLD || status === INVENTORY_STATUS.ARCHIVED) {
|
||||
throw new Error('Inventory item is locked against ordinary edits.');
|
||||
}
|
||||
}
|
||||
|
||||
function requiredText(value, fieldName) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function createInventoryItem({ productId, conditionId, notes }) {
|
||||
return {
|
||||
productId: requiredText(productId, 'Product ID'),
|
||||
conditionId: requiredText(conditionId, 'Condition ID'),
|
||||
...(typeof notes === 'string' && notes.trim().length > 0
|
||||
? { notes: notes.trim() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
conditionProfiles,
|
||||
conditions,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createConditionRepository(db) {
|
||||
return {
|
||||
async findById(id) {
|
||||
const [condition] = await db
|
||||
.select({
|
||||
id: conditions.id,
|
||||
categoryId: conditionProfiles.categoryId,
|
||||
})
|
||||
.from(conditions)
|
||||
.innerJoin(
|
||||
conditionProfiles,
|
||||
eq(conditions.conditionProfileId, conditionProfiles.id),
|
||||
)
|
||||
.where(eq(conditions.id, id));
|
||||
|
||||
return condition ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
inventoryImages,
|
||||
inventoryItems,
|
||||
products,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createInventoryImageRepository(db) {
|
||||
return {
|
||||
async createForSeller(image, sellerId) {
|
||||
const [ownedItem] = await db
|
||||
.select({ id: inventoryItems.id })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, image.inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
|
||||
if (!ownedItem) return null;
|
||||
|
||||
const [createdImage] = await db.insert(inventoryImages).values(image).returning();
|
||||
return createdImage;
|
||||
},
|
||||
|
||||
async countByInventoryItemId(inventoryItemId) {
|
||||
const rows = await db
|
||||
.select({ id: inventoryImages.id })
|
||||
.from(inventoryImages)
|
||||
.where(eq(inventoryImages.inventoryItemId, inventoryItemId));
|
||||
|
||||
return rows.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
|
||||
import { inventoryItems } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createInventoryItemRepository(db) {
|
||||
return {
|
||||
async create(inventoryItem) {
|
||||
const [createdInventoryItem] = await db
|
||||
.insert(inventoryItems)
|
||||
.values(inventoryItem)
|
||||
.returning();
|
||||
|
||||
return createdInventoryItem;
|
||||
},
|
||||
|
||||
async listByProductId(productId) {
|
||||
return db
|
||||
.select()
|
||||
.from(inventoryItems)
|
||||
.where(eq(inventoryItems.productId, productId))
|
||||
.orderBy(asc(inventoryItems.createdAt));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
|
||||
import { createInventoryImageSchema } from '@pokemon-inventory/validation/image';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerAdminImageRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
inventoryImageService,
|
||||
} = options;
|
||||
|
||||
app.post('/inventory/:id/images', async (request, reply) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
|
||||
const result = createInventoryImageSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
|
||||
const image = await inventoryImageService.create({
|
||||
...result.data,
|
||||
inventoryItemId: request.params.id,
|
||||
sellerId: admin.sellerId,
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: toAdminInventoryImageDto(image) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory';
|
||||
import { createInventoryItemSchema } from '@pokemon-inventory/validation/inventory';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultAuthenticateAdmin() {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
export async function registerAdminInventoryRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = defaultAuthenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
inventoryItemService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.post('/inventory', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const result = createInventoryItemSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const inventoryItem = await inventoryItemService.create({
|
||||
...result.data,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: toAdminInventoryItemDto(inventoryItem) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
export function createInventoryImageService({ inventoryImageRepository }) {
|
||||
return {
|
||||
async create(input) {
|
||||
const image = await inventoryImageRepository.createForSeller(
|
||||
{ ...input, processingStatus: 'PENDING' },
|
||||
input.sellerId,
|
||||
);
|
||||
|
||||
if (!image) {
|
||||
throw createApplicationError('INVENTORY_ITEM_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
}
|
||||
|
||||
return image;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createInventoryItem } from '../domain/inventory-item.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
export function createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
}) {
|
||||
return {
|
||||
async create(input) {
|
||||
const inventoryItem = createInventoryItem(input);
|
||||
const [product, condition] = await Promise.all([
|
||||
productRepository.findById(inventoryItem.productId),
|
||||
conditionRepository.findById(inventoryItem.conditionId),
|
||||
]);
|
||||
|
||||
if (!product || product.sellerId !== input.sellerId) {
|
||||
throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.');
|
||||
}
|
||||
|
||||
if (!condition) {
|
||||
throw createApplicationError('CONDITION_NOT_FOUND', 'Condition was not found.');
|
||||
}
|
||||
|
||||
if (product.categoryId !== condition.categoryId) {
|
||||
throw createApplicationError(
|
||||
'CONDITION_CATEGORY_MISMATCH',
|
||||
'Condition does not belong to the product category.',
|
||||
);
|
||||
}
|
||||
|
||||
return inventoryItemRepository.create(inventoryItem);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
export const LISTING_STATUS = Object.freeze({
|
||||
DRAFT: 'DRAFT',
|
||||
AVAILABLE: 'AVAILABLE',
|
||||
PENDING: 'PENDING',
|
||||
CLOSED: 'CLOSED',
|
||||
ARCHIVED: 'ARCHIVED',
|
||||
});
|
||||
|
||||
const permittedTransitions = Object.freeze({
|
||||
[LISTING_STATUS.DRAFT]: new Set([
|
||||
LISTING_STATUS.AVAILABLE,
|
||||
LISTING_STATUS.PENDING,
|
||||
]),
|
||||
[LISTING_STATUS.AVAILABLE]: new Set([
|
||||
LISTING_STATUS.PENDING,
|
||||
LISTING_STATUS.CLOSED,
|
||||
LISTING_STATUS.ARCHIVED,
|
||||
]),
|
||||
[LISTING_STATUS.PENDING]: new Set([
|
||||
LISTING_STATUS.AVAILABLE,
|
||||
LISTING_STATUS.CLOSED,
|
||||
LISTING_STATUS.ARCHIVED,
|
||||
]),
|
||||
[LISTING_STATUS.CLOSED]: new Set([LISTING_STATUS.ARCHIVED]),
|
||||
[LISTING_STATUS.ARCHIVED]: new Set(),
|
||||
});
|
||||
|
||||
function requiredText(value, fieldName) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function createListing({
|
||||
inventoryItemId,
|
||||
productName,
|
||||
categoryId,
|
||||
conditionId,
|
||||
askingPriceMinor,
|
||||
imageCount,
|
||||
notes,
|
||||
}) {
|
||||
return {
|
||||
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
||||
productName: requiredText(productName, 'Product name'),
|
||||
categoryId: requiredText(categoryId, 'Category ID'),
|
||||
conditionId: requiredText(conditionId, 'Condition ID'),
|
||||
askingPriceMinor: requirePositiveMinorUnit(askingPriceMinor, 'Asking price'),
|
||||
imageCount: requirePositiveInteger(imageCount, 'Image count'),
|
||||
status: LISTING_STATUS.DRAFT,
|
||||
...(typeof notes === 'string' && notes.trim().length > 0
|
||||
? { notes: notes.trim() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function canPublishListing({
|
||||
productName,
|
||||
categoryId,
|
||||
conditionId,
|
||||
inventoryItemId,
|
||||
askingPriceMinor,
|
||||
imageCount,
|
||||
inventoryStatus,
|
||||
}) {
|
||||
const hasRequiredIdentity =
|
||||
typeof productName === 'string' && productName.trim().length > 0 &&
|
||||
typeof categoryId === 'string' && categoryId.trim().length > 0 &&
|
||||
typeof conditionId === 'string' && conditionId.trim().length > 0 &&
|
||||
typeof inventoryItemId === 'string' && inventoryItemId.trim().length > 0;
|
||||
|
||||
const hasPrice = Number.isInteger(askingPriceMinor) && askingPriceMinor > 0;
|
||||
const hasImages = Number.isInteger(imageCount) && imageCount > 0;
|
||||
const hasPublishableInventory = !['SOLD', 'ARCHIVED'].includes(inventoryStatus);
|
||||
|
||||
if (!hasRequiredIdentity || !hasPrice || !hasImages || !hasPublishableInventory) {
|
||||
if (!hasPublishableInventory) {
|
||||
throw createApplicationError(
|
||||
'LISTING_INVENTORY_UNAVAILABLE',
|
||||
'The physical inventory item cannot be published.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
throw createApplicationError(
|
||||
'LISTING_REQUIREMENTS_NOT_MET',
|
||||
'Listing requirements are not met.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function transitionListingStatus(currentStatus, nextStatus) {
|
||||
if (!Object.values(LISTING_STATUS).includes(currentStatus)) {
|
||||
throw new Error('Unknown current listing status.');
|
||||
}
|
||||
|
||||
if (!Object.values(LISTING_STATUS).includes(nextStatus)) {
|
||||
throw new Error('Unknown next listing status.');
|
||||
}
|
||||
|
||||
if (!permittedTransitions[currentStatus].has(nextStatus)) {
|
||||
throw new Error(
|
||||
`Listing status cannot transition from ${currentStatus} to ${nextStatus}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
function requirePositiveMinorUnit(value, fieldName) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${fieldName} must be a positive integer minor unit.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value, fieldName) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${fieldName} must be a positive integer.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { and, asc, desc, eq } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
inventoryItems,
|
||||
listings,
|
||||
products,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createListingRepository(db) {
|
||||
return {
|
||||
async create(listing) {
|
||||
const [createdListing] = await db.insert(listings).values(listing).returning();
|
||||
return createdListing;
|
||||
},
|
||||
|
||||
async listByInventoryItemId(inventoryItemId) {
|
||||
return db
|
||||
.select()
|
||||
.from(listings)
|
||||
.where(eq(listings.inventoryItemId, inventoryItemId))
|
||||
.orderBy(asc(listings.createdAt));
|
||||
},
|
||||
|
||||
async findByIdForSeller(listingId, sellerId) {
|
||||
const [result] = await db
|
||||
.select({ listing: listings, inventoryStatus: inventoryItems.status })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(listings.id, listingId), eq(products.sellerId, sellerId)));
|
||||
|
||||
return result ? { ...result.listing, inventoryStatus: result.inventoryStatus } : null;
|
||||
},
|
||||
|
||||
async listBySellerId(sellerId) {
|
||||
const results = await db
|
||||
.select({ listing: listings })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(desc(listings.createdAt));
|
||||
|
||||
return results.map(({ listing }) => listing);
|
||||
},
|
||||
|
||||
async updateStatus(listingId, status) {
|
||||
const [updatedListing] = await db
|
||||
.update(listings)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(eq(listings.id, listingId))
|
||||
.returning();
|
||||
|
||||
return updatedListing ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { toAdminListingDto } from '@pokemon-inventory/contracts/listing';
|
||||
import { createListingSchema } from '@pokemon-inventory/validation/listing';
|
||||
import { LISTING_STATUS } from '../domain/listing.js';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultAuthenticateAdmin() {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
export async function registerAdminListingRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = defaultAuthenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
listingService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.get('/listings', async (request) => {
|
||||
const listings = await listingService.list({ sellerId: request.admin.sellerId });
|
||||
return { data: listings.map(toAdminListingDto) };
|
||||
});
|
||||
|
||||
app.get('/listings/:id', async (request) => {
|
||||
const listing = await listingService.get({
|
||||
listingId: request.params.id,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
return { data: toAdminListingDto(listing) };
|
||||
});
|
||||
|
||||
app.post('/listings', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
|
||||
const result = createListingSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const listing = await listingService.create({
|
||||
...result.data,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: toAdminListingDto(listing) });
|
||||
});
|
||||
|
||||
async function transition(request, reply, nextStatus) {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
|
||||
const listing = await listingService.transition({
|
||||
listingId: request.params.id,
|
||||
sellerId: request.admin.sellerId,
|
||||
nextStatus,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ data: toAdminListingDto(listing) });
|
||||
}
|
||||
|
||||
app.post('/listings/:id/publish', async (request, reply) =>
|
||||
transition(request, reply, LISTING_STATUS.AVAILABLE));
|
||||
app.post('/listings/:id/unpublish', async (request, reply) =>
|
||||
transition(request, reply, LISTING_STATUS.CLOSED));
|
||||
app.post('/listings/:id/pending', async (request, reply) =>
|
||||
transition(request, reply, LISTING_STATUS.PENDING));
|
||||
app.post('/listings/:id/archive', async (request, reply) =>
|
||||
transition(request, reply, LISTING_STATUS.ARCHIVED));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
canPublishListing,
|
||||
createListing,
|
||||
transitionListingStatus,
|
||||
} from '../domain/listing.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
export function createListingService({ listingRepository, inventoryImageRepository }) {
|
||||
return {
|
||||
async create(input) {
|
||||
canPublishListing(input);
|
||||
const listing = createListing(input);
|
||||
return listingRepository.create(listing);
|
||||
},
|
||||
|
||||
async transition({ listingId, sellerId, nextStatus }) {
|
||||
const listing = await listingRepository.findByIdForSeller(listingId, sellerId);
|
||||
|
||||
if (!listing) {
|
||||
throw createApplicationError('LISTING_NOT_FOUND', 'Listing was not found.', 404);
|
||||
}
|
||||
|
||||
const status = transitionListingStatus(listing.status, nextStatus);
|
||||
|
||||
if (status === 'AVAILABLE') {
|
||||
const imageCount = inventoryImageRepository
|
||||
? await inventoryImageRepository.countByInventoryItemId(listing.inventoryItemId)
|
||||
: listing.imageCount;
|
||||
canPublishListing({ ...listing, imageCount });
|
||||
}
|
||||
|
||||
return listingRepository.updateStatus(listing.id, status);
|
||||
},
|
||||
|
||||
async get({ listingId, sellerId }) {
|
||||
const listing = await listingRepository.findByIdForSeller(listingId, sellerId);
|
||||
|
||||
if (!listing) {
|
||||
throw createApplicationError('LISTING_NOT_FOUND', 'Listing was not found.', 404);
|
||||
}
|
||||
|
||||
return listing;
|
||||
},
|
||||
|
||||
async list({ sellerId }) {
|
||||
return listingRepository.listBySellerId(sellerId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
function requiredText(value, fieldName) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function createProduct({ name, categoryId, sellerId }) {
|
||||
return {
|
||||
name: requiredText(name, 'Product name'),
|
||||
categoryId: requiredText(categoryId, 'Category ID'),
|
||||
sellerId: requiredText(sellerId, 'Seller ID'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createProductSlug(name) {
|
||||
const normalizedName = requiredText(name, 'Product name')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
if (normalizedName.length === 0) {
|
||||
throw new Error('Product name must include letters or numbers.');
|
||||
}
|
||||
|
||||
return normalizedName;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
|
||||
import { products } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createProductRepository(db) {
|
||||
return {
|
||||
async create(product) {
|
||||
const [createdProduct] = await db.insert(products).values(product).returning();
|
||||
return createdProduct;
|
||||
},
|
||||
|
||||
async findById(id) {
|
||||
const [product] = await db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(eq(products.id, id));
|
||||
|
||||
return product ?? null;
|
||||
},
|
||||
|
||||
async findBySellerIdAndSlug(sellerId, slug) {
|
||||
const [product] = await db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(eq(products.sellerId, sellerId), eq(products.slug, slug)));
|
||||
|
||||
return product ?? null;
|
||||
},
|
||||
|
||||
async listBySellerId(sellerId) {
|
||||
return db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(asc(products.name));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { toAdminProductDto } from '@pokemon-inventory/contracts/product';
|
||||
import { createProductSchema } from '@pokemon-inventory/validation/product';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultAuthenticateAdmin() {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
export async function registerAdminProductRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = defaultAuthenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
productService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.get('/products', async (request) => ({
|
||||
data: (await productService.listBySellerId(request.admin.sellerId)).map(
|
||||
toAdminProductDto,
|
||||
),
|
||||
}));
|
||||
|
||||
app.post('/products', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const result = createProductSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const product = await productService.create({
|
||||
...result.data,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: toAdminProductDto(product) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createProduct, createProductSlug } from '../domain/product.js';
|
||||
|
||||
export function createProductService({ productRepository }) {
|
||||
return {
|
||||
async create(input) {
|
||||
const product = createProduct(input);
|
||||
const baseSlug = createProductSlug(product.name);
|
||||
const slug = await createAvailableSlug(
|
||||
productRepository,
|
||||
product.sellerId,
|
||||
baseSlug,
|
||||
);
|
||||
|
||||
return productRepository.create({ ...product, slug });
|
||||
},
|
||||
|
||||
async listBySellerId(sellerId) {
|
||||
return productRepository.listBySellerId(sellerId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createAvailableSlug(productRepository, sellerId, baseSlug) {
|
||||
let suffix = 1;
|
||||
let slug = baseSlug;
|
||||
|
||||
while (await productRepository.findBySellerIdAndSlug(sellerId, slug)) {
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function createApplicationError(code, message, statusCode = 400) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -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