Files
RenewCMS/web/src/views/__tests__/ApiKeysView.spec.ts
T

1182 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UButton from '@nuxt/ui/components/Button.vue'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { ApiKey, CreatedApiKey } from '@/composables/useApiKeys'
import { useSessionStore } from '@/stores/session'
import { initializeLocale, setLocale } from '@/i18n'
import ApiKeysView from '../ApiKeysView.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useToast: () => ({ add: toastAdd }),
}
})
const LIST_PATH = '/v1/api-key'
const FULL_KEY = 'rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe'
const CONNECTION_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
const CREATE_FAILED_COPY = "Couldn't create the key. Try again in a moment."
const REVOKE_FAILED_COPY = "Couldn't revoke the key. Try again in a moment."
const DAY_MS = 24 * 60 * 60 * 1000
function ownerFixture(): ApiKey[] {
return [
{
id: 4,
user_id: 1,
name: 'Website build',
prefix: 'rcms_Kf3x9Qw',
status: 'active',
created_at: '2026-03-12T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: null,
revoked_at: null,
created_by_name: 'Marta Alves',
},
{
id: 2,
user_id: 2,
name: 'Nightly media import',
prefix: 'rcms_Mq7bT2d',
status: 'active',
created_at: '2026-04-02T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: '2026-12-31T12:00:00Z',
revoked_at: null,
created_by_name: 'Jonas',
},
{
id: 3,
user_id: 1,
name: 'Staging preview',
prefix: 'rcms_Zt2dL8a',
status: 'expired',
created_at: '2026-06-14T09:00:00Z',
last_used_at: '2026-08-20T09:00:00Z',
expires_at: '2026-08-30T12:00:00Z',
revoked_at: null,
created_by_name: 'Marta Alves',
},
{
id: 1,
user_id: 3,
name: 'Old migration script',
prefix: 'rcms_Hn5wR4c',
status: 'revoked',
created_at: '2026-04-03T09:00:00Z',
last_used_at: '2026-05-09T09:00:00Z',
expires_at: null,
revoked_at: '2026-05-10T09:00:00Z',
created_by_name: 'Inès Duarte',
},
]
}
function adminFixture(): ApiKey[] {
return [
{
id: 2,
user_id: 2,
name: 'Nightly media import',
prefix: 'rcms_Mq7bT2d',
status: 'active',
created_at: '2026-04-02T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: '2026-12-31T12:00:00Z',
revoked_at: null,
},
{
id: 1,
user_id: 2,
name: 'Old CI token',
prefix: 'rcms_Lp9cE1x',
status: 'revoked',
created_at: '2026-01-18T09:00:00Z',
last_used_at: '2026-02-02T09:00:00Z',
expires_at: null,
revoked_at: '2026-02-03T09:00:00Z',
},
]
}
function createdResponse(
name = 'Nightly media import',
expiresAt: string | null = null,
): CreatedApiKey {
return {
id: 9,
name,
prefix: FULL_KEY.slice(0, 12),
key: FULL_KEY,
created_at: new Date().toISOString(),
expires_at: expiresAt,
}
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function setViewport(desktop: boolean) {
window.matchMedia = ((query: string) => ({
matches: desktop,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
}
let requestMock: ReturnType<typeof vi.fn>
let wrapper: VueWrapper | null = null
interface Handlers {
list?: () => Promise<unknown>
create?: (body: Record<string, unknown>) => Promise<unknown>
revoke?: (path: string) => Promise<unknown>
}
interface MountOptions {
desktop?: boolean
role?: string | null
username?: string
displayName?: string
}
async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
const {
desktop = true,
role = 'owner',
username = 'marta',
displayName = 'Marta Alves',
} = options
setViewport(desktop)
setActivePinia(createPinia())
const session = useSessionStore()
const sessionRole = role ?? undefined
session.session = {
verified: true,
username,
id: 1,
displayName,
role: sessionRole as 'owner' | 'admin' | undefined,
}
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
if (path === LIST_PATH && (init?.method ?? 'GET') === 'GET') {
return (handlers.list ?? (() => Promise.resolve(ownerFixture())))()
}
if (path === LIST_PATH && init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as Record<string, unknown>
return (handlers.create ?? (() => Promise.resolve(createdResponse(String(body.name)))))(
body,
)
}
if (init?.method === 'DELETE') {
return (handlers.revoke ?? (() => Promise.resolve(undefined)))(path)
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username, id: 1, role: sessionRole }),
request: requestMock,
})
await router.push('/integration/api-keys')
const Host = defineComponent({ render: () => h(UApp, null, () => h(ApiKeysView)) })
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function textOf(element: Element) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('tbody tr, [data-testid="api-key-card"]'),
)
}
function rowTexts() {
return rows().map(textOf)
}
function rowOf(name: string) {
return rows().find((row) => row.textContent?.includes(name))!
}
function buttonsIn(root: ParentNode) {
return Array.from(root.querySelectorAll('button'))
}
function buttonIn(root: ParentNode, text: string) {
return buttonsIn(root).find((button) => button.textContent?.trim() === text)
}
function findButton(text: string) {
return buttonIn(document.body, text)
}
function formModal() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
function alertDialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
function modalButton(text: string) {
return buttonIn(formModal()!, text)
}
async function click(button: HTMLElement | undefined) {
button?.click()
await flushPromises()
}
function nameInput() {
return document.body.querySelector<HTMLInputElement>('input[name="name"]')!
}
function expiryInput() {
return document.body.querySelector<HTMLInputElement>('input[type="date"]')!
}
async function typeInto(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
async function openCreateModal(label = 'Create key') {
const opener = findButton(label)!
opener.focus()
opener.click()
await flushPromises()
}
async function fillAndSubmit(name: string, expiry = '', label = 'Create key') {
await typeInto(nameInput(), name)
if (expiry) {
await typeInto(expiryInput(), expiry)
}
await click(modalButton(label))
}
function postCalls() {
return requestMock.mock.calls.filter(([, init]) => init?.method === 'POST')
}
function deleteCalls() {
return requestMock.mock.calls.filter(([, init]) => init?.method === 'DELETE')
}
function listCalls() {
return requestMock.mock.calls.filter(
([path, init]) => path === LIST_PATH && (init?.method ?? 'GET') === 'GET',
)
}
function postedBody() {
return JSON.parse(String(postCalls()[0]![1].body)) as Record<string, unknown>
}
function iconOf(button: HTMLElement) {
return wrapper!
.findAllComponents(UButton)
.find((component) => component.element === button)
?.props('icon')
}
function pressEscape() {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
async function escape() {
pressEscape()
await flushPromises()
}
async function clickBackdrop() {
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
await new Promise((resolve) => setTimeout(resolve, 20))
const down = new MouseEvent('pointerdown', { bubbles: true, button: 0 })
Object.defineProperty(down, 'pointerType', { value: 'mouse' })
overlay.dispatchEvent(down)
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
}
function formatLocalDate(dateValue: string) {
const [year, month, day] = dateValue.split('-').map(Number)
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
return `${day} ${months[month! - 1]} ${year}`
}
function localDate(offsetDays: number) {
const date = new Date(Date.now() + offsetDays * DAY_MS)
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function alertText() {
return document.body.querySelector('[role="alert"]')?.textContent?.trim() ?? ''
}
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
return writeText
}
beforeEach(() => {
toastAdd = vi.fn()
localStorage.clear()
sessionStorage.clear()
initializeLocale()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
document.body.innerHTML = ''
await router.push('/login')
localStorage.clear()
initializeLocale()
})
describe('Owner: list', () => {
it('shows the header, the four rows in fixture order and the note on desktop', async () => {
await mountPage()
const breadcrumb = document.body.querySelector('[data-testid="api-keys-breadcrumb"]')!
expect(textOf(breadcrumb)).toBe('Integration › API keys')
expect(breadcrumb.querySelector('a[href="/integration"]')?.textContent).toContain(
'Integration',
)
expect(document.body.querySelector('h1')?.textContent).toBe('API keys')
expect(document.body.textContent).toContain(
"Keys act as the user who created them. As an owner you see everyone's keys.",
)
expect(findButton('Create key')).toBeTruthy()
expect(document.body.textContent).toContain(
"Resetting a user's password revokes all of their keys.",
)
const header = document.body.querySelector('thead tr')!
expect(textOf(header)).toBe('Name Key Status Created by Created Last used Expires')
expect(rowTexts()).toEqual([
'Website build rcms_Kf3x… active Marta Alves 12 Mar 2026 3 Oct 2026 None Revoke',
'Nightly media import rcms_Mq7b… active Jonas 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
'Staging preview rcms_Zt2d… expired Marta Alves 14 Jun 2026 20 Aug 2026 30 Aug 2026 Revoke',
'Old migration script rcms_Hn5w… revoked Inès Duarte 3 Apr 2026 9 May 2026 None',
])
expect(document.body.textContent).not.toContain('rcms_Kf3x9')
})
it('mutes the revoked row and offers no Revoke on it', async () => {
await mountPage()
const revoked = rowOf('Old migration script')
expect(revoked.className).toContain('bg-muted')
expect(buttonIn(revoked, 'Revoke')).toBeUndefined()
expect(buttonIn(rowOf('Staging preview'), 'Revoke')).toBeTruthy()
expect(rowOf('Website build').className).not.toContain('bg-muted')
})
it('shows an em dash when the creator name is empty', async () => {
const fixture = ownerFixture()
fixture[0]!.created_by_name = ''
await mountPage({ list: () => Promise.resolve(fixture) })
expect(rowTexts()[0]).toContain('rcms_Kf3x… active — 12 Mar 2026')
})
it('renders the same keys as cards below md', async () => {
await mountPage({}, { desktop: false })
expect(rowTexts()).toEqual([
'Website build active rcms_Kf3x… Created 12 Mar 2026 by Marta Alves · Last used 3 Oct 2026 · Expires None Revoke',
'Nightly media import active rcms_Mq7b… Created 2 Apr 2026 by Jonas · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
'Staging preview expired rcms_Zt2d… Created 14 Jun 2026 by Marta Alves · Last used 20 Aug 2026 · Expires 30 Aug 2026 Revoke',
'Old migration script revoked rcms_Hn5w… Created 3 Apr 2026 by Inès Duarte · Last used 9 May 2026 · Expires None',
])
expect(document.body.querySelector('table')).toBeNull()
expect(document.body.querySelector('thead')).toBeNull()
expect(buttonIn(rowOf('Website build'), 'Revoke')!.className).toContain('h-11')
})
it('colors the status badges success, warning and neutral', async () => {
await mountPage()
const badge = (name: string, word: string) =>
Array.from(rowOf(name).querySelectorAll('span')).find(
(el) => el.textContent?.trim() === word,
)!
expect(badge('Website build', 'active').className).toContain('text-success')
expect(badge('Staging preview', 'expired').className).toContain('text-warning')
expect(badge('Old migration script', 'revoked').className).toContain('text-muted')
})
it('gives the Integration sidebar link aria-current on /integration/api-keys', async () => {
await mountPage()
const link = document.body.querySelector('nav[aria-label="Main"] a[href="/integration"]')
expect(link?.getAttribute('aria-current')).toBe('page')
})
})
describe('Admin: own keys only', () => {
it('shows two rows with no Created by column and the admin description', async () => {
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { role: 'admin' })
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
'Name Key Status Created Last used Expires',
)
expect(rowTexts()).toEqual([
'Nightly media import rcms_Mq7b… active 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
'Old CI token rcms_Lp9c… revoked 18 Jan 2026 2 Feb 2026 None',
])
expect(document.body.textContent).toContain(
'Keys act as you, with your role. You only see your own keys.',
)
expect(document.body.textContent).not.toContain('Created by')
})
it('reads the cards without " by {name}" below md', async () => {
await mountPage(
{ list: () => Promise.resolve(adminFixture()) },
{ role: 'admin', desktop: false, username: 'jonas' },
)
expect(rowTexts()[0]).toBe(
'Nightly media import active rcms_Mq7b… Created 2 Apr 2026 · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
)
})
it('renders a session without a role like an admin', async () => {
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { role: null })
expect(document.body.textContent).toContain('You only see your own keys.')
expect(document.body.textContent).not.toContain('Created by')
})
})
describe('Loading', () => {
it('draws four skeleton rows with aria-busy, keeps Create key, then shows the rows', async () => {
const pending = deferred<ApiKey[]>()
await mountPage({ list: () => pending.promise })
const busy = document.body.querySelector('[aria-busy="true"]')!
expect(busy.querySelector('table')).toBeTruthy()
expect(busy.querySelectorAll('tbody tr')).toHaveLength(4)
expect(busy.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(4)
expect(findButton('Create key')).toBeTruthy()
pending.resolve(ownerFixture())
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(rows()).toHaveLength(4)
})
it('draws two-bar cards below md', async () => {
await mountPage({ list: () => deferred<ApiKey[]>().promise }, { desktop: false })
const busy = document.body.querySelector('[aria-busy="true"]')!
expect(busy.querySelectorAll('li')).toHaveLength(4)
expect(busy.querySelectorAll('.animate-pulse')).toHaveLength(8)
})
})
describe('Empty', () => {
it('shows the panel with its own Create key and no table', async () => {
await mountPage({ list: () => Promise.resolve([]) })
expect(document.body.textContent).toContain('No API keys yet')
expect(document.body.textContent).toContain(
'Create a key to call the API from a script or service.',
)
expect(document.body.querySelector('table')).toBeNull()
expect(
buttonsIn(document.body).filter(
(button) => button.textContent?.trim() === 'Create key',
),
).toHaveLength(2)
})
})
describe.each([
{ name: 'status 500', failure: () => new ApiError(500) },
{ name: 'a network error', failure: () => new NetworkError() },
])('Error on load: $name', ({ failure }) => {
it('shows the alert with Try again and nothing else, then recovers', async () => {
let attempts = 0
await mountPage({
list: () =>
++attempts === 1 ? Promise.reject(failure()) : Promise.resolve(ownerFixture()),
})
expect(alertText()).toContain("Couldn't load your API keys")
expect(alertText()).toContain('Check your connection and try again.')
expect(rows()).toHaveLength(0)
expect(document.body.querySelector('table')).toBeNull()
expect(document.body.textContent).not.toContain('No API keys yet')
await click(findButton('Try again'))
expect(listCalls()).toHaveLength(2)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(4)
})
})
describe.each([401, 403])('Session problems: %i', (status) => {
it('leaves the redirect to the API client and shows no alert', async () => {
await mountPage({ list: () => Promise.reject(new ApiError(status)) })
expect(document.body.textContent).not.toContain("Couldn't load your API keys")
expect(document.body.querySelector('[aria-busy="true"]')).toBeTruthy()
})
})
describe('Create key: modal opened', () => {
it('shows the form copy, counter, helps, buttons and focuses Name', async () => {
await mountPage()
await openCreateModal()
const modal = formModal()!
expect(modal.textContent).toContain('Create API key')
expect(modal.textContent).toContain(
'The key acts as you, with your role. It is shown in full once.',
)
expect(modal.textContent).toContain('0 / 60')
expect(modal.textContent).toContain('Required. Up to 60 characters.')
expect(modal.textContent).toContain('Expiry date (optional)')
expect(modal.textContent).toContain(
'Optional. Must be in the future. Leave it empty for a key that does not expire.',
)
expect(modalButton('Cancel')).toBeTruthy()
expect(modalButton('Create key')).toBeTruthy()
expect(document.activeElement).toBe(nameInput())
})
})
describe('Create key: validation', () => {
it.each([
{ name: '', expiry: '2026-03-12', nameError: true, expiryError: true },
{ name: '', expiry: '', nameError: true, expiryError: false },
{ name: 'Deploy', expiry: '2026-03-12', nameError: false, expiryError: true },
])(
'name "$name" and expiry "$expiry" sends nothing',
async ({ name, expiry, nameError, expiryError }) => {
await mountPage()
await openCreateModal()
await fillAndSubmit(name, expiry)
const text = formModal()!.textContent
expect(text?.includes('Enter a name.')).toBe(nameError)
expect(text?.includes('Choose a date in the future.')).toBe(expiryError)
expect(postCalls()).toHaveLength(0)
expect(formModal()).toBeTruthy()
},
)
it('colors the errors red and clears them once fixed', async () => {
await mountPage()
await openCreateModal()
await fillAndSubmit('', '2026-03-12')
const errors = Array.from(formModal()!.querySelectorAll('.text-error')).map((el) =>
el.textContent?.trim(),
)
expect(errors).toContain('Enter a name.')
expect(errors).toContain('Choose a date in the future.')
await typeInto(nameInput(), 'Deploy')
await typeInto(expiryInput(), localDate(5))
expect(formModal()!.textContent).not.toContain('Enter a name.')
expect(formModal()!.textContent).not.toContain('Choose a date in the future.')
})
})
describe('Create key: name too long', () => {
it('turns the counter and the error red at 75 characters and sends nothing', async () => {
await mountPage()
await openCreateModal()
await typeInto(nameInput(), 'x'.repeat(75))
const counter = Array.from(formModal()!.querySelectorAll('span')).findLast(
(el) => el.textContent?.trim() === '75 / 60',
)!
expect(counter.className).toContain('text-error')
expect(formModal()!.textContent).toContain('Use 60 characters or fewer.')
await click(modalButton('Create key'))
expect(postCalls()).toHaveLength(0)
})
})
describe('Create key: success reveals the key once', () => {
it('posts only the name and replaces the form with the reveal', async () => {
await mountPage()
await openCreateModal()
await fillAndSubmit('Nightly media import')
expect(postCalls()).toHaveLength(1)
expect(postCalls()[0]![0]).toBe('/v1/api-key')
expect(postedBody()).toEqual({ name: 'Nightly media import' })
const modal = formModal()!
expect(modal.textContent).toContain('Your new API key')
expect(modal.textContent).not.toContain('Create API key')
expect(modal.textContent).toContain(
'"Nightly media import" is ready. Copy the key and store it somewhere safe.',
)
const warning = modal.querySelector('[role="alert"]')!
expect(warning.textContent).toContain("You won't be able to see this key again.")
expect(modal.textContent).toContain(FULL_KEY)
expect(modal.querySelector('.font-mono')?.textContent?.trim()).toBe(FULL_KEY)
expect(modal.textContent).toContain(
'Closing this window discards the key from the screen. Copy it first.',
)
expect(buttonsIn(modal).map((button) => button.textContent?.trim())).toEqual([
'Copy',
'Done',
])
})
it('puts initial focus on Copy', async () => {
await mountPage()
await openCreateModal()
await fillAndSubmit('Nightly media import')
expect(document.activeElement).toBe(modalButton('Copy'))
})
})
describe('Create key: with an expiry', () => {
it('sends 23:59:59 local on D + 30 days as an RFC3339 instant with the offset', async () => {
await mountPage()
await openCreateModal()
const date = localDate(30)
await fillAndSubmit('temp', date)
const body = postedBody()
expect(body.name).toBe('temp')
const sent = String(body.expires_at)
expect(sent).toMatch(/^\d{4}-\d{2}-\d{2}T23:59:59[+-]\d{2}:\d{2}$/)
expect(sent.startsWith(`${date}T23:59:59`)).toBe(true)
const instant = new Date(sent)
expect([instant.getHours(), instant.getMinutes(), instant.getSeconds()]).toEqual([
23, 59, 59,
])
expect(localDate(0) < date).toBe(true)
})
it.each([
{ label: 'today', offset: 0, sent: true },
{ label: 'yesterday', offset: -1, sent: false },
{ label: 'tomorrow', offset: 1, sent: true },
])('$label: request sent is $sent', async ({ offset, sent }) => {
await mountPage()
await openCreateModal()
await fillAndSubmit('temp', localDate(offset))
expect(postCalls()).toHaveLength(sent ? 1 : 0)
expect(formModal()!.textContent?.includes('Choose a date in the future.')).toBe(!sent)
})
})
describe('One-time reveal: copied', () => {
it('writes the key, shows Copied announced for 2 s, then reverts', async () => {
const writeText = stubClipboard()
await mountPage()
await openCreateModal()
await fillAndSubmit('Nightly media import')
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await click(modalButton('Copy'))
expect(writeText).toHaveBeenCalledWith(FULL_KEY)
const copied = modalButton('Copied')!
expect(copied).toBeTruthy()
expect(copied.className).toContain('text-success')
expect(iconOf(copied)).toBe('i-lucide-check')
expect(copied.getAttribute('aria-live')).toBe('polite')
expect(formModal()!.querySelector('[aria-live="polite"]')?.textContent?.trim()).toBe(
'Copied',
)
vi.advanceTimersByTime(2000)
await flushPromises()
const copy = modalButton('Copy')!
expect(copy).toBeTruthy()
expect(modalButton('Copied')).toBeUndefined()
expect(iconOf(copy)).toBe('i-lucide-copy')
expect(formModal()!.querySelector('[aria-live="polite"]')?.textContent?.trim()).toBe('Copy')
})
})
describe.each([
{ name: 'Done', dismiss: async () => click(modalButton('Done')) },
{ name: 'Escape', dismiss: async () => escape() },
])('One-time reveal: $name discards the key', ({ dismiss }) => {
it('closes, forgets the key everywhere, lists the new key first and makes no second GET', async () => {
await mountPage({
create: (body) =>
Promise.resolve(createdResponse('Nightly media import', String(body.expires_at))),
})
await openCreateModal()
await fillAndSubmit('Nightly media import', localDate(30))
await dismiss()
expect(formModal()).toBeNull()
expect(document.body.innerHTML).not.toContain(FULL_KEY)
expect(wrapper!.html()).not.toContain(FULL_KEY)
expect(router.currentRoute.value.fullPath).not.toContain(FULL_KEY)
expect(JSON.stringify({ ...localStorage })).not.toContain(FULL_KEY)
expect(JSON.stringify({ ...sessionStorage })).not.toContain(FULL_KEY)
expect(rows()).toHaveLength(5)
const first = textOf(rows()[0]!)
expect(first).toContain('Nightly media import rcms_Kf3x… active Marta Alves')
expect(first).toContain('Never')
expect(first).toContain(formatLocalDate(localDate(30)))
expect(first).not.toContain('None')
expect(first).toContain('Revoke')
expect(listCalls()).toHaveLength(1)
await openCreateModal()
expect(nameInput().value).toBe('')
expect(expiryInput().value).toBe('')
})
})
describe('One-time reveal: Done lists an empty expiry as None', () => {
it('shows None in the Expires cell of the new row', async () => {
await mountPage()
await openCreateModal()
await fillAndSubmit('Nightly media import')
await click(modalButton('Done'))
expect(textOf(rows()[0]!)).toContain('active Marta Alves')
expect(textOf(rows()[0]!)).toContain('Never None Revoke')
})
})
describe('Dismissing the modals', () => {
it('keeps the reveal open on a backdrop click', async () => {
await mountPage()
await openCreateModal()
await fillAndSubmit('Nightly media import')
await clickBackdrop()
expect(formModal()!.textContent).toContain('Your new API key')
})
it('keeps the Create key form open on a backdrop click', async () => {
await mountPage()
await openCreateModal()
await clickBackdrop()
expect(formModal()).toBeTruthy()
})
it('closes the Create key form on Escape with no request', async () => {
await mountPage()
await openCreateModal()
await escape()
expect(formModal()).toBeNull()
expect(postCalls()).toHaveLength(0)
})
})
describe('Backdrop helper control', () => {
it('closes the revoke dialog, which is dismissible', async () => {
await mountPage()
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
await clickBackdrop()
expect(alertDialog()).toBeNull()
expect(deleteCalls()).toHaveLength(0)
})
})
describe('Create key: Cancel discards the form', () => {
it('closes with no request, returns focus to the opener and reopens empty', async () => {
await mountPage()
await openCreateModal()
await typeInto(nameInput(), 'Draft name')
await typeInto(expiryInput(), localDate(5))
await click(modalButton('Cancel'))
expect(formModal()).toBeNull()
expect(postCalls()).toHaveLength(0)
expect(document.activeElement).toBe(findButton('Create key'))
await openCreateModal()
expect(nameInput().value).toBe('')
expect(expiryInput().value).toBe('')
})
})
describe('Revoke: confirm', () => {
it('opens the dialog with the 9-character prefix and both buttons', async () => {
await mountPage()
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
const dialog = alertDialog()!
expect(dialog.textContent).toContain('Revoke this key?')
expect(dialog.textContent).toContain('(rcms_Mq7b…)')
expect(dialog.textContent).not.toContain('rcms_Mq7bT2d')
expect(dialog.textContent).toContain(
'"Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can\'t be undone.',
)
expect(buttonsIn(dialog).map((button) => button.textContent?.trim())).toEqual([
'Keep key',
'Revoke key',
])
})
it('closes on Keep key with no request and the row unchanged', async () => {
await mountPage()
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
await click(buttonIn(alertDialog()!, 'Keep key'))
expect(alertDialog()).toBeNull()
expect(deleteCalls()).toHaveLength(0)
expect(textOf(rowOf('Nightly media import'))).toContain('active')
})
})
describe('Revoked', () => {
it('sends one DELETE, keeps the row as revoked and muted, toasts and makes no second GET', async () => {
await mountPage()
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
await click(buttonIn(alertDialog()!, 'Revoke key'))
expect(deleteCalls()).toHaveLength(1)
expect(deleteCalls()[0]![0]).toBe('/v1/api-key/2')
expect(alertDialog()).toBeNull()
const row = rowOf('Nightly media import')
expect(textOf(row)).toContain('revoked')
expect(row.className).toContain('bg-muted')
expect(buttonIn(row, 'Revoke')).toBeUndefined()
expect(listCalls()).toHaveLength(1)
expect(toastAdd).toHaveBeenCalledTimes(1)
const toast = toastAdd.mock.calls[0]![0]
expect(toast.color).toBe('success')
expect(toast.title).toBe('Key revoked')
expect(toast.description).toBe('"Nightly media import" can no longer be used.')
})
})
describe('Mobile Create key button, not drawn on board', () => {
it.each([
{ name: 'list', list: () => Promise.resolve(ownerFixture()) },
{ name: 'loading', list: () => deferred<ApiKey[]>().promise },
{ name: 'error', list: () => Promise.reject(new ApiError(500)) },
])('is pinned in a sticky footer, full width and 48 px in $name with no header button', async ({ list }) => {
await mountPage({ list }, { desktop: false })
const buttons = buttonsIn(document.body).filter(
(button) => button.textContent?.trim() === 'Create key',
)
expect(buttons).toHaveLength(1)
expect(buttons[0]!.parentElement!.className).toContain('sticky')
expect(buttons[0]!.className).toContain('h-12')
expect(buttons[0]!.className).toContain('w-full')
expect(buttons[0]!.parentElement!.style.display).not.toBe('none')
})
it('is not drawn in the Empty state', async () => {
await mountPage({ list: () => Promise.resolve([]) }, { desktop: false })
const buttons = buttonsIn(document.body).filter(
(button) => button.textContent?.trim() === 'Create key',
)
expect(buttons).toHaveLength(1)
expect(buttons[0]!.parentElement!.className).not.toContain('sticky')
})
it('is hidden while a modal is open', async () => {
await mountPage({}, { desktop: false })
await openCreateModal()
const pinned = buttonsIn(document.body).find(
(button) =>
button.textContent?.trim() === 'Create key' &&
button.parentElement!.className.includes('sticky'),
)!
expect(pinned.parentElement!.style.display).toBe('none')
})
})
describe('Not drawn: Create sending', () => {
it('reads Creating…, disables the fields and Cancel and ignores Escape', async () => {
const pending = deferred<CreatedApiKey>()
await mountPage({ create: () => pending.promise })
await openCreateModal()
await fillAndSubmit('Nightly media import')
expect(modalButton('Creating…')).toBeTruthy()
expect(nameInput().disabled).toBe(true)
expect(expiryInput().disabled).toBe(true)
expect(modalButton('Cancel')!.disabled).toBe(true)
await escape()
expect(formModal()).toBeTruthy()
pending.resolve(createdResponse())
await flushPromises()
expect(formModal()!.textContent).toContain('Your new API key')
})
})
describe('Not drawn: Create failed', () => {
it.each([
{ name: 'status 500', failure: () => new ApiError(500), copy: CREATE_FAILED_COPY },
{ name: 'a network error', failure: () => new NetworkError(), copy: CONNECTION_COPY },
{
name: 'status 429',
failure: () => new ApiError(429),
copy: 'Too many requests. Wait a moment, then try again.',
},
{
name: 'status 400',
failure: () => new ApiError(400),
copy: "Something in the form isn't valid. Check the highlighted fields.",
},
])(
'keeps the values, re-enables the form and retries after $name',
async ({ failure, copy }) => {
let attempts = 0
await mountPage({
create: () =>
++attempts === 1
? Promise.reject(failure())
: Promise.resolve(createdResponse()),
})
await openCreateModal()
await fillAndSubmit('Nightly media import')
expect(formModal()!.querySelector('[role="alert"]')?.textContent?.trim()).toBe(copy)
expect(nameInput().value).toBe('Nightly media import')
expect(nameInput().disabled).toBe(false)
expect(modalButton('Create key')!.disabled).toBe(false)
expect(rows()).toHaveLength(4)
expect(formModal()!.textContent).not.toContain('Enter a name.')
expect(formModal()!.textContent).not.toContain('Use 60 characters or fewer.')
await click(modalButton('Create key'))
expect(formModal()!.textContent).toContain('Your new API key')
await click(modalButton('Done'))
expect(rows()).toHaveLength(5)
},
)
})
describe('Not drawn: Revoke sending', () => {
it('reads Revoking…, disables both buttons and ignores Escape', async () => {
const pending = deferred<undefined>()
await mountPage({ revoke: () => pending.promise })
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
await click(buttonIn(alertDialog()!, 'Revoke key'))
const dialog = alertDialog()!
expect(buttonIn(dialog, 'Revoking…')!.disabled).toBe(true)
expect(buttonIn(dialog, 'Keep key')!.disabled).toBe(true)
await escape()
expect(alertDialog()).toBeTruthy()
pending.resolve(undefined)
await flushPromises()
expect(alertDialog()).toBeNull()
})
})
describe.each([
{ name: 'status 500', failure: () => new ApiError(500), copy: REVOKE_FAILED_COPY },
{ name: 'a network error', failure: () => new NetworkError(), copy: CONNECTION_COPY },
])('Not drawn: Revoke failed: $name', ({ failure, copy }) => {
it('shows the notice in the open dialog, leaves the row and lets a retry succeed', async () => {
let attempts = 0
await mountPage({
revoke: () =>
++attempts === 1 ? Promise.reject(failure()) : Promise.resolve(undefined),
})
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
await click(buttonIn(alertDialog()!, 'Revoke key'))
expect(alertDialog()!.querySelector('[role="alert"]')?.textContent?.trim()).toBe(copy)
expect(buttonIn(alertDialog()!, 'Revoke key')!.disabled).toBe(false)
expect(buttonIn(alertDialog()!, 'Keep key')!.disabled).toBe(false)
expect(textOf(rowOf('Nightly media import'))).toContain('active')
expect(toastAdd).not.toHaveBeenCalled()
await click(buttonIn(alertDialog()!, 'Revoke key'))
expect(alertDialog()).toBeNull()
expect(textOf(rowOf('Nightly media import'))).toContain('revoked')
})
})
describe('Not drawn: Revoke 404', () => {
it('closes the dialog, removes the row and toasts an error', async () => {
await mountPage({ revoke: () => Promise.reject(new ApiError(404)) })
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
await click(buttonIn(alertDialog()!, 'Revoke key'))
expect(alertDialog()).toBeNull()
expect(rows()).toHaveLength(3)
expect(document.body.textContent).not.toContain('Nightly media import')
const toast = toastAdd.mock.calls[0]![0]
expect(toast.color).toBe('error')
expect(toast.title).toBe('That key no longer exists.')
})
})
describe('Not drawn: Last used, never', () => {
it('reads Never in the cell and on the card', async () => {
const fixture = ownerFixture()
fixture[1]!.last_used_at = null
await mountPage({ list: () => Promise.resolve(fixture) })
expect(textOf(rowOf('Nightly media import'))).toContain('Never')
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
await mountPage({ list: () => Promise.resolve(fixture) }, { desktop: false })
expect(textOf(rowOf('Nightly media import'))).toContain('Last used Never')
})
})
describe('Language: every string has a French entry', () => {
function frenchText() {
return document.body.textContent ?? ''
}
it('translates the list, the cards and the dates', async () => {
setLocale('fr')
await mountPage()
expect(frenchText()).toContain('Clés d’API')
expect(frenchText()).toContain('Les clés agissent comme l’utilisateur qui les a créées.')
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
'Nom Clé Statut Créée par Créé Dernière utilisation Expire le',
)
expect(rowTexts()[0]).toContain('active')
expect(rowTexts()[2]).toContain('expirée')
expect(rowTexts()[3]).toContain('révoquée')
expect(rowTexts()[0]).toContain('12 mars 2026')
expect(rowTexts()[0]).toContain('Aucune')
expect(frenchText()).toContain('Créer une clé')
expect(frenchText()).toContain('Réinitialiser le mot de passe d’un utilisateur')
expect(frenchText()).not.toContain('Revoke')
})
it('translates the empty panel and the create form', async () => {
setLocale('fr')
await mountPage({ list: () => Promise.resolve([]) })
expect(frenchText()).toContain('Aucune clé d’API pour l’instant')
expect(frenchText()).toContain('Créez une clé pour appeler l’API')
await openCreateModal('Créer une clé')
expect(formModal()!.textContent).toContain('Créer une clé d’API')
expect(formModal()!.textContent).toContain('Obligatoire. 60 caractères maximum.')
expect(formModal()!.textContent).toContain('Date d’expiration (facultative)')
expect(modalButton('Annuler')).toBeTruthy()
await fillAndSubmit('', '', 'Créer une clé')
expect(formModal()!.textContent).toContain('Saisissez un nom.')
})
it('translates the reveal, the revoke dialog and the toast', async () => {
setLocale('fr')
await mountPage()
await openCreateModal('Créer une clé')
await fillAndSubmit('Nightly media import', '', 'Créer une clé')
expect(formModal()!.textContent).toContain('Votre nouvelle clé d’API')
expect(formModal()!.textContent).toContain('Vous ne pourrez plus revoir cette clé.')
expect(modalButton('Copier')).toBeTruthy()
expect(modalButton('Terminé')).toBeTruthy()
await click(modalButton('Terminé'))
await click(buttonIn(rowOf('Website build'), 'Révoquer'))
expect(alertDialog()!.textContent).toContain('Révoquer cette clé ?')
expect(buttonIn(alertDialog()!, 'Garder la clé')).toBeTruthy()
await click(buttonIn(alertDialog()!, 'Révoquer la clé'))
const toast = toastAdd.mock.calls[0]![0]
expect(toast.title).toBe('Clé révoquée')
expect(toast.description).toBe('« Website build » ne peut plus être utilisée.')
})
})