import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { ResultSetHeader } from 'mysql2'; // ── DB mock ──────────────────────────────────────────────────────────────── const { mockExecute } = vi.hoisted(() => ({ mockExecute: vi.fn() })); vi.mock('./db.js', () => ({ getPool: vi.fn(() => ({ execute: mockExecute })), isPoolReady: vi.fn(() => true), })); import { createAuthCode, exchangeCodeForToken, validateAccessToken, getTokenCustomer, isValidRedirectUri, } from './oauth.js'; // Helper: build a minimal ResultSetHeader-shaped object function affected(n: number): [ResultSetHeader, unknown[]] { return [{ affectedRows: n } as ResultSetHeader, []]; } // Helper: build a row-result function rows(data: object[]): [object[], unknown[]] { return [data, []]; } describe('isValidRedirectUri', () => { it('exact match is valid', () => { expect(isValidRedirectUri('https://app.example.com/callback', ['https://app.example.com/callback'])).toBe(true); }); it('http://localhost:* wildcard matches any localhost port', () => { expect(isValidRedirectUri('http://localhost:3000', ['http://localhost:*'])).toBe(true); expect(isValidRedirectUri('http://localhost:9876/callback', ['http://localhost:*'])).toBe(true); }); it('http://localhost:* does not match non-localhost', () => { expect(isValidRedirectUri('http://evil.com', ['http://localhost:*'])).toBe(false); expect(isValidRedirectUri('https://localhost:3000', ['http://localhost:*'])).toBe(false); }); it('unregistered URI is rejected', () => { expect(isValidRedirectUri('https://attacker.com', ['https://app.example.com/callback'])).toBe(false); }); it('other wildcards are not supported', () => { expect(isValidRedirectUri('https://any.example.com', ['https://*.example.com'])).toBe(false); }); it('empty registered list rejects everything', () => { expect(isValidRedirectUri('https://app.example.com/callback', [])).toBe(false); }); }); describe('createAuthCode', () => { beforeEach(() => vi.clearAllMocks()); it('inserts with customer_id when provided', async () => { mockExecute // getClient SELECT (called by nothing here; we call createAuthCode directly) .mockResolvedValueOnce([[], []]); // INSERT returns ResultSetHeader but we ignore it // The first execute is the INSERT mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]); const code = await createAuthCode('client1', 'http://localhost:3000', 'read', undefined, undefined, 'cust-42'); expect(code.customer_id).toBe('cust-42'); const [sql, params] = mockExecute.mock.calls[0] as [string, unknown[]]; expect(sql).toContain('customer_id'); expect(params).toContain('cust-42'); }); it('inserts NULL customer_id when not provided', async () => { mockExecute.mockResolvedValue([{ insertId: 0, affectedRows: 1 }, []]); const code = await createAuthCode('client1', 'http://localhost:3000'); expect(code.customer_id).toBeUndefined(); const [, params] = mockExecute.mock.calls[0] as [string, unknown[]]; // Last param should be null expect(params[params.length - 1]).toBeNull(); }); }); describe('exchangeCodeForToken — replay protection', () => { beforeEach(() => vi.clearAllMocks()); const fakeClient = { client_id: 'c1', client_secret: 'secret', client_name: 'Test', redirect_uris: ['http://localhost:3000'], created_at: Date.now(), }; const fakeAuthCode = { code: 'abc', client_id: 'c1', redirect_uri: 'http://localhost:3000', scope: 'read', code_challenge: null, code_challenge_method: null, customer_id: 'cust-1', expires_at: new Date(Date.now() + 60_000), used: true, }; it('returns null when UPDATE affectedRows = 0 (already used)', async () => { // getClient SELECT mockExecute.mockResolvedValueOnce(rows([fakeClient])); // UPDATE oauth_clients last_used mockExecute.mockResolvedValueOnce(affected(1)); // Atomic UPDATE auth code — 0 rows (already used) mockExecute.mockResolvedValueOnce(affected(0)); const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000'); expect(result).toBeNull(); }); it('issues token and threads customer_id on success', async () => { // getClient SELECT mockExecute.mockResolvedValueOnce(rows([fakeClient])); // getClient UPDATE last_used mockExecute.mockResolvedValueOnce(affected(1)); // Atomic UPDATE auth code — 1 row consumed mockExecute.mockResolvedValueOnce(affected(1)); // SELECT auth code data mockExecute.mockResolvedValueOnce(rows([fakeAuthCode])); // INSERT token mockExecute.mockResolvedValueOnce(affected(1)); const token = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000'); expect(token).not.toBeNull(); expect(token!.access_token).toBeTruthy(); // Verify INSERT includes customer_id = 'cust-1' const insertCall = mockExecute.mock.calls.find((c) => (c[0] as string).includes('INSERT INTO oauth_tokens')); expect(insertCall).toBeDefined(); const insertParams = insertCall![1] as unknown[]; expect(insertParams).toContain('cust-1'); }); it('returns null when client/redirect mismatch', async () => { mockExecute.mockResolvedValueOnce(rows([fakeClient])); mockExecute.mockResolvedValueOnce(affected(1)); mockExecute.mockResolvedValueOnce(affected(1)); // SELECT returns code with different client_id mockExecute.mockResolvedValueOnce(rows([{ ...fakeAuthCode, client_id: 'other' }])); const result = await exchangeCodeForToken('c1', 'secret', 'abc', 'http://localhost:3000'); expect(result).toBeNull(); }); }); describe('getTokenCustomer', () => { beforeEach(() => vi.clearAllMocks()); it('returns customerId when token has one', async () => { mockExecute.mockResolvedValue(rows([{ customer_id: 'cust-99' }])); const result = await getTokenCustomer('tok-abc'); expect(result).toEqual({ customerId: 'cust-99' }); }); it('returns null when token not found', async () => { mockExecute.mockResolvedValue(rows([])); const result = await getTokenCustomer('bad-token'); expect(result).toBeNull(); }); it('returns null when token has no customer_id', async () => { mockExecute.mockResolvedValue(rows([{ customer_id: null }])); const result = await getTokenCustomer('tok-legacy'); expect(result).toBeNull(); }); }); describe('validateAccessToken', () => { beforeEach(() => vi.clearAllMocks()); it('returns true for valid token', async () => { mockExecute.mockResolvedValue(rows([{ token: 'tok' }])); expect(await validateAccessToken('tok')).toBe(true); }); it('returns false for expired/missing token', async () => { mockExecute.mockResolvedValue(rows([])); expect(await validateAccessToken('bad')).toBe(false); }); });