Ocelot-Social/webapp/pages/settings/api-keys.spec.js

429 lines
14 KiB
JavaScript

import Vue from 'vue'
import { mount } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import ApiKeys from './api-keys.vue'
const localVue = global.localVue
// Override dateTime filter to avoid locale dependency
Vue.filter('dateTime', (value) => value || '')
describe('settings/api-keys.vue', () => {
let wrapper, mocks
const originalClipboard = navigator.clipboard
const mutateMock = jest.fn()
const refetchMock = jest.fn()
afterEach(() => {
if (wrapper) wrapper.destroy()
Object.assign(navigator, { clipboard: originalClipboard })
})
beforeEach(() => {
mutateMock.mockReset()
refetchMock.mockReset()
mocks = {
$t: jest.fn((key) => key),
$env: { API_KEYS_MAX_PER_USER: 5 },
$toast: {
success: jest.fn(),
error: jest.fn(),
},
$apollo: {
mutate: mutateMock,
queries: {
myApiKeys: {
refetch: refetchMock,
},
},
},
}
})
const activeKey = (overrides = {}) => ({
id: 'k1',
name: 'CI Bot',
keyPrefix: 'oak_abc12345',
createdAt: '2026-04-01T00:00:00Z',
lastUsedAt: '2026-04-02T10:00:00Z',
expiresAt: null,
disabled: false,
disabledAt: null,
...overrides,
})
const revokedKey = (overrides = {}) => ({
id: 'k-revoked',
name: 'Old Key',
keyPrefix: 'oak_old12345',
createdAt: '2025-01-01T00:00:00Z',
lastUsedAt: '2025-05-01T00:00:00Z',
expiresAt: null,
disabled: true,
disabledAt: '2025-06-01T00:00:00Z',
...overrides,
})
const Wrapper = (data = {}) => {
return mount(ApiKeys, {
mocks,
localVue,
stubs: {
'nuxt-link': true,
'confirm-modal': true,
'date-time': { template: '<span>{{ dateTime }}</span>', props: ['dateTime'] },
},
data: () => ({
myApiKeys: [],
...data,
}),
})
}
describe('renders', () => {
it('shows create form', () => {
wrapper = Wrapper()
expect(wrapper.find('#api-key-name').exists()).toBe(true)
expect(wrapper.find('#api-key-expiry').exists()).toBe(true)
})
it('shows empty state when no keys exist', () => {
wrapper = Wrapper()
expect(wrapper.text()).toContain('settings.api-keys.list.empty')
})
it('shows active key list', () => {
wrapper = Wrapper({ myApiKeys: [activeKey()] })
expect(wrapper.text()).toContain('CI Bot')
expect(wrapper.text()).toContain('oak_abc12345')
})
it('shows key counter', () => {
wrapper = Wrapper({ myApiKeys: [activeKey(), activeKey({ id: 'k2', name: 'Key 2' })] })
expect(wrapper.text()).toContain('(2/5)')
})
it('shows "never" for keys without lastUsedAt', () => {
wrapper = Wrapper({ myApiKeys: [activeKey({ lastUsedAt: null })] })
expect(wrapper.text()).toContain('settings.api-keys.list.never')
})
it('shows "never-expires" for keys without expiresAt', () => {
wrapper = Wrapper({ myApiKeys: [activeKey()] })
expect(wrapper.text()).toContain('settings.api-keys.list.never-expires')
})
it('shows expired label for expired keys', () => {
wrapper = Wrapper({
myApiKeys: [activeKey({ expiresAt: '2020-01-01T00:00:00Z' })],
})
expect(wrapper.text()).toContain('settings.api-keys.list.expired')
})
})
describe('revoked keys section', () => {
it('is hidden when no revoked keys', () => {
wrapper = Wrapper({ myApiKeys: [activeKey()] })
expect(wrapper.find('.revoked-toggle').exists()).toBe(false)
})
it('shows toggle when revoked keys exist', () => {
wrapper = Wrapper({ myApiKeys: [revokedKey()] })
expect(wrapper.find('.revoked-toggle').exists()).toBe(true)
expect(wrapper.text()).toContain('settings.api-keys.revoked-list.title')
})
it('is collapsed by default', () => {
wrapper = Wrapper({ myApiKeys: [revokedKey()] })
expect(wrapper.find('#revoked-keys-list').exists()).toBe(false)
})
it('expands on toggle click', async () => {
wrapper = Wrapper({ myApiKeys: [revokedKey()] })
await wrapper.find('.revoked-toggle').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find('#revoked-keys-list').exists()).toBe(true)
expect(wrapper.text()).toContain('Old Key')
})
it('shows revoked status label', async () => {
wrapper = Wrapper({ myApiKeys: [revokedKey()] })
await wrapper.find('.revoked-toggle').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('settings.api-keys.list.revoked')
})
})
describe('computed properties', () => {
it('activeKeys filters out disabled keys', () => {
wrapper = Wrapper({ myApiKeys: [activeKey(), revokedKey()] })
expect(wrapper.vm.activeKeys).toHaveLength(1)
expect(wrapper.vm.activeKeys[0].id).toBe('k1')
})
it('revokedKeys filters to disabled keys only', () => {
wrapper = Wrapper({ myApiKeys: [activeKey(), revokedKey()] })
expect(wrapper.vm.revokedKeys).toHaveLength(1)
expect(wrapper.vm.revokedKeys[0].id).toBe('k-revoked')
})
it('maxKeys reads from $env', () => {
wrapper = Wrapper()
expect(wrapper.vm.maxKeys).toBe(5)
})
})
describe('create key', () => {
beforeEach(() => {
mutateMock.mockResolvedValue({
data: {
createApiKey: {
apiKey: activeKey({ id: 'new-key', name: 'My Key' }),
secret: 'oak_fullsecretkey123',
},
},
})
})
it('submit button is disabled when name is empty', () => {
wrapper = Wrapper()
const submitBtn = wrapper.find('button[type="submit"]')
expect(submitBtn.attributes('disabled')).toBeDefined()
})
it('submit button is disabled when limit reached', async () => {
const keys = Array.from({ length: 5 }, (_, i) => activeKey({ id: `k${i}`, name: `Key ${i}` }))
wrapper = Wrapper({ myApiKeys: keys })
await wrapper.setData({ name: 'New Key' })
const submitBtn = wrapper.find('button[type="submit"]')
expect(submitBtn.attributes('disabled')).toBeDefined()
})
it('shows limit warning when max keys reached', () => {
const keys = Array.from({ length: 5 }, (_, i) => activeKey({ id: `k${i}`, name: `Key ${i}` }))
wrapper = Wrapper({ myApiKeys: keys })
expect(wrapper.text()).toContain('settings.api-keys.create.limit-reached')
})
it('does not show limit warning when under limit', () => {
wrapper = Wrapper({ myApiKeys: [activeKey()] })
expect(wrapper.text()).not.toContain('settings.api-keys.create.limit-reached')
})
it('does not count revoked keys toward the limit', async () => {
const keys = [
...Array.from({ length: 4 }, (_, i) => activeKey({ id: `k${i}`, name: `Key ${i}` })),
revokedKey(),
]
wrapper = Wrapper({ myApiKeys: keys })
await wrapper.setData({ name: 'New Key' })
const submitBtn = wrapper.find('button[type="submit"]')
expect(submitBtn.attributes('disabled')).toBeUndefined()
expect(wrapper.text()).not.toContain('settings.api-keys.create.limit-reached')
})
it('calls createApiKey mutation on submit', async () => {
wrapper = Wrapper()
await wrapper.setData({ name: 'My Key' })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mutateMock).toHaveBeenCalledTimes(1)
expect(mutateMock).toHaveBeenCalledWith(
expect.objectContaining({
variables: { name: 'My Key' },
}),
)
})
it('sends expiresInDays as number when selected', async () => {
wrapper = Wrapper()
await wrapper.setData({ name: 'Expiring', expiresInDays: 30 })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mutateMock).toHaveBeenCalledWith(
expect.objectContaining({
variables: { name: 'Expiring', expiresInDays: 30 },
}),
)
})
it('does not send expiresInDays when null', async () => {
wrapper = Wrapper()
await wrapper.setData({ name: 'No Expiry', expiresInDays: null })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mutateMock).toHaveBeenCalledWith(
expect.objectContaining({
variables: { name: 'No Expiry' },
}),
)
})
it('shows secret banner after creation', async () => {
wrapper = Wrapper()
await wrapper.setData({ name: 'My Key' })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.vm.newSecret).toBe('oak_fullsecretkey123')
await flushPromises()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('settings.api-keys.secret.title')
})
it('refetches key list after creation', async () => {
wrapper = Wrapper()
await wrapper.setData({ name: 'My Key' })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(refetchMock).toHaveBeenCalled()
})
it('shows success toast', async () => {
wrapper = Wrapper()
await wrapper.setData({ name: 'My Key' })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.api-keys.create.success')
})
it('resets form after creation', async () => {
wrapper = Wrapper()
await wrapper.setData({ name: 'My Key', expiresInDays: 90 })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.vm.name).toBe('')
expect(wrapper.vm.expiresInDays).toBeNull()
})
it('shows error toast on failure', async () => {
mutateMock.mockRejectedValue(new Error('Maximum of 5 active API keys reached'))
wrapper = Wrapper()
await wrapper.setData({ name: 'Too Many' })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mocks.$toast.error).toHaveBeenCalledWith('Maximum of 5 active API keys reached')
})
it('does not submit when name is empty', async () => {
wrapper = Wrapper()
await wrapper.setData({ name: ' ' })
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(mutateMock).not.toHaveBeenCalled()
})
it('sets creating to true during mutation', async () => {
let resolveMutate
mutateMock.mockReturnValue(
new Promise((resolve) => {
resolveMutate = resolve
}),
)
wrapper = Wrapper()
await wrapper.setData({ name: 'Loading Test' })
const submitPromise = wrapper.find('form').trigger('submit')
await wrapper.vm.$nextTick()
expect(wrapper.vm.creating).toBe(true)
resolveMutate({
data: {
createApiKey: {
apiKey: activeKey({ id: 'new', name: 'Loading Test' }),
secret: 'oak_secret',
},
},
})
await submitPromise
await flushPromises()
expect(wrapper.vm.creating).toBe(false)
})
})
describe('copy secret', () => {
it('copies secret to clipboard on success', async () => {
Object.assign(navigator, {
clipboard: { writeText: jest.fn().mockResolvedValue(undefined) },
})
wrapper = Wrapper()
await wrapper.setData({ newSecret: 'oak_mysecret123' })
await wrapper.vm.copySecret()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('oak_mysecret123')
expect(mocks.$toast.success).toHaveBeenCalledWith('settings.api-keys.secret.copied')
})
it('shows error toast when clipboard fails', async () => {
Object.assign(navigator, {
clipboard: { writeText: jest.fn().mockRejectedValue(new Error('denied')) },
})
wrapper = Wrapper()
await wrapper.setData({ newSecret: 'oak_mysecret123' })
await wrapper.vm.copySecret()
expect(mocks.$toast.error).toHaveBeenCalledWith('settings.api-keys.secret.copy-failed')
})
})
describe('revoke key', () => {
it('opens confirm modal', async () => {
wrapper = Wrapper({ myApiKeys: [activeKey()] })
await wrapper.find('button[aria-label="settings.api-keys.list.revoke"]').trigger('click')
expect(wrapper.vm.showRevokeModal).toBe(true)
expect(wrapper.vm.revokeModalData.messageParams.name).toBe('CI Bot')
})
it('calls revokeApiKey mutation and refetches', async () => {
mutateMock.mockResolvedValue({ data: { revokeApiKey: true } })
wrapper = Wrapper({ myApiKeys: [activeKey()] })
await wrapper.vm.revokeKey({ id: 'k1', name: 'CI Bot' })
await flushPromises()
expect(mutateMock).toHaveBeenCalledWith(expect.objectContaining({ variables: { id: 'k1' } }))
expect(refetchMock).toHaveBeenCalled()
expect(mocks.$toast.success).toHaveBeenCalled()
})
it('sets revokingKeyId during mutation', async () => {
let resolveMutate
mutateMock.mockReturnValue(
new Promise((resolve) => {
resolveMutate = resolve
}),
)
wrapper = Wrapper({ myApiKeys: [activeKey()] })
const revokePromise = wrapper.vm.revokeKey({ id: 'k1', name: 'CI Bot' })
await wrapper.vm.$nextTick()
expect(wrapper.vm.revokingKeyId).toBe('k1')
resolveMutate({ data: { revokeApiKey: true } })
await revokePromise
await flushPromises()
expect(wrapper.vm.revokingKeyId).toBeNull()
})
it('shows error toast on revoke failure', async () => {
mutateMock.mockRejectedValue(new Error('Network error'))
wrapper = Wrapper({ myApiKeys: [activeKey()] })
await expect(wrapper.vm.revokeKey({ id: 'k1', name: 'CI Bot' })).rejects.toThrow(
'Network error',
)
await flushPromises()
expect(mocks.$toast.error).toHaveBeenCalledWith('Network error')
})
})
describe('isExpired', () => {
it('returns true for past expiresAt', () => {
wrapper = Wrapper()
expect(wrapper.vm.isExpired({ expiresAt: '2020-01-01T00:00:00Z' })).toBe(true)
})
it('returns false for future expiresAt', () => {
wrapper = Wrapper()
expect(wrapper.vm.isExpired({ expiresAt: '2099-01-01T00:00:00Z' })).toBe(false)
})
it('returns false for null expiresAt', () => {
wrapper = Wrapper()
expect(wrapper.vm.isExpired({ expiresAt: null })).toBeFalsy()
})
})
})