import type { ApiKeyWithRawValue } from '@n8n/api-types'; import { testDb, randomValidPassword, mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { User } from '@n8n/db'; import { ApiKeyRepository, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE } from '@n8n/db'; import { Container } from '@n8n/di'; import { getApiKeyScopesForRole, getOwnerOnlyApiKeyScopes, type ApiKeyScope, } from '@n8n/permissions'; import { PublicApiKeyService } from '@/services/public-api-key.service'; import { addApiKey, createAdmin, createMemberWithApiKey, createOwnerWithApiKey, createUser, createUserShell, } from './shared/db/users'; import type { SuperAgentTest } from './shared/types'; import * as utils from './shared/utils/'; const testServer = utils.setupTestServer({ endpointGroups: ['apiKeys'] }); let publicApiKeyService: PublicApiKeyService; beforeAll(() => { publicApiKeyService = Container.get(PublicApiKeyService); }); beforeEach(async () => { await testDb.truncate(['User']); mockInstance(GlobalConfig, { publicApi: { disabled: false } }); }); describe('When public API is disabled', () => { let owner: User; let authAgent: SuperAgentTest; beforeEach(async () => { owner = await createOwnerWithApiKey(); authAgent = testServer.authAgentFor(owner); mockInstance(GlobalConfig, { publicApi: { disabled: true } }); }); test('POST /api-keys should 404', async () => { await authAgent.post('/api-keys').expect(404); }); test('GET /api-keys should 404', async () => { await authAgent.get('/api-keys').expect(404); }); test('DELETE /api-key/:id should 404', async () => { await authAgent.delete(`/api-keys/${1}`).expect(404); }); }); describe('Owner shell', () => { let ownerShell: User; beforeEach(async () => { ownerShell = await createUserShell(GLOBAL_OWNER_ROLE); }); test('POST /api-keys should create an api key with no expiration', async () => { const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKey).toBeDefined(); const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ userId: ownerShell.id, }); expect(newStoredApiKey).toEqual({ id: expect.any(String), label: 'My API Key', userId: ownerShell.id, apiKey: newApiKey.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', lastUsedAt: null, }); expect(newApiKey.expiresAt).toBeNull(); expect(newApiKey.rawApiKey).toBeDefined(); }); test('POST /api-keys should fail to create api key with invalid scope', async () => { await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['wrong'] }) .expect(400); }); test('POST /api-keys should create an api key with expiration', async () => { const expiresAt = Date.now() + 1000; const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt, scopes: ['workflow:create'] }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKey).toBeDefined(); const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ userId: ownerShell.id, }); expect(newStoredApiKey).toEqual({ id: expect.any(String), label: 'My API Key', userId: ownerShell.id, apiKey: newApiKey.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', lastUsedAt: null, }); expect(newApiKey.expiresAt).toBe(expiresAt); expect(newApiKey.rawApiKey).toBeDefined(); }); test("POST /api-keys should create an api key with scopes allow in the user's role", async () => { const expiresAt = Date.now() + 1000; const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt, scopes: ['user:create'] }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKey).toBeDefined(); const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ userId: ownerShell.id, }); expect(newStoredApiKey).toEqual({ id: expect.any(String), label: 'My API Key', userId: ownerShell.id, apiKey: newApiKey.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), scopes: ['user:create'], audience: 'public-api', lastUsedAt: null, }); expect(newApiKey.expiresAt).toBe(expiresAt); expect(newApiKey.rawApiKey).toBeDefined(); }); test('PATCH /api-keys should update API key label', async () => { const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['user:create'] }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; await testServer .authAgentFor(ownerShell) .patch(`/api-keys/${newApiKey.id}`) .send({ label: 'updated label', scopes: ['user:create'] }); const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ userId: ownerShell.id, }); expect(newStoredApiKey).toEqual({ id: expect.any(String), label: 'updated label', userId: ownerShell.id, apiKey: newApiKey.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), scopes: ['user:create'], audience: 'public-api', lastUsedAt: null, }); }); test('PATCH /api-keys should update API key scopes', async () => { const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['user:create'] }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; await testServer .authAgentFor(ownerShell) .patch(`/api-keys/${newApiKey.id}`) .send({ label: 'updated label', scopes: ['user:create', 'workflow:create'] }); const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ userId: ownerShell.id, }); expect(newStoredApiKey).toEqual({ id: expect.any(String), label: 'updated label', userId: ownerShell.id, apiKey: newApiKey.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), scopes: ['user:create', 'workflow:create'], audience: 'public-api', lastUsedAt: null, }); }); test('PATCH /api-keys should not modify API key expiration', async () => { const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['user:create'] }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; await testServer .authAgentFor(ownerShell) .patch(`/api-keys/${newApiKey.id}`) .send({ label: 'updated label', expiresAt: 123, scopes: ['user:create'] }); const getApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys'); const allApiKeys = getApiKeysResponse.body.data.items as ApiKeyWithRawValue[]; const updatedApiKey = allApiKeys.find((apiKey) => apiKey.id === newApiKey.id); expect(updatedApiKey?.expiresAt).toBe(null); }); test('POST /api-keys/:id/rotate should re-issue the secret while keeping label and scopes', async () => { const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['user:create'] }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; const rotateResponse = await testServer .authAgentFor(ownerShell) .post(`/api-keys/${newApiKey.id}/rotate`) .expect(200); const rotatedApiKey = rotateResponse.body.data as ApiKeyWithRawValue; expect(rotatedApiKey.id).toBe(newApiKey.id); expect(rotatedApiKey.label).toBe('My API Key'); expect(rotatedApiKey.scopes).toEqual(['user:create']); expect(rotatedApiKey.rawApiKey).not.toBe(newApiKey.rawApiKey); const storedApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ id: newApiKey.id, }); // The stored token is the new one — the previous secret no longer authenticates. expect(storedApiKey.apiKey).toBe(rotatedApiKey.rawApiKey); expect(storedApiKey.apiKey).not.toBe(newApiKey.rawApiKey); }); test('POST /api-keys/:id/rotate should reject an expired key', async () => { // Mint an already-expired key via the service (the create DTO rejects past expiry). const expiredKey = await publicApiKeyService.createPublicApiKeyForUser(ownerShell, { label: 'My API Key', expiresAt: Math.floor(Date.now() / 1000) - 1000, scopes: ['user:create'], }); await testServer.authAgentFor(ownerShell).post(`/api-keys/${expiredKey.id}/rotate`).expect(400); const storedApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ id: expiredKey.id, }); // The token is left untouched. expect(storedApiKey.apiKey).toBe(expiredKey.apiKey); }); test('GET /api-keys should fetch the api key redacted', async () => { const expirationDateInTheFuture = Date.now() + 1000; const apiKeyWithNoExpiration = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] }); const apiKeyWithExpiration = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key 2', expiresAt: expirationDateInTheFuture, scopes: ['workflow:create'], }); const retrieveAllApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys'); expect(retrieveAllApiKeysResponse.statusCode).toBe(200); const expectedOwner = { id: ownerShell.id, firstName: ownerShell.firstName ?? null, lastName: ownerShell.lastName ?? null, email: ownerShell.email, }; expect(retrieveAllApiKeysResponse.body.data.counts.all).toBe(2); expect(retrieveAllApiKeysResponse.body.data.items[0]).toEqual({ id: apiKeyWithExpiration.body.data.id, label: 'My API Key 2', userId: ownerShell.id, apiKey: publicApiKeyService.redactApiKey(apiKeyWithExpiration.body.data.rawApiKey), createdAt: expect.any(String), updatedAt: expect.any(String), expiresAt: expirationDateInTheFuture, scopes: ['workflow:create'], audience: 'public-api', lastUsedAt: null, owner: expectedOwner, }); expect(retrieveAllApiKeysResponse.body.data.items[1]).toEqual({ id: apiKeyWithNoExpiration.body.data.id, label: 'My API Key', userId: ownerShell.id, apiKey: publicApiKeyService.redactApiKey(apiKeyWithNoExpiration.body.data.rawApiKey), createdAt: expect.any(String), updatedAt: expect.any(String), expiresAt: null, scopes: ['workflow:create'], audience: 'public-api', lastUsedAt: null, owner: expectedOwner, }); }); test('DELETE /api-keys/:id should delete the api key', async () => { const newApiKeyResponse = await testServer .authAgentFor(ownerShell) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] }); const deleteApiKeyResponse = await testServer .authAgentFor(ownerShell) .delete(`/api-keys/${newApiKeyResponse.body.data.id}`); const retrieveAllApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys'); expect(deleteApiKeyResponse.body.data.success).toBe(true); expect(retrieveAllApiKeysResponse.body.data.counts.all).toBe(0); expect(retrieveAllApiKeysResponse.body.data.items).toHaveLength(0); }); test('GET /api-keys/scopes should return scopes for the role', async () => { const apiKeyScopesResponse = await testServer.authAgentFor(ownerShell).get('/api-keys/scopes'); const scopes = apiKeyScopesResponse.body.data as ApiKeyScope[]; const scopesForRole = getApiKeyScopesForRole(ownerShell); expect(scopes.sort()).toEqual(scopesForRole.sort()); }); }); describe('Member', () => { const memberPassword = randomValidPassword(); let member: User; beforeEach(async () => { member = await createUser({ password: memberPassword, role: GLOBAL_MEMBER_ROLE, }); }); test('POST /api-keys should create an api key with no expiration', async () => { const newApiKeyResponse = await testServer .authAgentFor(member) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] }); expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKeyResponse.body.data.apiKey).toBeDefined(); expect(newApiKeyResponse.body.data.apiKey).not.toBeNull(); const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ userId: member.id, }); expect(newStoredApiKey).toEqual({ id: expect.any(String), label: 'My API Key', userId: member.id, apiKey: newApiKeyResponse.body.data.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', lastUsedAt: null, }); expect(newApiKeyResponse.body.data.expiresAt).toBeNull(); expect(newApiKeyResponse.body.data.rawApiKey).toBeDefined(); }); test('POST /api-keys should create an api key with expiration', async () => { const expiresAt = Date.now() + 1000; const newApiKeyResponse = await testServer .authAgentFor(member) .post('/api-keys') .send({ label: 'My API Key', expiresAt, scopes: ['workflow:create'] }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKey).toBeDefined(); const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ userId: member.id, }); expect(newStoredApiKey).toEqual({ id: expect.any(String), label: 'My API Key', userId: member.id, apiKey: newApiKey.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', lastUsedAt: null, }); expect(newApiKey.expiresAt).toBe(expiresAt); expect(newApiKey.rawApiKey).toBeDefined(); }); test("POST /api-keys should create an api key with scopes allowed in the user's role", async () => { const expiresAt = Date.now() + 1000; const newApiKeyResponse = await testServer .authAgentFor(member) .post('/api-keys') .send({ label: 'My API Key', expiresAt, scopes: ['workflow:create'] }); const newApiKey = newApiKeyResponse.body.data as ApiKeyWithRawValue; expect(newApiKeyResponse.statusCode).toBe(200); expect(newApiKey).toBeDefined(); const newStoredApiKey = await Container.get(ApiKeyRepository).findOneByOrFail({ userId: member.id, }); expect(newStoredApiKey).toEqual({ id: expect.any(String), label: 'My API Key', userId: member.id, apiKey: newApiKey.rawApiKey, createdAt: expect.any(Date), updatedAt: expect.any(Date), scopes: ['workflow:create'], audience: 'public-api', lastUsedAt: null, }); expect(newApiKey.expiresAt).toBe(expiresAt); expect(newApiKey.rawApiKey).toBeDefined(); }); test("POST /api-keys should fail to create api key with scopes not allowed in the user's role", async () => { const expiresAt = Date.now() + 1000; const notAllowedScope = getOwnerOnlyApiKeyScopes()[0]; const newApiKeyResponse = await testServer .authAgentFor(member) .post('/api-keys') .send({ label: 'My API Key', expiresAt, scopes: [notAllowedScope] }); expect(newApiKeyResponse.statusCode).toBe(400); }); test('GET /api-keys should fetch the api key redacted', async () => { const expirationDateInTheFuture = Date.now() + 1000; const apiKeyWithNoExpiration = await testServer .authAgentFor(member) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] }); const apiKeyWithExpiration = await testServer .authAgentFor(member) .post('/api-keys') .send({ label: 'My API Key 2', expiresAt: expirationDateInTheFuture, scopes: ['workflow:create'], }); const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/api-keys'); expect(retrieveAllApiKeysResponse.statusCode).toBe(200); const expectedOwner = { id: member.id, firstName: member.firstName ?? null, lastName: member.lastName ?? null, email: member.email, }; expect(retrieveAllApiKeysResponse.body.data.counts.all).toBe(2); expect(retrieveAllApiKeysResponse.body.data.items[0]).toEqual({ id: apiKeyWithExpiration.body.data.id, label: 'My API Key 2', userId: member.id, apiKey: publicApiKeyService.redactApiKey(apiKeyWithExpiration.body.data.rawApiKey), createdAt: expect.any(String), updatedAt: expect.any(String), expiresAt: expirationDateInTheFuture, scopes: ['workflow:create'], audience: 'public-api', lastUsedAt: null, owner: expectedOwner, }); expect(retrieveAllApiKeysResponse.body.data.items[1]).toEqual({ id: apiKeyWithNoExpiration.body.data.id, label: 'My API Key', userId: member.id, apiKey: publicApiKeyService.redactApiKey(apiKeyWithNoExpiration.body.data.rawApiKey), createdAt: expect.any(String), updatedAt: expect.any(String), expiresAt: null, scopes: ['workflow:create'], audience: 'public-api', lastUsedAt: null, owner: expectedOwner, }); }); test('GET /api-keys ignores ownership and ownerIds filters for members', async () => { // Another user's key that a member must never be able to surface. const otherOwner = await createOwnerWithApiKey(); await testServer .authAgentFor(member) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] }); // A member tries to escalate to the "all" view and target another owner. const response = await testServer .authAgentFor(member) .get(`/api-keys?ownership=all&ownerIds=${otherOwner.id}`); expect(response.statusCode).toBe(200); // Only the member's own key is returned; the owner filter is ignored. expect(response.body.data.counts.all).toBe(1); expect(response.body.data.items).toHaveLength(1); expect( response.body.data.items.every((apiKey: { userId: string }) => apiKey.userId === member.id), ).toBe(true); // The owner list is never exposed to callers without apiKey:manage. expect(response.body.data.owners).toEqual([]); }); test('DELETE /api-keys/:id should delete the api key', async () => { const newApiKeyResponse = await testServer .authAgentFor(member) .post('/api-keys') .send({ label: 'My API Key', expiresAt: null, scopes: ['workflow:create'] }); const deleteApiKeyResponse = await testServer .authAgentFor(member) .delete(`/api-keys/${newApiKeyResponse.body.data.id}`); const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/api-keys'); expect(deleteApiKeyResponse.body.data.success).toBe(true); expect(retrieveAllApiKeysResponse.body.data.counts.all).toBe(0); expect(retrieveAllApiKeysResponse.body.data.items).toHaveLength(0); }); test('GET /api-keys/scopes should return scopes for the role', async () => { const apiKeyScopesResponse = await testServer.authAgentFor(member).get('/api-keys/scopes'); const scopes = apiKeyScopesResponse.body.data as ApiKeyScope[]; const scopesForRole = getApiKeyScopesForRole(member); expect(scopes.sort()).toEqual(scopesForRole.sort()); }); }); describe('Pagination', () => { const seedKeys = async (user: User, count: number): Promise => { const agent = testServer.authAgentFor(user); const ids: string[] = []; for (let i = 0; i < count; i++) { const res = await agent .post('/api-keys') .send({ label: `Key ${i}`, expiresAt: null, scopes: ['workflow:create'] }); ids.push(res.body.data.id); } return ids; }; test('GET /api-keys honors `take` and returns total count', async () => { const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); await seedKeys(owner, 3); const response = await testServer.authAgentFor(owner).get('/api-keys?take=2').expect(200); expect(response.body.data.counts.all).toBe(3); expect(response.body.data.items).toHaveLength(2); }); test('GET /api-keys honors `skip` to page through results', async () => { const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); const createdIds = await seedKeys(owner, 3); const agent = testServer.authAgentFor(owner); const firstPage = await agent.get('/api-keys?take=2&skip=0').expect(200); const secondPage = await agent.get('/api-keys?take=2&skip=2').expect(200); expect(firstPage.body.data.items).toHaveLength(2); expect(secondPage.body.data.items).toHaveLength(1); const pagedIds = [...firstPage.body.data.items, ...secondPage.body.data.items].map( (k: { id: string }) => k.id, ); expect(new Set(pagedIds)).toEqual(new Set(createdIds)); }); }); describe('Sorting', () => { test('GET /api-keys sorts by label asc when sortBy=label:asc', async () => { const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); const agent = testServer.authAgentFor(owner); for (const label of ['gamma', 'alpha', 'beta']) { await agent.post('/api-keys').send({ label, expiresAt: null, scopes: ['workflow:create'] }); } const response = await agent.get('/api-keys?sortBy=label:asc').expect(200); const labels = (response.body.data.items as Array<{ label: string }>).map((k) => k.label); expect(labels).toEqual(['alpha', 'beta', 'gamma']); }); test('GET /api-keys sorts by scope count when sortBy=scopes:desc', async () => { const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); const agent = testServer.authAgentFor(owner); await agent .post('/api-keys') .send({ label: 'one-scope', expiresAt: null, scopes: ['workflow:create'] }); await agent.post('/api-keys').send({ label: 'three-scopes', expiresAt: null, scopes: ['workflow:create', 'workflow:read', 'workflow:delete'], }); await agent.post('/api-keys').send({ label: 'two-scopes', expiresAt: null, scopes: ['workflow:create', 'workflow:read'], }); const response = await agent.get('/api-keys?sortBy=scopes:desc').expect(200); const labels = (response.body.data.items as Array<{ label: string }>).map((k) => k.label); expect(labels).toEqual(['three-scopes', 'two-scopes', 'one-scope']); }); test('GET /api-keys rejects an unknown sortBy with 400', async () => { const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); await testServer.authAgentFor(owner).get('/api-keys?sortBy=bogus:asc').expect(400); }); }); describe('Label search', () => { test('GET /api-keys treats % in the search string as a literal character', async () => { const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); const agent = testServer.authAgentFor(owner); for (const label of ['100% complete', 'partial', 'fully done']) { await agent.post('/api-keys').send({ label, expiresAt: null, scopes: ['workflow:create'] }); } const response = await agent.get('/api-keys?label=100%25').expect(200); const labels = (response.body.data.items as Array<{ label: string }>).map((k) => k.label); expect(labels).toEqual(['100% complete']); }); test('GET /api-keys returns counts under filter and totals over the full list', async () => { const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); const agent = testServer.authAgentFor(owner); for (const label of ['prod-a', 'prod-b', 'staging']) { await agent.post('/api-keys').send({ label, expiresAt: null, scopes: ['workflow:create'] }); } const filtered = await agent.get('/api-keys?label=prod').expect(200); expect(filtered.body.data.counts.all).toBe(2); expect(filtered.body.data.totals.all).toBe(3); const unfiltered = await agent.get('/api-keys').expect(200); expect(unfiltered.body.data.counts.all).toBe(3); expect(unfiltered.body.data.totals.all).toBe(3); }); }); describe('Multi-value sortBy', () => { test('GET /api-keys rejects array sortBy with 400', async () => { const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); await testServer .authAgentFor(owner) .get('/api-keys?sortBy=label:asc&sortBy=createdAt:desc') .expect(400); }); }); describe('Cross-user behavior (admin scope)', () => { test("GET /api-keys returns every user's keys for an owner", async () => { const ownerWithKey = await createOwnerWithApiKey(); const memberWithKey = await createMemberWithApiKey(); const response = await testServer.authAgentFor(ownerWithKey).get('/api-keys').expect(200); const ids = (response.body.data.items as Array<{ id: string }>).map((k) => k.id); expect(ids).toEqual( expect.arrayContaining([ownerWithKey.apiKeys[0].id, memberWithKey.apiKeys[0].id]), ); expect(ids).toHaveLength(2); }); test('GET /api-keys returns only the caller’s keys for a member', async () => { const memberWithKey = await createMemberWithApiKey(); await createOwnerWithApiKey(); const response = await testServer.authAgentFor(memberWithKey).get('/api-keys').expect(200); const ids = (response.body.data.items as Array<{ id: string }>).map((k) => k.id); expect(ids).toEqual([memberWithKey.apiKeys[0].id]); }); test('DELETE /api-keys/:id 404s when a member targets another user’s key', async () => { const ownerWithKey = await createOwnerWithApiKey(); const member = await createUser({ role: GLOBAL_MEMBER_ROLE }); await testServer .authAgentFor(member) .delete(`/api-keys/${ownerWithKey.apiKeys[0].id}`) .expect(404); // Owner's key still exists. const ownerKeys = await Container.get(ApiKeyRepository).findBy({ userId: ownerWithKey.id }); expect(ownerKeys).toHaveLength(1); }); test('DELETE /api-keys/:id lets an admin revoke another user’s key', async () => { const admin = await createAdmin(); const memberWithKey = await createMemberWithApiKey(); await testServer .authAgentFor(admin) .delete(`/api-keys/${memberWithKey.apiKeys[0].id}`) .expect(200); const memberKeys = await Container.get(ApiKeyRepository).findBy({ userId: memberWithKey.id }); expect(memberKeys).toHaveLength(0); }); test('GET /api-keys narrows the all view to ownerIds for an admin', async () => { const ownerWithKey = await createOwnerWithApiKey(); const memberWithKey = await createMemberWithApiKey(); const response = await testServer .authAgentFor(ownerWithKey) .get(`/api-keys?ownership=all&ownerIds=${memberWithKey.id}`) .expect(200); const ids = (response.body.data.items as Array<{ id: string }>).map((k) => k.id); // Only the targeted owner's keys come back... expect(ids).toEqual([memberWithKey.apiKeys[0].id]); // ...while the owner list still reflects the full population with counts. const owners = response.body.data.owners as Array<{ id: string; keyCount: number }>; expect(owners).toEqual( expect.arrayContaining([ expect.objectContaining({ id: ownerWithKey.id, keyCount: 1 }), expect.objectContaining({ id: memberWithKey.id, keyCount: 1 }), ]), ); }); test('GET /api-keys reports per-owner key counts and a true total independent of filters', async () => { // One owner with several keys, one with a single key, so the grouped // COUNT(...) is exercised beyond the trivial one-key case. const ownerWithManyKeys = await createOwnerWithApiKey(); await addApiKey(ownerWithManyKeys); await addApiKey(ownerWithManyKeys); const memberWithKey = await createMemberWithApiKey(); // Narrow the page to a single owner; the owner list + counts must still // reflect the full population. const response = await testServer .authAgentFor(ownerWithManyKeys) .get(`/api-keys?ownership=all&ownerIds=${memberWithKey.id}`) .expect(200); const owners = response.body.data.owners as Array<{ id: string; keyCount: number }>; expect(owners).toEqual( expect.arrayContaining([ expect.objectContaining({ id: ownerWithManyKeys.id, keyCount: 3 }), expect.objectContaining({ id: memberWithKey.id, keyCount: 1 }), ]), ); // The page is narrowed (member's single key) while `totals` keep the // unfiltered population, so badges render against the true counts. expect(response.body.data.items).toHaveLength(1); expect(response.body.data.counts.all).toBe(1); expect(response.body.data.totals.all).toBe(4); }); });