UNPKG

@accounter/server

Version:
360 lines 16.4 kB
import { describe, expect, it, vi, beforeEach } from 'vitest'; import * as jose from 'jose'; import { AuthContextProvider, handleDevBypassAuth } from '../auth-context.provider.js'; vi.mock('jose', async () => { return { createRemoteJWKSet: vi.fn(), jwtVerify: vi.fn(), }; }); describe('AuthContextProvider', () => { let provider; let mockDBProvider; let mockEnv; let mockRawAuth; beforeEach(() => { vi.clearAllMocks(); mockDBProvider = { query: vi.fn(), }; mockEnv = { auth0: { domain: 'test.auth0.com', audience: 'test-audience', }, }; mockRawAuth = { authType: 'jwt', token: 'valid-token', }; vi.mocked(jose.createRemoteJWKSet).mockReturnValue({}); provider = new AuthContextProvider(mockEnv, mockRawAuth, mockDBProvider); }); it('should return null if authType is null', async () => { provider = new AuthContextProvider(mockEnv, { authType: null, token: null }, mockDBProvider); const result = await provider.getAuthContext(); expect(result).toBeNull(); }); it('should return null for invalid apiKey', async () => { provider = new AuthContextProvider(mockEnv, { authType: 'apiKey', token: 'key' }, mockDBProvider); mockDBProvider.query.mockResolvedValueOnce({ rows: [] }); const result = await provider.getAuthContext(); expect(result).toBeNull(); }); describe('JWT Verification', () => { it('should verify JWT and return context if valid', async () => { const mockPayload = { sub: 'auth0|123', exp: 1234567890, email: 'test@example.com', email_verified: false, permissions: [], }; vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: mockPayload }); mockDBProvider.query.mockResolvedValue({ rowCount: 1, rows: [{ user_id: 'u-1', business_id: 'b-1', role_id: 'admin' }], }); const result = await provider.getAuthContext(); expect(jose.createRemoteJWKSet).toHaveBeenCalled(); expect(jose.jwtVerify).toHaveBeenCalledWith('valid-token', expect.anything(), { issuer: 'https://test.auth0.com/', audience: 'test-audience', }); expect(result).toEqual({ authType: 'jwt', token: 'valid-token', user: { userId: 'u-1', roleId: 'admin', email: 'test@example.com', auth0UserId: 'auth0|123', permissions: [], emailVerified: false, permissionsVersion: 0, }, tenant: { businessId: 'b-1', roleId: 'admin', }, memberships: [{ businessId: 'b-1', roleId: 'admin' }], activeReadScope: { businessIds: ['b-1'] }, accessTokenExpiresAt: 1234567890, }); }); it('should return null if JWT verification fails', async () => { vi.mocked(jose.jwtVerify).mockRejectedValue(new Error('Invalid token')); const result = await provider.getAuthContext(); expect(result).toBeNull(); }); it('should return null if user not found in DB', async () => { const mockPayload = { sub: 'auth0|123', }; vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: mockPayload }); mockDBProvider.query.mockResolvedValue({ rowCount: 0, rows: [], }); const result = await provider.getAuthContext(); expect(result).toBeNull(); }); it('should relink user by verified email when auth0 sub changes', async () => { const mockPayload = { sub: 'google-oauth2|new-subject', exp: 1234567890, email: 'member@example.com', email_verified: true, permissions: [], }; vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: mockPayload }); mockDBProvider.query // byAuth0: no rows for the new subject .mockResolvedValueOnce({ rowCount: 0, rows: [] }) // byVerifiedEmail: identifies the local user .mockResolvedValueOnce({ rowCount: 1, rows: [{ user_id: 'u-42' }], }) // relink UPDATE .mockResolvedValueOnce({ rowCount: 2, rows: [] }) // byUserId: ALL memberships for the relinked user .mockResolvedValueOnce({ rowCount: 2, rows: [ { user_id: 'u-42', business_id: 'b-42', role_id: 'employee' }, { user_id: 'u-42', business_id: 'b-99', role_id: 'accountant' }, ], }); const result = await provider.getAuthContext(); expect(mockDBProvider.query).toHaveBeenNthCalledWith(1, expect.stringContaining('WHERE bu.auth0_user_id = $1'), ['google-oauth2|new-subject']); expect(mockDBProvider.query).toHaveBeenNthCalledWith(2, expect.stringContaining('FROM accounter_schema.invitations i'), ['member@example.com']); expect(mockDBProvider.query).toHaveBeenNthCalledWith(3, expect.stringContaining('UPDATE accounter_schema.business_users'), ['google-oauth2|new-subject', 'u-42']); expect(mockDBProvider.query).toHaveBeenNthCalledWith(4, expect.stringContaining('WHERE bu.user_id = $1'), ['u-42']); expect(result).toEqual({ authType: 'jwt', token: 'valid-token', user: { userId: 'u-42', roleId: 'employee', email: 'member@example.com', auth0UserId: 'google-oauth2|new-subject', permissions: [], emailVerified: true, permissionsVersion: 0, }, tenant: { businessId: 'b-42', roleId: 'employee', }, memberships: [ { businessId: 'b-42', roleId: 'employee' }, { businessId: 'b-99', roleId: 'accountant' }, ], activeReadScope: { businessIds: ['b-42', 'b-99'] }, accessTokenExpiresAt: 1234567890, }); }); it('should return null if sub claim is missing', async () => { const mockPayload = {}; // No sub vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: mockPayload }); const result = await provider.getAuthContext(); expect(result).toBeNull(); }); }); }); describe('AuthContextProvider read-scope resolution', () => { const makeJwtProvider = (membershipRows, requestedBusinessScope) => { const mockDb = { query: vi.fn().mockResolvedValue({ rowCount: membershipRows.length, rows: membershipRows }), }; const mockEnv = { auth0: { domain: 'test.auth0.com', audience: 'aud' } }; vi.mocked(jose.createRemoteJWKSet).mockReturnValue({}); vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: { sub: 'auth0|x', exp: 1, email: null, email_verified: false, permissions: [] }, }); return new AuthContextProvider(mockEnv, { authType: 'jwt', token: 'valid-token', requestedBusinessScope }, mockDb); }; const membershipRows = [ { user_id: 'u-1', business_id: 'b-1', role_id: 'owner' }, { user_id: 'u-1', business_id: 'b-2', role_id: 'accountant' }, ]; it('defaults read scope to all memberships when no header is sent', async () => { const result = await makeJwtProvider(membershipRows).getAuthContext(); expect(result?.activeReadScope).toEqual({ businessIds: ['b-1', 'b-2'] }); }); it('narrows read scope to a valid requested subset and re-points the tenant', async () => { const result = await makeJwtProvider(membershipRows, { kind: 'valid', businessIds: ['b-2'], }).getAuthContext(); expect(result?.activeReadScope).toEqual({ businessIds: ['b-2'] }); // tenant re-points to the scoped business so consumers reading // tenant.businessId directly do not operate on the out-of-scope primary expect(result?.tenant.businessId).toBe('b-2'); expect(result?.tenant.roleId).toBe('accountant'); }); it('keeps the primary tenant when it remains within a multi-business scope', async () => { const result = await makeJwtProvider(membershipRows, { kind: 'valid', businessIds: ['b-2', 'b-1'], }).getAuthContext(); expect(result?.activeReadScope).toEqual({ businessIds: ['b-2', 'b-1'] }); // primary (b-1) is still in scope, so the tenant is unchanged expect(result?.tenant.businessId).toBe('b-1'); expect(result?.tenant.roleId).toBe('owner'); }); it('re-points to the first scoped business when the primary is outside a multi-business scope', async () => { const rows = [ { user_id: 'u-1', business_id: 'b-1', role_id: 'owner' }, { user_id: 'u-1', business_id: 'b-2', role_id: 'accountant' }, { user_id: 'u-1', business_id: 'b-3', role_id: 'employee' }, ]; const result = await makeJwtProvider(rows, { kind: 'valid', businessIds: ['b-2', 'b-3'], }).getAuthContext(); expect(result?.activeReadScope).toEqual({ businessIds: ['b-2', 'b-3'] }); // primary (b-1) is out of scope → first scoped business becomes the tenant expect(result?.tenant.businessId).toBe('b-2'); expect(result?.tenant.roleId).toBe('accountant'); }); it('rejects a requested scope outside the user memberships', async () => { await expect(makeJwtProvider(membershipRows, { kind: 'valid', businessIds: ['b-1', 'b-999'], }).getAuthContext()).rejects.toMatchObject({ extensions: { code: 'FORBIDDEN' }, }); }); it('rejects a malformed scope header', async () => { await expect(makeJwtProvider(membershipRows, { kind: 'invalid', errors: [{ code: 'INVALID_UUID', value: 'nope' }], }).getAuthContext()).rejects.toMatchObject({ extensions: { code: 'FORBIDDEN' }, }); }); it('does not expand scope beyond memberships (no super-admin auto-expansion)', async () => { const result = await makeJwtProvider([ { user_id: 'u-1', business_id: 'b-1', role_id: 'owner' }, ]).getAuthContext(); expect(result?.activeReadScope).toEqual({ businessIds: ['b-1'] }); }); it('pins API-key scope to its business and ignores the header', async () => { const mockDb = { query: vi .fn() .mockResolvedValueOnce({ rows: [{ id: 'k1', business_id: 'biz-1', role_id: 'scraper' }], }) .mockResolvedValueOnce({ rowCount: 1 }), }; const provider = new AuthContextProvider({ auth0: { domain: 'd', audience: 'a' } }, { authType: 'apiKey', token: 'key', requestedBusinessScope: { kind: 'valid', businessIds: ['biz-2'] }, }, mockDb); const result = await provider.getAuthContext(); expect(result?.tenant.businessId).toBe('biz-1'); expect(result?.activeReadScope).toEqual({ businessIds: ['biz-1'] }); }); }); describe('handleDevBypassAuth', () => { it('returns devBypass auth context when user exists', async () => { const db = { query: vi.fn().mockResolvedValue({ rows: [ { user_id: 'user-123', business_id: 'biz-456', role_id: 'business_owner', auth0_user_id: 'auth0|demo', }, ], }), }; const result = await handleDevBypassAuth(db, 'user-123'); expect(db.query).toHaveBeenCalledWith(expect.stringContaining('FROM accounter_schema.business_users'), ['user-123']); expect(result).toEqual({ authType: 'devBypass', token: 'user-123', user: { userId: 'user-123', auth0UserId: 'auth0|demo', email: 'dev-user', roleId: 'business_owner', permissions: [], emailVerified: true, permissionsVersion: 0, }, tenant: { businessId: 'biz-456', roleId: 'business_owner', }, memberships: [{ businessId: 'biz-456', roleId: 'business_owner' }], activeReadScope: { businessIds: ['biz-456'] }, }); }); it('returns null when user is not found', async () => { const db = { query: vi.fn().mockResolvedValue({ rows: [] }), }; const result = await handleDevBypassAuth(db, 'missing-user'); expect(result).toBeNull(); }); it('returns null for an empty/whitespace user id without querying', async () => { const db = { query: vi.fn(), }; expect(await handleDevBypassAuth(db, ' ')).toBeNull(); expect(db.query).not.toHaveBeenCalled(); }); // dev-bypass now resolves ALL memberships (no LIMIT 1); the primary (most // recently updated) membership still backs the single-business tenant context. it('resolves all memberships and selects the primary as tenant', async () => { const query = vi.fn().mockResolvedValue({ rows: [ { user_id: 'user-1', business_id: 'biz-1', role_id: 'business_owner', auth0_user_id: null }, { user_id: 'user-1', business_id: 'biz-2', role_id: 'accountant', auth0_user_id: null }, ], }); const db = { query }; const result = await handleDevBypassAuth(db, 'user-1'); expect(query).toHaveBeenCalledWith(expect.not.stringContaining('LIMIT 1'), ['user-1']); expect(result?.tenant.businessId).toBe('biz-1'); expect(result?.memberships).toEqual([ { businessId: 'biz-1', roleId: 'business_owner' }, { businessId: 'biz-2', roleId: 'accountant' }, ]); }); }); // JWT auth now resolves all of a user's memberships (no LIMIT 1 on the auth0 // lookup); the primary membership still backs the single-business tenant context. describe('AuthContextProvider membership resolution', () => { it('resolves all memberships for the Auth0 subject without a LIMIT 1 lookup', async () => { const mockDb = { query: vi.fn().mockResolvedValue({ rowCount: 2, rows: [ { user_id: 'u-1', business_id: 'b-1', role_id: 'admin' }, { user_id: 'u-1', business_id: 'b-2', role_id: 'accountant' }, ], }), }; const mockEnv = { auth0: { domain: 'test.auth0.com', audience: 'aud' } }; vi.mocked(jose.createRemoteJWKSet).mockReturnValue({}); vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: { sub: 'auth0|123', exp: 1, email: null, email_verified: false, permissions: [] }, }); const provider = new AuthContextProvider(mockEnv, { authType: 'jwt', token: 'valid-token' }, mockDb); const result = await provider.getAuthContext(); expect(mockDb.query).toHaveBeenCalledWith(expect.not.stringContaining('LIMIT 1'), ['auth0|123']); expect(result?.tenant.businessId).toBe('b-1'); expect(result?.memberships).toEqual([ { businessId: 'b-1', roleId: 'admin' }, { businessId: 'b-2', roleId: 'accountant' }, ]); }); }); //# sourceMappingURL=auth-context.test.js.map