mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2026-04-06 01:25:31 +00:00
494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
/* 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([])
|
|
})
|
|
})
|
|
})
|