@accounter/server
Version:
Accounter GraphQL server
343 lines (341 loc) • 13.9 kB
JavaScript
import { createHash } from 'node:crypto';
import { useSchema } from '@envelop/core';
import { useGraphQLModules } from '@envelop/graphql-modules';
import { CONTEXT, Scope, createApplication, createModule, gql } from 'graphql-modules';
import { createYoga } from 'graphql-yoga';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authPlugin } from '../../../../plugins/auth-plugin.js';
import { ENVIRONMENT, RAW_AUTH } from '../../../../shared/tokens.js';
import { DBProvider } from '../../../app-providers/db.provider.js';
import { authDirectiveTransformer } from '../../directives/auth-directives.js';
import { AuthContextProvider } from '../auth-context.provider.js';
describe('AuthContextProvider API key unit tests', () => {
const mockEnv = {
auth0: {
domain: 'test.auth0.com',
audience: 'test-audience',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
managementAudience: 'https://test.auth0.com/api/v2/',
},
};
let mockDb;
beforeEach(() => {
mockDb = {
query: vi.fn(),
};
});
it('valid API key returns authType=apiKey with tenant and role', async () => {
const plaintextKey = 'valid-plain-key';
const expectedHash = createHash('sha256').update(plaintextKey).digest('hex');
mockDb.query
.mockResolvedValueOnce({
rows: [
{
id: 'api-key-1',
business_id: 'business-123',
role_id: 'scraper',
},
],
})
.mockResolvedValueOnce({ rowCount: 1 });
const provider = new AuthContextProvider(mockEnv, { authType: 'apiKey', token: plaintextKey }, mockDb);
const context = await provider.getAuthContext();
expect(context?.authType).toBe('apiKey');
expect(context?.tenant.businessId).toBe('business-123');
expect(context?.user?.roleId).toBe('scraper');
expect(context?.user?.userId).toBe('api-key:api-key-1');
expect(mockDb.query).toHaveBeenNthCalledWith(1, expect.stringContaining('FROM accounter_schema.api_keys'), [expectedHash]);
});
it('invalid API key returns null', async () => {
mockDb.query.mockResolvedValueOnce({ rows: [] });
const provider = new AuthContextProvider(mockEnv, { authType: 'apiKey', token: 'wrong-key' }, mockDb);
const context = await provider.getAuthContext();
expect(context).toBeNull();
expect(mockDb.query).toHaveBeenCalledTimes(1);
});
it('revoked API key behaves as not found and returns null', async () => {
mockDb.query.mockResolvedValueOnce({ rows: [] });
const provider = new AuthContextProvider(mockEnv, { authType: 'apiKey', token: 'revoked-key' }, mockDb);
const context = await provider.getAuthContext();
expect(context).toBeNull();
});
it('updates last_used_at on first use', async () => {
mockDb.query
.mockResolvedValueOnce({
rows: [
{
id: 'api-key-2',
business_id: 'business-xyz',
role_id: 'scraper',
},
],
})
.mockResolvedValueOnce({ rowCount: 1 });
const provider = new AuthContextProvider(mockEnv, { authType: 'apiKey', token: 'first-use-key' }, mockDb);
await provider.getAuthContext();
expect(mockDb.query).toHaveBeenNthCalledWith(2, expect.stringContaining('SET last_used_at = NOW()'), ['api-key-2']);
});
it('API key with disallowed role (business_owner) returns null', async () => {
mockDb.query.mockResolvedValueOnce({
rows: [
{
id: 'api-key-bad-role',
business_id: 'business-xyz',
role_id: 'business_owner',
},
],
});
const provider = new AuthContextProvider(mockEnv, { authType: 'apiKey', token: 'disallowed-role-key' }, mockDb);
const context = await provider.getAuthContext();
expect(context).toBeNull();
// last_used_at must NOT be touched for a rejected key
expect(mockDb.query).toHaveBeenCalledTimes(1);
});
it('uses hourly throttle condition when touching last_used_at', async () => {
mockDb.query
.mockResolvedValueOnce({
rows: [
{
id: 'api-key-3',
business_id: 'business-hourly',
role_id: 'scraper',
},
],
})
.mockResolvedValueOnce({ rowCount: 0 });
const provider = new AuthContextProvider(mockEnv, { authType: 'apiKey', token: 'hourly-key' }, mockDb);
const context = await provider.getAuthContext();
expect(context?.authType).toBe('apiKey');
const updateSql = mockDb.query.mock.calls[1]?.[0];
expect(updateSql).toContain("last_used_at < NOW() - INTERVAL '1 hour'");
});
});
describe('API key GraphQL integration', () => {
const env = {
auth0: {
domain: 'test.auth0.com',
audience: 'test-audience',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
managementAudience: 'https://test.auth0.com/api/v2/',
},
};
const keyRows = {
validScraperKey: {
id: 'key-1',
business_id: 'business-a',
role_id: 'scraper',
revoked_at: null,
},
ownerBusinessBKey: {
id: 'key-2',
business_id: 'business-b',
role_id: 'accountant',
revoked_at: null,
},
revokedKey: {
id: 'key-3',
business_id: 'business-a',
role_id: 'scraper',
revoked_at: new Date('2030-01-01T00:00:00.000Z'),
},
};
const chargesByBusiness = {
'business-a': ['charge-a1', 'charge-a2'],
'business-b': ['charge-b1'],
};
function buildMockDb() {
return {
query: vi.fn(async (queryText, params) => {
if (queryText.includes('FROM accounter_schema.api_keys')) {
const hash = params[0];
const entry = Object.entries(keyRows).find(([plainKey]) => {
return createHash('sha256').update(plainKey).digest('hex') === hash;
});
if (!entry) {
return { rows: [] };
}
const row = entry[1];
if (row.revoked_at) {
return { rows: [] };
}
return {
rows: [
{
id: row.id,
business_id: row.business_id,
role_id: row.role_id,
},
],
};
}
if (queryText.includes('SET last_used_at = NOW()')) {
return { rowCount: 1, rows: [] };
}
throw new Error(`Unexpected query in test: ${queryText}`);
}),
};
}
function createTestYoga() {
const mockDb = buildMockDb();
const testModule = createModule({
id: 'api-key-auth-integration',
typeDefs: [
gql `
directive @requiresAuth on FIELD_DEFINITION
directive @requiresRole(role: String!) on FIELD_DEFINITION
type Query {
tenantBusinessId: String! @requiresAuth
tenantCharges: [String!]! @requiresAuth
}
type Mutation {
createInvitation: Boolean! @requiresRole(role: "business_owner")
}
`,
],
resolvers: {
Query: {
tenantBusinessId: async (_root, _args, context) => {
const authProvider = context.injector.get(AuthContextProvider);
const authContext = await authProvider.getAuthContext();
// The @requiresAuth directive should ensure authContext is not null.
return authContext.tenant.businessId;
},
tenantCharges: async (_root, _args, context) => {
const authProvider = context.injector.get(AuthContextProvider);
const authContext = await authProvider.getAuthContext();
// The @requiresAuth directive should ensure authContext is not null.
const businessId = authContext.tenant.businessId;
// Simulates tenant-scoped reads (as enforced by RLS in production).
return chargesByBusiness[businessId] ?? [];
},
},
Mutation: {
createInvitation: () => true,
},
},
});
const application = createApplication({
modules: [testModule],
providers: [
{
provide: ENVIRONMENT,
useValue: env,
scope: Scope.Singleton,
},
{
provide: RAW_AUTH,
useFactory: (context) => {
return context.rawAuth ?? { authType: null, token: null };
},
scope: Scope.Operation,
deps: [CONTEXT],
},
{
provide: DBProvider,
useValue: mockDb,
scope: Scope.Singleton,
},
AuthContextProvider,
],
});
const transformedSchema = authDirectiveTransformer(application.schema);
return createYoga({
maskedErrors: false,
plugins: [authPlugin(), useGraphQLModules(application), useSchema(transformedSchema)],
context: ({ request }) => {
const apiKeyHeader = request.headers.get('x-api-key');
const rawAuth = apiKeyHeader
? { authType: 'apiKey', token: apiKeyHeader.trim() }
: { authType: null, token: null };
return {
env,
rawAuth,
injector: {
get(token) {
if (token !== AuthContextProvider) {
throw new Error(`Unexpected token requested: ${String(token)}`);
}
return new AuthContextProvider(env, rawAuth, mockDb);
},
},
};
},
});
}
it('valid X-API-Key returns 200 and tenant-scoped data', async () => {
const yoga = createTestYoga();
const response = await yoga.fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': 'validScraperKey',
},
body: JSON.stringify({
query: '{ tenantBusinessId tenantCharges }',
}),
});
const json = await response.json();
expect(response.status).toBe(200);
expect(json.errors).toBeUndefined();
expect(json.data).toEqual({
tenantBusinessId: 'business-a',
tenantCharges: ['charge-a1', 'charge-a2'],
});
});
it('invalid X-API-Key returns UNAUTHENTICATED', async () => {
const yoga = createTestYoga();
const response = await yoga.fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': 'invalid-key',
},
body: JSON.stringify({
query: '{ tenantBusinessId }',
}),
});
const json = await response.json();
expect(response.status).toBe(200);
expect(json.data).toBeNull();
expect(json.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED');
});
it('enforces business isolation for API key tenant context', async () => {
const yoga = createTestYoga();
const response = await yoga.fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': 'validScraperKey',
},
body: JSON.stringify({
query: '{ tenantBusinessId tenantCharges }',
}),
});
const json = await response.json();
expect(json.errors).toBeUndefined();
expect(json.data).toEqual({
tenantBusinessId: 'business-a',
tenantCharges: ['charge-a1', 'charge-a2'],
});
});
it('scraper API key cannot invoke createInvitation', async () => {
const yoga = createTestYoga();
const response = await yoga.fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': 'validScraperKey',
},
body: JSON.stringify({
query: 'mutation { createInvitation }',
}),
});
const json = await response.json();
expect(response.status).toBe(200);
expect(json.data).toBeNull();
expect(json.errors?.[0]?.extensions?.code).toBe('FORBIDDEN');
});
});
//# sourceMappingURL=api-key-auth.test.js.map