// The settings page with tabs and write-only secrets (Phase 18, UI-SPEC F2 // to F4). Fixtures are neutral acme.demo.* data; no application names // appear in framework tests. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils' import { setBundle } from '../../src/app/i18n' import { setSettings } from '../../src/state/useSettings' import { clone, langFixture, settingsFixture } from '../fixtures/typed' import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers' const PAGE = `${API}/settings/search` const strings = { 'backend::lang.form.tab_default': { other: 'General' }, 'backend::lang.form.tab_errors': { one: ':count error', other: ':count errors' }, 'backend::lang.secret.stored': { other: 'A value is stored.' }, 'backend::lang.secret.env': { other: 'From the server configuration.' }, 'backend::lang.secret.empty': { other: 'Not set.' }, 'backend::lang.secret.clear': { other: 'Remove stored value' }, 'backend::lang.secret.clear_pending': { other: 'Removed when you save.' }, 'backend::lang.secret.undo': { other: 'Keep stored value' }, 'backend::lang.settings.saved': { other: 'Settings saved' }, } function tabbedSchema() { const envelope = clone(settingsFixture.schema) envelope.data.fields = [ { name: 'use_engine', label: 'Use the search engine', type: 'switch' }, { name: 'engine_host', label: 'Engine host', type: 'text', tab: 'Connection' }, { name: 'api_key', label: 'API key', type: 'password', tab: 'Keys' }, { name: 'test_api_key', label: 'Test API key', type: 'password', tab: 'Keys' }, ] as typeof envelope.data.fields return envelope } function valuesWith(secrets: Record): Reply { const envelope = clone(settingsFixture.values) envelope.data.meta = { secrets } return { body: envelope } } function routes(overrides: Record = {}): Record { return { [`GET ${API}/settings`]: { body: settingsFixture.list }, [`GET ${PAGE}/schema`]: { body: tabbedSchema() }, [`GET ${PAGE}`]: valuesWith({ api_key: 'stored', test_api_key: 'empty' }), ...overrides, } } async function openPage(overrides: Record = {}) { setSettings(settingsFixture.list.data) return mountApp('/settings/search', routes(overrides)) } const tabLabels = (wrapper: VueWrapper) => wrapper.findAll('[role="tab"]').map((tab) => tab.text().replace(/\d+ errors?$/, '').trim()) const activeTab = (wrapper: VueWrapper) => wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true') const fieldNames = (wrapper: VueWrapper) => wrapper.findAll('[data-field]').map((field) => field.attributes('data-field')) beforeEach(() => { resetState() setBundle({ ...langFixture.data, ...strings }, 'en') }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('settings tabs (UI-SPEC F4)', () => { it('renders no tab strip for an untabbed schema', async () => { const { wrapper } = await openPage({ [`GET ${PAGE}/schema`]: { body: settingsFixture.schema } }) expect(wrapper.find('[role="tablist"]').exists()).toBe(false) expect(wrapper.find('form').attributes('role')).toBeUndefined() expect(fieldNames(wrapper)).toEqual(['use_engine', 'engine_host']) }) it('renders the tabs with the default tab first and the card inside the panel', async () => { const { wrapper } = await openPage() expect(tabLabels(wrapper)).toEqual(['General', 'Connection', 'Keys']) expect(activeTab(wrapper)!.text()).toBe('General') expect(fieldNames(wrapper)).toEqual(['use_engine']) expect(wrapper.find('form').attributes('role')).toBe('tabpanel') expect(wrapper.find('form').attributes('aria-labelledby')).toBe(activeTab(wrapper)!.attributes('id')) await wrapper.findAll('[role="tab"]')[2]!.trigger('click') expect(fieldNames(wrapper)).toEqual(['api_key', 'test_api_key']) }) it('activates the first tab with an error after a 422 and shows its error chip', async () => { const { wrapper } = await openPage({ [`PUT ${PAGE}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Invalid', details: { engine_host: ['Bad host'] } } } }, }) await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() expect(activeTab(wrapper)!.text()).toContain('Connection') expect(activeTab(wrapper)!.find('[data-tab-errors]').text()).toBe('1') expect(fieldNames(wrapper)).toEqual(['engine_host']) expect(wrapper.find('[data-field="engine_host"]').text()).toContain('Bad host') }) }) describe('settings secrets (UI-SPEC F2, D-13)', () => { it('shows each secret state and sends neither an untouched secret nor its empty input', async () => { const { wrapper, calls } = await openPage({ [`PUT ${PAGE}`]: valuesWith({ api_key: 'stored', test_api_key: 'empty' }) }) await wrapper.findAll('[role="tab"]')[2]!.trigger('click') expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('stored') expect(wrapper.find('[data-field="test_api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('empty') await wrapper.find('[data-field="test_api_key"] input').setValue('') await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const sent = requestsTo(calls, 'PUT', '/admin-test/api/v1/settings/search') expect(sent).toHaveLength(1) const body = (await sent[0]!.clone().json()) as Record expect(body).not.toHaveProperty('api_key') expect(body).not.toHaveProperty('test_api_key') expect(body).toHaveProperty('engine_host') }) it('sends a typed secret once, then clears the input and shows the stored line from the response', async () => { const { wrapper, calls } = await openPage({ [`PUT ${PAGE}`]: valuesWith({ api_key: 'stored', test_api_key: 'stored' }) }) await wrapper.findAll('[role="tab"]')[2]!.trigger('click') await wrapper.find('[data-field="test_api_key"] input').setValue('sk_test_9') await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const body = (await requestsTo(calls, 'PUT', '/admin-test/api/v1/settings/search')[0]!.clone().json()) as Record expect(body.test_api_key).toBe('sk_test_9') expect(wrapper.find('[data-field="test_api_key"] input').element.value).toBe('') expect(wrapper.find('[data-field="test_api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('stored') expect(wrapper.html()).not.toContain('sk_test_9') expect(document.body.textContent).not.toContain('sk_test_9') }) it('sends null for a removed stored value and restores the line on Undo', async () => { const { wrapper, calls } = await openPage({ [`PUT ${PAGE}`]: valuesWith({ api_key: 'empty', test_api_key: 'empty' }) }) await wrapper.findAll('[role="tab"]')[2]!.trigger('click') await wrapper.find('[data-field="api_key"] [data-action="secret-clear"]').trigger('click') const line = wrapper.find('[data-field="api_key"] [data-secret-state]') expect(line.attributes('data-secret-state')).toBe('clearing') expect(wrapper.find('[data-field="api_key"] input').attributes('disabled')).toBeDefined() await line.find('[data-action="secret-undo"]').trigger('click') expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('stored') await wrapper.find('[data-field="api_key"] [data-action="secret-clear"]').trigger('click') await wrapper.find('[data-action="save"]').trigger('click') await flushPromises() const body = (await requestsTo(calls, 'PUT', '/admin-test/api/v1/settings/search')[0]!.clone().json()) as Record expect(body.api_key).toBeNull() expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('empty') }) })