/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { cleanDatabase } from '@db/factories' import adminRevokeApiKey from '@graphql/queries/apiKeys/adminRevokeApiKey.gql' import adminRevokeUserApiKeys from '@graphql/queries/apiKeys/adminRevokeUserApiKeys.gql' import apiKeysForUser from '@graphql/queries/apiKeys/apiKeysForUser.gql' import apiKeyUsers from '@graphql/queries/apiKeys/apiKeyUsers.gql' import createApiKey from '@graphql/queries/apiKeys/createApiKey.gql' import myApiKeys from '@graphql/queries/apiKeys/myApiKeys.gql' import revokeApiKey from '@graphql/queries/apiKeys/revokeApiKey.gql' import updateApiKey from '@graphql/queries/apiKeys/updateApiKey.gql' import { createApolloTestSetup } from '@root/test/helpers' import type { ApolloTestSetup } from '@root/test/helpers' import type { Context } from '@src/context' let authenticatedUser: Context['user'] const context = () => ({ authenticatedUser, config: { API_KEYS_ENABLED: true, API_KEYS_MAX_PER_USER: 3 }, }) let query: ApolloTestSetup['query'] let mutate: ApolloTestSetup['mutate'] let database: ApolloTestSetup['database'] let server: ApolloTestSetup['server'] beforeAll(async () => { await cleanDatabase() const apolloSetup = await createApolloTestSetup({ context }) query = apolloSetup.query mutate = apolloSetup.mutate database = apolloSetup.database server = apolloSetup.server }) afterAll(async () => { await cleanDatabase() void server.stop() void database.driver.close() database.neode.close() }) afterEach(async () => { await cleanDatabase() }) describe('createApiKey', () => { describe('unauthenticated', () => { beforeEach(() => { authenticatedUser = null }) it('throws authorization error', async () => { const { errors } = await mutate({ mutation: createApiKey, variables: { name: 'Test Key' }, }) expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') }) }) describe('authenticated', () => { beforeEach(async () => { const user = await database.neode.create('User', { id: 'u1', name: 'Test User', slug: 'test-user', role: 'user', }) authenticatedUser = (await user.toJson()) as Context['user'] }) it('creates an API key and returns secret with oak_ prefix', async () => { const { data, errors } = await mutate({ mutation: createApiKey, variables: { name: 'My CI Key' }, }) expect(errors).toBeUndefined() expect(data.createApiKey.secret).toMatch(/^oak_/) expect(data.createApiKey.apiKey).toMatchObject({ name: 'My CI Key', disabled: false, disabledAt: null, lastUsedAt: null, }) expect(data.createApiKey.apiKey.keyPrefix).toMatch(/^oak_/) expect(data.createApiKey.apiKey.id).toBeTruthy() expect(data.createApiKey.apiKey.createdAt).toBeTruthy() }) it('creates a key with expiry', async () => { const { data, errors } = await mutate({ mutation: createApiKey, variables: { name: 'Expiring Key', expiresInDays: 30 }, }) expect(errors).toBeUndefined() expect(data.createApiKey.apiKey.expiresAt).toBeTruthy() }) it('creates a key without expiry', async () => { const { data, errors } = await mutate({ mutation: createApiKey, variables: { name: 'Permanent Key' }, }) expect(errors).toBeUndefined() expect(data.createApiKey.apiKey.expiresAt).toBeNull() }) it('enforces max keys per user', async () => { await mutate({ mutation: createApiKey, variables: { name: 'Key 1' } }) await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } }) await mutate({ mutation: createApiKey, variables: { name: 'Key 3' } }) const { errors } = await mutate({ mutation: createApiKey, variables: { name: 'Key 4' }, }) expect(errors?.[0].message).toContain('Maximum of 3 active API keys reached') }) it('rejects expiresInDays of 0', async () => { const { errors } = await mutate({ mutation: createApiKey, variables: { name: 'Zero Expiry', expiresInDays: 0 }, }) expect(errors?.[0].message).toContain('expiresInDays must be a positive integer') }) it('rejects negative expiresInDays', async () => { const { errors } = await mutate({ mutation: createApiKey, variables: { name: 'Negative Expiry', expiresInDays: -5 }, }) expect(errors?.[0].message).toContain('expiresInDays must be a positive integer') }) it('does not count revoked keys towards the limit', async () => { const { data: k1 } = await mutate({ mutation: createApiKey, variables: { name: 'Key 1' } }) await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } }) await mutate({ mutation: createApiKey, variables: { name: 'Key 3' } }) // Revoke one await mutate({ mutation: revokeApiKey, variables: { id: k1.createApiKey.apiKey.id } }) // Should succeed now const { errors } = await mutate({ mutation: createApiKey, variables: { name: 'Key 4' } }) expect(errors).toBeUndefined() }) }) describe('API keys disabled', () => { it('throws error when feature is disabled', async () => { const user = await database.neode.create('User', { id: 'u-disabled', name: 'Disabled User', slug: 'disabled-user', role: 'user', }) authenticatedUser = (await user.toJson()) as Context['user'] const contextDisabled = () => ({ authenticatedUser, config: { API_KEYS_ENABLED: false, API_KEYS_MAX_PER_USER: 5 }, }) const setup = await createApolloTestSetup({ context: contextDisabled }) const { errors } = await setup.mutate({ mutation: createApiKey, variables: { name: 'Should Fail' }, }) expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') void setup.server.stop() }) }) }) describe('myApiKeys', () => { beforeEach(async () => { const user = await database.neode.create('User', { id: 'u2', name: 'Key Owner', slug: 'key-owner', role: 'user', }) authenticatedUser = (await user.toJson()) as Context['user'] }) it('returns empty list when no keys exist', async () => { const { data, errors } = await query({ query: myApiKeys }) expect(errors).toBeUndefined() expect(data.myApiKeys).toEqual([]) }) it('returns created keys ordered by createdAt desc', async () => { await mutate({ mutation: createApiKey, variables: { name: 'Key A' } }) await mutate({ mutation: createApiKey, variables: { name: 'Key B' } }) const { data, errors } = await query({ query: myApiKeys }) expect(errors).toBeUndefined() expect(data.myApiKeys).toHaveLength(2) // Most recent first expect(data.myApiKeys[0].name).toBe('Key B') expect(data.myApiKeys[1].name).toBe('Key A') }) it('includes revoked keys', async () => { const { data: created } = await mutate({ mutation: createApiKey, variables: { name: 'To Revoke' }, }) await mutate({ mutation: revokeApiKey, variables: { id: created.createApiKey.apiKey.id } }) const { data } = await query({ query: myApiKeys }) expect(data.myApiKeys).toHaveLength(1) expect(data.myApiKeys[0].disabled).toBe(true) expect(data.myApiKeys[0].disabledAt).toBeTruthy() }) }) describe('updateApiKey', () => { let keyId: string beforeEach(async () => { const user = await database.neode.create('User', { id: 'u3', name: 'Updater', slug: 'updater', role: 'user', }) authenticatedUser = (await user.toJson()) as Context['user'] const { data } = await mutate({ mutation: createApiKey, variables: { name: 'Original' } }) keyId = data.createApiKey.apiKey.id }) it('renames a key', async () => { const { data, errors } = await mutate({ mutation: updateApiKey, variables: { id: keyId, name: 'Renamed' }, }) expect(errors).toBeUndefined() expect(data.updateApiKey.name).toBe('Renamed') }) it('throws error for nonexistent key', async () => { const { errors } = await mutate({ mutation: updateApiKey, variables: { id: 'nonexistent', name: 'Fail' }, }) expect(errors?.[0].message).toContain('API key not found') }) it("throws error for another user's key", async () => { const otherUser = await database.neode.create('User', { id: 'u-stranger', name: 'Stranger', slug: 'stranger', role: 'user', }) authenticatedUser = (await otherUser.toJson()) as Context['user'] const { errors } = await mutate({ mutation: updateApiKey, variables: { id: keyId, name: 'Stolen' }, }) expect(errors?.[0].message).toContain('API key not found') }) }) describe('revokeApiKey', () => { let keyId: string beforeEach(async () => { const user = await database.neode.create('User', { id: 'u4', name: 'Revoker', slug: 'revoker', role: 'user', }) authenticatedUser = (await user.toJson()) as Context['user'] const { data } = await mutate({ mutation: createApiKey, variables: { name: 'To Revoke' } }) keyId = data.createApiKey.apiKey.id }) it('revokes own key', async () => { const { data, errors } = await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) expect(errors).toBeUndefined() expect(data.revokeApiKey).toBe(true) }) it('sets disabledAt on revoked key', async () => { await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) const { data } = await query({ query: myApiKeys }) const revoked = data.myApiKeys.find((k) => k.id === keyId) expect(revoked.disabled).toBe(true) expect(revoked.disabledAt).toBeTruthy() }) it('returns false for nonexistent key', async () => { const { data } = await mutate({ mutation: revokeApiKey, variables: { id: 'nonexistent' } }) expect(data.revokeApiKey).toBe(false) }) it('returns false when revoking an already revoked key', async () => { await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) const { data } = await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) expect(data.revokeApiKey).toBe(false) }) it('preserves original disabledAt on double revoke', async () => { await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) const { data: before } = await query({ query: myApiKeys }) const originalDisabledAt = before.myApiKeys.find((k) => k.id === keyId).disabledAt await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) const { data: after } = await query({ query: myApiKeys }) expect(after.myApiKeys.find((k) => k.id === keyId).disabledAt).toBe(originalDisabledAt) }) it("returns false for another user's key", async () => { const otherUser = await database.neode.create('User', { id: 'u4-other', name: 'Other', slug: 'other', role: 'user', }) authenticatedUser = (await otherUser.toJson()) as Context['user'] const { data } = await mutate({ mutation: revokeApiKey, variables: { id: keyId } }) expect(data.revokeApiKey).toBe(false) }) }) describe('admin operations', () => { let regularUser, adminUser let keyId: string beforeEach(async () => { regularUser = await database.neode.create('User', { id: 'u-regular', name: 'Regular', slug: 'regular', role: 'user', }) adminUser = await database.neode.create('User', { id: 'u-admin', name: 'Admin', slug: 'admin', role: 'admin', }) authenticatedUser = (await regularUser.toJson()) as Context['user'] const { data } = await mutate({ mutation: createApiKey, variables: { name: 'Regular Key' } }) keyId = data.createApiKey.apiKey.id }) describe('adminRevokeApiKey', () => { it('non-admin cannot revoke', async () => { authenticatedUser = (await regularUser.toJson()) as Context['user'] const { errors } = await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') }) it('admin can revoke any key', async () => { authenticatedUser = (await adminUser.toJson()) as Context['user'] const { data, errors } = await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId }, }) expect(errors).toBeUndefined() expect(data.adminRevokeApiKey).toBe(true) }) it('returns false when admin revokes an already revoked key', async () => { authenticatedUser = (await adminUser.toJson()) as Context['user'] await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) const { data } = await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) expect(data.adminRevokeApiKey).toBe(false) }) }) describe('adminRevokeUserApiKeys', () => { beforeEach(async () => { authenticatedUser = (await regularUser.toJson()) as Context['user'] await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } }) }) it('non-admin cannot bulk revoke', async () => { authenticatedUser = (await regularUser.toJson()) as Context['user'] const { errors } = await mutate({ mutation: adminRevokeUserApiKeys, variables: { userId: 'u-regular' }, }) expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') }) it('admin can revoke all keys of a user', async () => { authenticatedUser = (await adminUser.toJson()) as Context['user'] const { data, errors } = await mutate({ mutation: adminRevokeUserApiKeys, variables: { userId: 'u-regular' }, }) expect(errors).toBeUndefined() expect(data.adminRevokeUserApiKeys).toBe(2) }) it('returns 0 when user has no active keys', async () => { authenticatedUser = (await adminUser.toJson()) as Context['user'] const { data } = await mutate({ mutation: adminRevokeUserApiKeys, variables: { userId: 'u-admin' }, }) expect(data.adminRevokeUserApiKeys).toBe(0) }) }) describe('apiKeyUsers', () => { it('non-admin cannot access', async () => { authenticatedUser = (await regularUser.toJson()) as Context['user'] const { errors } = await query({ query: apiKeyUsers, variables: { first: 10, offset: 0 } }) expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') }) it('admin sees users with key stats', async () => { authenticatedUser = (await adminUser.toJson()) as Context['user'] const { data, errors } = await query({ query: apiKeyUsers, variables: { first: 10, offset: 0 }, }) expect(errors).toBeUndefined() expect(data.apiKeyUsers.length).toBeGreaterThanOrEqual(1) const entry = data.apiKeyUsers.find((e) => e.user.id === 'u-regular') expect(entry).toBeTruthy() expect(entry.activeCount).toBe(1) expect(entry.revokedCount).toBe(0) expect(entry).toHaveProperty('postsCount') expect(entry).toHaveProperty('commentsCount') }) it('counts active and revoked keys correctly', async () => { // Revoke one of regular's keys authenticatedUser = (await adminUser.toJson()) as Context['user'] await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) const { data } = await query({ query: apiKeyUsers, variables: { first: 10, offset: 0 } }) const entry = data.apiKeyUsers.find((e) => e.user.id === 'u-regular') expect(entry.activeCount).toBe(0) expect(entry.revokedCount).toBe(1) }) it('supports pagination', async () => { authenticatedUser = (await adminUser.toJson()) as Context['user'] const { data } = await query({ query: apiKeyUsers, variables: { first: 1, offset: 0 } }) expect(data.apiKeyUsers).toHaveLength(1) }) }) describe('apiKeysForUser', () => { it('non-admin cannot access', async () => { authenticatedUser = (await regularUser.toJson()) as Context['user'] const { errors } = await query({ query: apiKeysForUser, variables: { userId: 'u-regular' }, }) expect(errors?.[0]).toHaveProperty('message', 'Not Authorized!') }) it('admin sees all keys for a user', async () => { authenticatedUser = (await adminUser.toJson()) as Context['user'] const { data, errors } = await query({ query: apiKeysForUser, variables: { userId: 'u-regular' }, }) expect(errors).toBeUndefined() expect(data.apiKeysForUser).toHaveLength(1) data.apiKeysForUser.forEach((k) => { expect(k).toHaveProperty('id') expect(k).toHaveProperty('name') expect(k).toHaveProperty('keyPrefix') expect(k).toHaveProperty('disabled') expect(k).toHaveProperty('disabledAt') expect(k).toHaveProperty('lastUsedAt') expect(k).toHaveProperty('expiresAt') }) }) it('returns active keys before revoked keys', async () => { // Create a second key so we have one active + one revoked authenticatedUser = (await regularUser.toJson()) as Context['user'] await mutate({ mutation: createApiKey, variables: { name: 'Key 2' } }) authenticatedUser = (await adminUser.toJson()) as Context['user'] await mutate({ mutation: adminRevokeApiKey, variables: { id: keyId } }) const { data } = await query({ query: apiKeysForUser, variables: { userId: 'u-regular' } }) expect(data.apiKeysForUser).toHaveLength(2) expect(data.apiKeysForUser[0].disabled).toBe(false) expect(data.apiKeysForUser[1].disabled).toBe(true) }) it('returns empty array for user without keys', async () => { authenticatedUser = (await adminUser.toJson()) as Context['user'] const { data } = await query({ query: apiKeysForUser, variables: { userId: 'u-admin' } }) expect(data.apiKeysForUser).toEqual([]) }) }) })