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:
2026-09-08 17:32:03 -04:00
commit c728016dec
128 changed files with 9788 additions and 0 deletions
+25
View File
@@ -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"
}
}
View File
+53
View File
@@ -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;
}
+97
View File
@@ -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(),
};
}
View File
@@ -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');
}
+33
View File
@@ -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;
}
View File
@@ -0,0 +1,6 @@
export function createApplicationError(code, message, statusCode = 400) {
const error = new Error(message);
error.code = code;
error.statusCode = statusCode;
return error;
}
View File
@@ -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));
});
+74
View File
@@ -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);
});
+29
View File
@@ -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');
});
+340
View File
@@ -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');
});
+200
View File
@@ -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');
});
+91
View File
@@ -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();
}
});
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File