feat(18-01): write-only settings secrets, richeditor and tabs on settings forms (task 3)
- type: password on a settings form binds a lagoon.Encrypted column as a write-only secret (any other column fails boot naming the field): the GET omits it from data and reports stored, env or empty per field in meta.secrets; on PUT an absent key or "" keeps the value, null clears it, a non-string is a 422; the answer never carries a value - cabana.SecretFallbacks (field to config key) gives the env state; cabana.SettingsMeta, SecretStored/SecretEnv/SecretEmpty exported - settings forms accept richeditor: the Put sanitizes through seaglass like a controller save and returns meta.notices; repeater and dependsOn stay refused (settingsFormOnly next to controllerFormOnly) - backend::lang secret.* phrases in en and pl - admin SPA: PasswordField secret status line (stored with Remove stored value, env, empty, pending clear with Undo), SettingsFormView tabs with error-tab activation and the secrets/notices meta, settings payload that never sends an untouched secret; regenerated OpenAPI and TS types; rebuilt dist - vault fixture, TestSettingsSecretCompile, Docker TestSettingsSecretSmoke and TestSettingsRichEditorSmoke, conform settings fixture with a password field; settings.md sections, forms.md notes, cabana README
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
// application names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import PasswordField from '../../src/components/form/fields/PasswordField.vue'
|
||||
import { FORM_SESSION, type FormSession } from '../../src/components/form/formContext'
|
||||
import { FORM_SESSION, SETTINGS_SECRETS, type FormSession } from '../../src/components/form/formContext'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
@@ -114,3 +114,92 @@ describe('password field component (UI-SPEC S7, D-19)', () => {
|
||||
expect(input(plain.wrapper).attributes('required')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings secret states (UI-SPEC F2, D-13)', () => {
|
||||
const secretStrings = {
|
||||
'backend::lang.secret.stored': { other: 'A value is stored. Leave the field empty to keep it.' },
|
||||
'backend::lang.secret.env': { other: 'Nothing is stored here, so the value from the server configuration is used.' },
|
||||
'backend::lang.secret.empty': { other: 'Not set.' },
|
||||
'backend::lang.secret.clear': { other: 'Remove stored value' },
|
||||
'backend::lang.secret.clear_pending': { other: 'The stored value will be removed when you save.' },
|
||||
'backend::lang.secret.undo': { other: 'Keep stored value' },
|
||||
'backend::lang.secret.placeholder_stored': { other: 'Enter a new value to replace it' },
|
||||
}
|
||||
|
||||
function mountSecret(state: string, modelValue: unknown = '') {
|
||||
const secrets = ref<Record<string, string>>({ api_key: state })
|
||||
const wrapper = mount(PasswordField, {
|
||||
props: { modelValue, controlId: 'f-api_key', field: { name: 'api_key', type: 'password', label: 'API key' } as FormField },
|
||||
global: { provide: { [SETTINGS_SECRETS as symbol]: computed(() => secrets.value) } },
|
||||
attachTo: document.body,
|
||||
})
|
||||
return { wrapper, secrets }
|
||||
}
|
||||
|
||||
const line = (wrapper: VueWrapper) => wrapper.find('[data-secret-state]')
|
||||
|
||||
beforeEach(() => {
|
||||
setBundle({ ...langFixture.data, ...strings, ...secretStrings }, 'en')
|
||||
})
|
||||
|
||||
it('renders no status line on a record form (no state provided)', () => {
|
||||
const { wrapper } = mountField()
|
||||
expect(line(wrapper).exists()).toBe(false)
|
||||
expect(input(wrapper).attributes('aria-describedby')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders the stored, env and empty lines with the input always empty', () => {
|
||||
const stored = mountSecret('stored')
|
||||
expect(input(stored.wrapper).element.value).toBe('')
|
||||
expect(line(stored.wrapper).attributes('data-secret-state')).toBe('stored')
|
||||
expect(line(stored.wrapper).text()).toContain('A value is stored.')
|
||||
expect(line(stored.wrapper).find('[data-action="secret-clear"]').text()).toBe('Remove stored value')
|
||||
expect(input(stored.wrapper).attributes('placeholder')).toBe('Enter a new value to replace it')
|
||||
expect(input(stored.wrapper).attributes('aria-describedby')).toContain('f-api_key-secret')
|
||||
stored.wrapper.unmount()
|
||||
|
||||
const env = mountSecret('env')
|
||||
expect(line(env.wrapper).attributes('data-secret-state')).toBe('env')
|
||||
expect(line(env.wrapper).text()).toContain('server configuration')
|
||||
expect(line(env.wrapper).find('[data-action="secret-clear"]').exists()).toBe(false)
|
||||
env.wrapper.unmount()
|
||||
|
||||
const empty = mountSecret('empty')
|
||||
expect(line(empty.wrapper).attributes('data-secret-state')).toBe('empty')
|
||||
expect(line(empty.wrapper).text()).toBe('Not set.')
|
||||
expect(line(empty.wrapper).find('button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not emit for an untouched field, emits null on Remove stored value and "" on Undo', async () => {
|
||||
const { wrapper } = mountSecret('stored')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
await line(wrapper).find('[data-action="secret-clear"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
|
||||
await wrapper.setProps({ modelValue: null })
|
||||
const pending = line(wrapper)
|
||||
expect(pending.attributes('data-secret-state')).toBe('clearing')
|
||||
expect(pending.attributes('role')).toBe('status')
|
||||
expect(pending.classes()).toContain('text-danger')
|
||||
expect(pending.text()).toContain('will be removed when you save')
|
||||
expect(input(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(input(wrapper).element.value).toBe('')
|
||||
await pending.find('[data-action="secret-undo"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[null], ['']])
|
||||
await wrapper.setProps({ modelValue: '' })
|
||||
expect(line(wrapper).attributes('data-secret-state')).toBe('stored')
|
||||
expect(input(wrapper).attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never shows the typed value after the save response replaces it', async () => {
|
||||
const { wrapper, secrets } = mountSecret('empty')
|
||||
await input(wrapper).setValue('sk_live_123')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['sk_live_123']])
|
||||
// The save answers: the value is gone from the model, the state is stored.
|
||||
await wrapper.setProps({ modelValue: undefined })
|
||||
secrets.value = { api_key: 'stored' }
|
||||
await nextTick()
|
||||
expect(input(wrapper).element.value).toBe('')
|
||||
expect(wrapper.html()).not.toContain('sk_live_123')
|
||||
expect(line(wrapper).attributes('data-secret-state')).toBe('stored')
|
||||
})
|
||||
})
|
||||
|
||||
152
admin/tests/views/SettingsFormView.test.ts
Normal file
152
admin/tests/views/SettingsFormView.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// 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<string, string>): Reply {
|
||||
const envelope = clone(settingsFixture.values)
|
||||
envelope.data.meta = { secrets }
|
||||
return { body: envelope }
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
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<string, Route> = {}) {
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
expect(body.test_api_key).toBe('sk_test_9')
|
||||
expect(wrapper.find<HTMLInputElement>('[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<string, unknown>
|
||||
expect(body.api_key).toBeNull()
|
||||
expect(wrapper.find('[data-field="api_key"] [data-secret-state]').attributes('data-secret-state')).toBe('empty')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user