UNPKG

@accounter/server

Version:
422 lines 19.9 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('getJwtIdentity', () => { // The JWKS cache in the provider module is global and keyed by domain, and // `vi.clearAllMocks()` does not clear it. Using a domain of our own keeps // these cases from warming the entry other tests assert on. let identityProvider; beforeEach(() => { identityProvider = new AuthContextProvider({ auth0: { domain: 'jwt-identity.auth0.com', audience: 'test-audience' } }, mockRawAuth, mockDBProvider); }); it('verifies the JWT once per operation across repeated calls', async () => { // The auth directives call this on every guarded field that fails to // resolve a context, so an unprovisioned user's multi-field query would // otherwise re-verify the same token once per field. vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: { sub: 'auth0|123', email: 'test@example.com', email_verified: true }, }); const first = await identityProvider.getJwtIdentity(); const second = await identityProvider.getJwtIdentity(); expect(first).toEqual({ auth0UserId: 'auth0|123', email: 'test@example.com', emailVerified: true, }); expect(second).toEqual(first); expect(jose.jwtVerify).toHaveBeenCalledTimes(1); }); it('reuses the verification getAuthContext already performed', async () => { // The auth directives always call getAuthContext() first and only fall // back to getJwtIdentity() when it yields no user — the unprovisioned // case. Verifying the same signature twice per request would be pure waste. vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: { sub: 'auth0|unlinked', email: 'new@example.com', email_verified: true }, }); // No membership rows: this is the identity that has no auth context. mockDBProvider.query.mockResolvedValue({ rowCount: 0, rows: [] }); const context = await identityProvider.getAuthContext(); const identity = await identityProvider.getJwtIdentity(); expect(context).toBeNull(); expect(identity).toEqual({ auth0UserId: 'auth0|unlinked', email: 'new@example.com', emailVerified: true, }); expect(jose.jwtVerify).toHaveBeenCalledTimes(1); }); it('does not re-verify a token that carries no sub claim', async () => { vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: { email: 'new@example.com' } }); expect(await identityProvider.getAuthContext()).toBeNull(); expect(await identityProvider.getJwtIdentity()).toBeNull(); expect(jose.jwtVerify).toHaveBeenCalledTimes(1); }); it('verifies once when concurrent callers race', async () => { vi.mocked(jose.jwtVerify).mockResolvedValue({ payload: { sub: 'auth0|123', email: 'test@example.com', email_verified: true }, }); const [first, second] = await Promise.all([ identityProvider.getJwtIdentity(), identityProvider.getJwtIdentity(), ]); expect(second).toEqual(first); expect(jose.jwtVerify).toHaveBeenCalledTimes(1); }); }); 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