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
@@ -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();
}
});