- 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
206 lines
9.8 KiB
TypeScript
206 lines
9.8 KiB
TypeScript
// Password control (UI-SPEC S7, D-19), the component on its own. No
|
|
// application names appear in framework tests.
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
|
|
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, SETTINGS_SECRETS, type FormSession } from '../../src/components/form/formContext'
|
|
import { langFixture } from '../fixtures/typed'
|
|
import { resetState } from '../helpers'
|
|
|
|
const strings = {
|
|
'backend::lang.form.show_password': { other: 'Show password' },
|
|
'backend::lang.form.hide_password': { other: 'Hide password' },
|
|
}
|
|
|
|
function mountField(props: Record<string, unknown> = {}, revision = ref(0)) {
|
|
const field = { name: 'password', type: 'password', label: 'Password', ...(props.field as object) } as FormField
|
|
const wrapper = mount(PasswordField, {
|
|
props: { modelValue: '', controlId: 'f-password', ...props, field },
|
|
global: { provide: { [FORM_SESSION as symbol]: { revision } as unknown as FormSession } },
|
|
attachTo: document.body,
|
|
})
|
|
return { wrapper, revision }
|
|
}
|
|
|
|
const input = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('input')
|
|
const toggle = (wrapper: VueWrapper) => wrapper.find('[data-password-toggle]')
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
setBundle({ ...langFixture.data, ...strings }, 'en')
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
describe('password field component (UI-SPEC S7, D-19)', () => {
|
|
it('is an empty, masked input that asks the browser for a new password', () => {
|
|
const { wrapper } = mountField()
|
|
expect(input(wrapper).attributes('type')).toBe('password')
|
|
expect(input(wrapper).element.value).toBe('')
|
|
expect(input(wrapper).attributes('id')).toBe('f-password')
|
|
expect(input(wrapper).attributes('name')).toBe('password')
|
|
expect(input(wrapper).attributes('autocomplete')).toBe('new-password')
|
|
expect(input(wrapper).attributes('spellcheck')).toBe('false')
|
|
})
|
|
|
|
it('never shows a value that is not a string', () => {
|
|
for (const modelValue of [null, undefined, 12345, { hash: '$2a$10$abc' }, ['x']]) {
|
|
const { wrapper } = mountField({ modelValue })
|
|
expect(input(wrapper).element.value).toBe('')
|
|
wrapper.unmount()
|
|
}
|
|
})
|
|
|
|
it('emits what is typed and does not keep a copy of its own', async () => {
|
|
const { wrapper } = mountField()
|
|
await input(wrapper).setValue('s3cret-pass')
|
|
expect(wrapper.emitted('update:modelValue')).toEqual([['s3cret-pass']])
|
|
// The control is driven by modelValue: the host clears it after a save.
|
|
await wrapper.setProps({ modelValue: 's3cret-pass' })
|
|
await wrapper.setProps({ modelValue: '' })
|
|
expect(input(wrapper).element.value).toBe('')
|
|
})
|
|
|
|
it('toggles between hidden and visible text and says so with aria-pressed', async () => {
|
|
const { wrapper } = mountField({ modelValue: 'visible-text' })
|
|
expect(toggle(wrapper).attributes('type')).toBe('button')
|
|
expect(toggle(wrapper).attributes('aria-pressed')).toBe('false')
|
|
expect(toggle(wrapper).attributes('aria-label')).toBe('Show password')
|
|
await toggle(wrapper).trigger('click')
|
|
expect(input(wrapper).attributes('type')).toBe('text')
|
|
expect(toggle(wrapper).attributes('aria-pressed')).toBe('true')
|
|
expect(toggle(wrapper).attributes('aria-label')).toBe('Hide password')
|
|
await toggle(wrapper).trigger('click')
|
|
expect(input(wrapper).attributes('type')).toBe('password')
|
|
expect(toggle(wrapper).attributes('aria-pressed')).toBe('false')
|
|
// The toggle changes no value.
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
})
|
|
|
|
it('returns to hidden when the form session moves on (after a save)', async () => {
|
|
const { wrapper, revision } = mountField({ modelValue: 'visible-text' })
|
|
await toggle(wrapper).trigger('click')
|
|
expect(input(wrapper).attributes('type')).toBe('text')
|
|
revision.value += 1
|
|
await nextTick()
|
|
expect(input(wrapper).attributes('type')).toBe('password')
|
|
expect(toggle(wrapper).attributes('aria-pressed')).toBe('false')
|
|
})
|
|
|
|
it('works without a form session', async () => {
|
|
const wrapper = mount(PasswordField, {
|
|
props: { field: { name: 'password', type: 'password' } as FormField, modelValue: '', controlId: 'x' },
|
|
})
|
|
await wrapper.find('[data-password-toggle]').trigger('click')
|
|
expect(wrapper.find('input').attributes('type')).toBe('text')
|
|
})
|
|
|
|
it('marks required and invalid, and points at its comment and error lines', () => {
|
|
const { wrapper } = mountField({ field: { required: true }, invalid: true, describedBy: 'f-password-error' })
|
|
expect(input(wrapper).attributes('required')).toBeDefined()
|
|
expect(input(wrapper).attributes('aria-required')).toBe('true')
|
|
expect(input(wrapper).attributes('aria-invalid')).toBe('true')
|
|
expect(input(wrapper).attributes('aria-describedby')).toBe('f-password-error')
|
|
expect(input(wrapper).classes()).toContain('border-danger')
|
|
const plain = mountField()
|
|
expect(input(plain.wrapper).attributes('aria-invalid')).toBeUndefined()
|
|
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')
|
|
})
|
|
})
|