import { describe, it, expect, beforeEach } from 'vitest'; import { createTestDb, type TestDb } from '../helpers/db'; import { apiTokens, users } from '@/src/lib/db/schema'; import { createHash } from 'node:crypto'; import { eq } from 'drizzle-orm'; let db: TestDb; beforeEach(() => { db = createTestDb(); }); function nowIso() { return new Date().toISOString(); } function hashToken(rawToken: string): string { return createHash('sha256').update(rawToken).digest('hex'); } async function insertUser(overrides: Partial = {}) { const now = nowIso(); const [user] = await db.insert(users).values({ email: 'admin@localhost', name: 'Admin', passwordHash: 'hash123', role: 'admin', provider: 'credentials', subject: 'admin@localhost', status: 'active', createdAt: now, updatedAt: now, ...overrides, }).returning(); return user; } async function insertApiToken(createdBy: number, overrides: Partial = {}) { const now = nowIso(); const rawToken = 'test-token-' + Math.random().toString(36).slice(2); const tokenHash = hashToken(rawToken); const [token] = await db.insert(apiTokens).values({ name: 'Test Token', tokenHash, createdBy, createdAt: now, ...overrides, }).returning(); return { token, rawToken }; } describe('api-tokens integration', () => { it('inserts an api token and retrieves it by hash', async () => { const user = await insertUser(); const { token, rawToken } = await insertApiToken(user.id); const hash = hashToken(rawToken); const row = await db.query.apiTokens.findFirst({ where: (t, { eq }) => eq(t.tokenHash, hash), }); expect(row).toBeDefined(); expect(row!.id).toBe(token.id); expect(row!.name).toBe('Test Token'); expect(row!.createdBy).toBe(user.id); }); it('stored hash matches SHA-256 of raw token', async () => { const user = await insertUser(); const { token, rawToken } = await insertApiToken(user.id); const expectedHash = hashToken(rawToken); expect(token.tokenHash).toBe(expectedHash); }); it('different raw tokens produce different hashes', async () => { const user = await insertUser(); const t1 = await insertApiToken(user.id, { name: 'Token 1' }); const t2 = await insertApiToken(user.id, { name: 'Token 2' }); expect(t1.token.tokenHash).not.toBe(t2.token.tokenHash); }); it('token lookup fails for wrong hash', async () => { const user = await insertUser(); await insertApiToken(user.id); const wrongHash = hashToken('wrong-token'); const row = await db.query.apiTokens.findFirst({ where: (t, { eq }) => eq(t.tokenHash, wrongHash), }); expect(row).toBeUndefined(); }); it('expired token is detectable', async () => { const user = await insertUser(); const pastDate = new Date(Date.now() - 86400000).toISOString(); // 1 day ago const { token } = await insertApiToken(user.id, { expiresAt: pastDate }); const row = await db.query.apiTokens.findFirst({ where: (t, { eq }) => eq(t.id, token.id), }); expect(row).toBeDefined(); expect(new Date(row!.expiresAt!).getTime()).toBeLessThan(Date.now()); }); it('non-expired token has future expiry', async () => { const user = await insertUser(); const futureDate = new Date(Date.now() + 86400000).toISOString(); // 1 day from now const { token } = await insertApiToken(user.id, { expiresAt: futureDate }); const row = await db.query.apiTokens.findFirst({ where: (t, { eq }) => eq(t.id, token.id), }); expect(row).toBeDefined(); expect(new Date(row!.expiresAt!).getTime()).toBeGreaterThan(Date.now()); }); it('deleting a token removes it from the database', async () => { const user = await insertUser(); const { token } = await insertApiToken(user.id); await db.delete(apiTokens).where(eq(apiTokens.id, token.id)); const row = await db.query.apiTokens.findFirst({ where: (t, { eq }) => eq(t.id, token.id), }); expect(row).toBeUndefined(); }); it('cascade deletes tokens when user is deleted', async () => { const user = await insertUser(); const { token } = await insertApiToken(user.id); await db.delete(users).where(eq(users.id, user.id)); const row = await db.query.apiTokens.findFirst({ where: (t, { eq }) => eq(t.id, token.id), }); expect(row).toBeUndefined(); }); it('lastUsedAt is initially null', async () => { const user = await insertUser(); const { token } = await insertApiToken(user.id); expect(token.lastUsedAt).toBeNull(); }); it('lastUsedAt can be updated', async () => { const user = await insertUser(); const { token } = await insertApiToken(user.id); const now = nowIso(); await db.update(apiTokens).set({ lastUsedAt: now }).where(eq(apiTokens.id, token.id)); const row = await db.query.apiTokens.findFirst({ where: (t, { eq }) => eq(t.id, token.id), }); expect(row!.lastUsedAt).toBe(now); }); it('unique index prevents duplicate token hashes', async () => { const user = await insertUser(); const { token } = await insertApiToken(user.id); await expect( db.insert(apiTokens).values({ name: 'Duplicate', tokenHash: token.tokenHash, createdBy: user.id, createdAt: nowIso(), }) ).rejects.toThrow(); }); it('lists tokens for a specific user', async () => { const user1 = await insertUser({ email: 'u1@localhost', subject: 'u1@localhost' }); const user2 = await insertUser({ email: 'u2@localhost', subject: 'u2@localhost' }); await insertApiToken(user1.id, { name: 'User1 Token' }); await insertApiToken(user2.id, { name: 'User2 Token' }); const user1Tokens = await db.query.apiTokens.findMany({ where: (t, { eq }) => eq(t.createdBy, user1.id), }); expect(user1Tokens).toHaveLength(1); expect(user1Tokens[0].name).toBe('User1 Token'); }); it('token created by user A still exists after user B deletes own tokens', async () => { const userA = await insertUser({ email: 'a@localhost', subject: 'a@localhost' }); const userB = await insertUser({ email: 'b@localhost', subject: 'b@localhost', role: 'user' }); const { token: tokenA } = await insertApiToken(userA.id, { name: 'A Token' }); await insertApiToken(userB.id, { name: 'B Token' }); // User B deletes only their own tokens await db.delete(apiTokens).where(eq(apiTokens.createdBy, userB.id)); const remainingTokens = await db.query.apiTokens.findMany(); expect(remainingTokens).toHaveLength(1); expect(remainingTokens[0].id).toBe(tokenA.id); }); it('admin can see all tokens regardless of creator', async () => { const admin = await insertUser({ email: 'admin2@localhost', subject: 'admin2@localhost', role: 'admin' }); const user1 = await insertUser({ email: 'u3@localhost', subject: 'u3@localhost', role: 'user' }); const user2 = await insertUser({ email: 'u4@localhost', subject: 'u4@localhost', role: 'user' }); await insertApiToken(user1.id, { name: 'User1 Token' }); await insertApiToken(user2.id, { name: 'User2 Token' }); await insertApiToken(admin.id, { name: 'Admin Token' }); const allTokens = await db.query.apiTokens.findMany(); expect(allTokens).toHaveLength(3); const creators = allTokens.map(t => t.createdBy); expect(creators).toContain(user1.id); expect(creators).toContain(user2.id); expect(creators).toContain(admin.id); }); it('inactive user token is discoverable but user status is inactive', async () => { const inactiveUser = await insertUser({ email: 'inactive@localhost', subject: 'inactive@localhost', status: 'inactive', }); const { token } = await insertApiToken(inactiveUser.id, { name: 'Inactive Token' }); const row = await db.query.apiTokens.findFirst({ where: (t, { eq }) => eq(t.id, token.id), }); expect(row).toBeDefined(); expect(row!.createdBy).toBe(inactiveUser.id); const user = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.id, inactiveUser.id), }); expect(user!.status).toBe('inactive'); }); });