430 lines
13 KiB
JavaScript
430 lines
13 KiB
JavaScript
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,
|
|
mfaMethods,
|
|
passwordResetTokens,
|
|
products,
|
|
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';
|
|
import { createSellerRepository } from '../../src/modules/sellers/repositories/seller-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 sellerRepository = createSellerRepository(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('finds only products with active inventory and a public listing', async () => {
|
|
const { seller, category, condition } = await createCatalogPrerequisites();
|
|
const product = await productRepository.create({
|
|
sellerId: seller.id,
|
|
categoryId: category.id,
|
|
name: 'Public Pikachu Plush',
|
|
slug: 'public-pikachu-plush',
|
|
});
|
|
const inventoryItem = await inventoryItemRepository.create({
|
|
productId: product.id,
|
|
conditionId: condition.id,
|
|
});
|
|
|
|
await database.db.insert(listings).values({
|
|
inventoryItemId: inventoryItem.id,
|
|
productName: product.name,
|
|
categoryId: category.id,
|
|
conditionId: condition.id,
|
|
askingPriceMinor: 2499,
|
|
status: 'PENDING',
|
|
});
|
|
|
|
assert.equal(
|
|
(await productRepository.findPublicBySlug(product.slug))?.id,
|
|
product.id,
|
|
);
|
|
|
|
await database.db
|
|
.update(listings)
|
|
.set({ status: 'CLOSED' })
|
|
.where(sql`inventory_item_id = ${inventoryItem.id}`);
|
|
|
|
assert.equal(await productRepository.findPublicBySlug(product.slug), null);
|
|
});
|
|
|
|
test('lists only sellers with publicly visible inventory', async () => {
|
|
const { seller, category, condition } = await createCatalogPrerequisites();
|
|
const [product] = await database.db.insert(products).values({
|
|
sellerId: seller.id,
|
|
categoryId: category.id,
|
|
name: 'Public Seller Product',
|
|
slug: 'public-seller-product',
|
|
}).returning();
|
|
const [inventoryItem] = await database.db.insert(inventoryItems).values({
|
|
productId: product.id,
|
|
conditionId: condition.id,
|
|
}).returning();
|
|
await database.db.insert(listings).values({
|
|
inventoryItemId: inventoryItem.id,
|
|
productName: product.name,
|
|
categoryId: category.id,
|
|
conditionId: condition.id,
|
|
askingPriceMinor: 1999,
|
|
status: 'AVAILABLE',
|
|
});
|
|
|
|
assert.deepEqual((await sellerRepository.listPublic()).map(({ id }) => id), [seller.id]);
|
|
|
|
await database.db.update(listings).set({ status: 'CLOSED' });
|
|
assert.deepEqual(await sellerRepository.listPublic(), []);
|
|
});
|
|
|
|
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);
|
|
}); |