Skip to content

Commit 8620079

Browse files
committed
test(secrets-write-ui): cover the last three unclaimed scenarios in this spec
Every scenario in secrets-write-ui is now accounted for: 15/15, ten by a Playwright test and five by an exclude that names the tests actually driving it. None of the excludes claims coverage that does not exist — that was the whole point of not waiving these earlier. The three closed here had NO coverage of any kind, and all three are older than the work that surfaced them: - **Name and value are required** and **Create a secret inside the current folder** — carried across PR #270 and #282 as a known gap. SecretCreateDialog.requiredFields walks every partial state (including whitespace-only), asserts no request is made, and covers the folder default plus the null-folder case at the vault root. - **Edit metadata only** — SecretEditDialog.metadataOnly asserts only the name is sent when only the name changed, nothing at all is sent when nothing changed, and the counterpart: the value IS re-encrypted when it really changes. That matters beyond wasted work — re-encrypting on every rename would rewrite the whole additional-fields blob, which is what loses members another session added. A harness trap is documented in both files, because it silently defeats tests of this exact kind: `sessionStore.isLocked = false` is a NO-OP. It is a getter over `cryptoKey`, so the assignment does nothing (Vue logs "target is readonly"), the dialog stays locked, canSubmit stays false, and submit() returns having done nothing — a test asserting on the resulting call would simply never see one. The existing SecretCreateDialog.generator spec does the same thing. Two disclosures. First, commit 4fcc170 carries content its message does not describe: the registry-dispatch spec and two scenario annotations were swept into it by a `git add -A` before I had written their commit. The work is right, the message is incomplete, and history stays as it is rather than being rewritten. Second, eslint --fix and prettier disagree on these files and will undo each other. Prettier has to run last; after that both are clean. Assisted-by: ClaudeCode:claude-opus-5
1 parent 4fcc170 commit 8620079

4 files changed

Lines changed: 305 additions & 2 deletions

File tree

openspec/specs/secrets-write-ui/spec.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ The dialog MUST be blocked (disabled) while the vault is locked.
2323
- **THEN** the new secret MUST appear in the vault list and its value MUST round-trip: opening it and decrypting MUST return the exact value entered
2424

2525
#### Scenario: Create a secret inside the current folder
26+
@e2e exclude Had NO coverage of any kind until now — carried across PR #270 and #282 as a known gap rather than waived. Driven by SecretCreateDialog.requiredFields "defaults the folder to the one being viewed, and persists it", plus "sends a null folder when created at the vault root" for the other half.
2627
- **WHEN** the user is viewing a folder and creates a secret
2728
- **THEN** the dialog's folder field MUST default to that folder and the created secret MUST persist that `folderId`
2829

2930
#### Scenario: Name and value are required
31+
@e2e exclude Also previously uncovered, and from the same gap. Driven by SecretCreateDialog.requiredFields "requires a name AND a value before anything is sent", which walks every partial state including whitespace-only and asserts no request is made, plus "stays blocked while the vault is locked, however complete the form is" — the requirement enforces that at the dialog rather than by disabling each field.
3032
- **WHEN** the user submits with an empty name or empty value
3133
- **THEN** the submit control MUST be disabled and no request MUST be sent
3234

@@ -44,6 +46,7 @@ re-encryption.
4446
- **THEN** re-opening the secret and decrypting MUST return the updated value
4547

4648
#### Scenario: Edit metadata only
49+
@e2e exclude Previously uncovered. Driven by SecretEditDialog.metadataOnly: only the name is sent when only the name changed, nothing is sent when nothing changed, and the counterpart — the value IS re-encrypted when it actually changes, since the rule is "only CHANGED sensitive fields", not "never".
4750
- **WHEN** the user changes only the name and saves
4851
- **THEN** the system MUST persist the new name and MUST NOT alter the stored ciphertext
4952

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Conduction / Doriath Contributors
3+
* SPDX-License-Identifier: EUPL-1.2
4+
*
5+
* Two create-dialog rules that nothing covered: required fields, and the folder.
6+
*
7+
* Both are declared scenarios of "Create a Secret from the UI" and neither had a
8+
* test of any kind — no vitest, no Playwright. They were carried across PR #270 and
9+
* #282 as a known gap rather than waived with an exclude claiming coverage that did
10+
* not exist; this is where that debt is paid.
11+
*
12+
* A note on the harness, because it is the reason a test like this can look like it
13+
* passes while asserting nothing: `sessionStore.isLocked = false` is a NO-OP. It is
14+
* a getter over `cryptoKey`, so assigning it does nothing (Vue logs "target is
15+
* readonly"), the dialog stays locked, `canSubmit` stays false and `submit()`
16+
* returns before doing any work. Set `cryptoKey` instead.
17+
*
18+
* @spec openspec/specs/secrets-write-ui/spec.md#requirement-create-a-secret-from-the-ui
19+
*/
20+
21+
import { mount } from '@vue/test-utils'
22+
import { createPinia, setActivePinia } from 'pinia'
23+
import { beforeEach, describe, expect, it, vi } from 'vitest'
24+
import SecretCreateDialog from '../../src/dialogs/SecretCreateDialog.vue'
25+
import { useFolderStore } from '../../src/store/modules/folder.js'
26+
import { useSecretStore } from '../../src/store/modules/secret.js'
27+
import { useSecretTypeStore } from '../../src/store/modules/secretType.js'
28+
import { useSessionStore } from '../../src/store/modules/session.js'
29+
30+
/** A value strong enough to satisfy the org password policy. */
31+
const STRONG = 'Xk9#mQ2$vL7@pR4!zT6&'
32+
33+
const stubs = {
34+
NcDialog: {
35+
props: ['name', 'open', 'size'],
36+
template: '<div><slot /><slot name="actions" /></div>',
37+
},
38+
NcButton: {
39+
props: ['disabled', 'variant', 'ariaLabel', 'title'],
40+
template:
41+
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
42+
},
43+
NcSelect: {
44+
props: ['options', 'reduce', 'inputLabel', 'clearable', 'modelValue'],
45+
template: '<div />',
46+
},
47+
NcTextField: {
48+
props: ['modelValue', 'label', 'placeholder', 'disabled', 'required'],
49+
template: '<input :value="modelValue" :disabled="disabled" />',
50+
},
51+
NcPasswordField: {
52+
props: ['modelValue', 'label'],
53+
template: '<input type="password" :value="modelValue" />',
54+
},
55+
NcNoteCard: { props: ['type'], template: '<div><slot /></div>' },
56+
NcLoadingIcon: { template: '<span />' },
57+
Plus: { template: '<i />' },
58+
Dice5: { template: '<i />' },
59+
KeyGeneratorModal: { props: ['open'], template: '<div />' },
60+
}
61+
62+
async function mountDialog(propsData = {}) {
63+
const wrapper = mount(SecretCreateDialog, { propsData, global: { stubs } })
64+
await wrapper.vm.$nextTick()
65+
66+
return wrapper
67+
}
68+
69+
describe('SecretCreateDialog — required fields and folder default', () => {
70+
beforeEach(() => {
71+
setActivePinia(createPinia())
72+
vi.restoreAllMocks()
73+
74+
const typeStore = useSecretTypeStore()
75+
typeStore.types = [{ id: 'login', name: 'login', label: 'Login' }]
76+
typeStore.fetchTypes = vi.fn().mockResolvedValue()
77+
const folderStore = useFolderStore()
78+
folderStore.folders = []
79+
folderStore.fetchFolders = vi.fn().mockResolvedValue()
80+
81+
const session = useSessionStore()
82+
session.cryptoKey = 'UNLOCKED'
83+
session.certificate = 'PEM'
84+
})
85+
86+
it('requires a name AND a value before anything is sent', async () => {
87+
const wrapper = await mountDialog()
88+
const create = vi
89+
.spyOn(useSecretStore(), 'createSecret')
90+
.mockResolvedValue({ id: 's1' })
91+
92+
expect(wrapper.vm.canSubmit).toBe(false)
93+
94+
wrapper.vm.name = 'Has a name'
95+
expect(wrapper.vm.canSubmit).toBe(false)
96+
97+
wrapper.vm.name = ''
98+
wrapper.vm.value = STRONG
99+
expect(wrapper.vm.canSubmit).toBe(false)
100+
101+
// Whitespace is not a name.
102+
wrapper.vm.name = ' '
103+
expect(wrapper.vm.canSubmit).toBe(false)
104+
105+
await wrapper.vm.submit()
106+
expect(create).not.toHaveBeenCalled()
107+
108+
wrapper.vm.name = 'Both present'
109+
expect(wrapper.vm.canSubmit).toBe(true)
110+
})
111+
112+
it('stays blocked while the vault is locked, however complete the form is', async () => {
113+
// The requirement's "MUST be blocked while the vault is locked" is enforced
114+
// here rather than by disabling each field.
115+
useSessionStore().cryptoKey = null
116+
const wrapper = await mountDialog()
117+
const create = vi
118+
.spyOn(useSecretStore(), 'createSecret')
119+
.mockResolvedValue({ id: 's1' })
120+
121+
wrapper.vm.name = 'Complete'
122+
wrapper.vm.value = STRONG
123+
124+
expect(wrapper.vm.locked).toBe(true)
125+
expect(wrapper.vm.canSubmit).toBe(false)
126+
127+
await wrapper.vm.submit()
128+
expect(create).not.toHaveBeenCalled()
129+
})
130+
131+
it('defaults the folder to the one being viewed, and persists it', async () => {
132+
const create = vi
133+
.spyOn(useSecretStore(), 'createSecret')
134+
.mockResolvedValue({ id: 's1' })
135+
const wrapper = await mountDialog({ folderId: 'folder-42' })
136+
137+
expect(wrapper.vm.selectedFolderId).toBe('folder-42')
138+
139+
wrapper.vm.name = 'In a folder'
140+
wrapper.vm.value = STRONG
141+
await wrapper.vm.submit()
142+
143+
expect(create.mock.calls[0][0].folderId).toBe('folder-42')
144+
})
145+
146+
it('sends a null folder when created at the vault root', async () => {
147+
const create = vi
148+
.spyOn(useSecretStore(), 'createSecret')
149+
.mockResolvedValue({ id: 's1' })
150+
const wrapper = await mountDialog()
151+
152+
wrapper.vm.name = 'At the root'
153+
wrapper.vm.value = STRONG
154+
await wrapper.vm.submit()
155+
156+
expect(create.mock.calls[0][0].folderId ?? null).toBeNull()
157+
})
158+
})
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Conduction / Doriath Contributors
3+
* SPDX-License-Identifier: EUPL-1.2
4+
*
5+
* A metadata-only edit must not touch the ciphertext.
6+
*
7+
* "Edit metadata only" is a declared scenario of "Edit a Secret from the UI" and had
8+
* no test of any kind. It matters beyond wasted work: re-encrypting on every rename
9+
* would rewrite the value, the login and the whole additional-fields blob, and
10+
* rewriting the blob is exactly what loses members another session added in the
11+
* meantime.
12+
*
13+
* @spec openspec/specs/secrets-write-ui/spec.md#requirement-edit-a-secret-from-the-ui
14+
*/
15+
16+
import { mount } from '@vue/test-utils'
17+
import { createPinia, setActivePinia } from 'pinia'
18+
import { beforeEach, describe, expect, it, vi } from 'vitest'
19+
import SecretEditDialog from '../../src/dialogs/SecretEditDialog.vue'
20+
import { useSecretStore } from '../../src/store/modules/secret.js'
21+
import { useSecretTypeStore } from '../../src/store/modules/secretType.js'
22+
import { useSessionStore } from '../../src/store/modules/session.js'
23+
24+
const stubs = {
25+
NcDialog: {
26+
props: ['name', 'open', 'size'],
27+
template: '<div><slot /><slot name="actions" /></div>',
28+
},
29+
NcButton: {
30+
props: ['disabled', 'variant', 'ariaLabel', 'title'],
31+
template:
32+
'<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
33+
},
34+
NcSelect: {
35+
props: ['options', 'reduce', 'inputLabel', 'clearable', 'modelValue'],
36+
template: '<div />',
37+
},
38+
NcTextField: {
39+
props: ['modelValue', 'label', 'placeholder', 'disabled', 'required'],
40+
template: '<input :value="modelValue" :disabled="disabled" />',
41+
},
42+
NcPasswordField: {
43+
props: ['modelValue', 'label'],
44+
template: '<input type="password" :value="modelValue" />',
45+
},
46+
NcNoteCard: { props: ['type'], template: '<div><slot /></div>' },
47+
NcLoadingIcon: { template: '<span />' },
48+
Plus: { template: '<i />' },
49+
Dice5: { template: '<i />' },
50+
KeyGeneratorModal: { props: ['open'], template: '<div />' },
51+
}
52+
53+
async function mountOver(secret) {
54+
useSecretStore().fetchSecret = vi.fn().mockResolvedValue(secret)
55+
56+
const wrapper = mount(SecretEditDialog, {
57+
propsData: { secretId: secret.id },
58+
global: { stubs },
59+
})
60+
// mounted() → fetchPolicy + fetchTypes + load()
61+
await wrapper.vm.$nextTick()
62+
await new Promise((resolve) => setTimeout(resolve, 0))
63+
await wrapper.vm.$nextTick()
64+
65+
return wrapper
66+
}
67+
68+
describe('SecretEditDialog — metadata-only edits', () => {
69+
beforeEach(() => {
70+
setActivePinia(createPinia())
71+
vi.restoreAllMocks()
72+
73+
const typeStore = useSecretTypeStore()
74+
typeStore.types = [{ id: 'login', name: 'login', label: 'Login' }]
75+
typeStore.fetchTypes = vi.fn().mockResolvedValue()
76+
77+
// isLocked is a getter over cryptoKey; assigning it is a no-op.
78+
const session = useSessionStore()
79+
session.cryptoKey = 'UNLOCKED'
80+
session.certificate = 'PEM'
81+
})
82+
83+
it('sends only the name when only the name changed', async () => {
84+
const wrapper = await mountOver({
85+
id: 's1',
86+
name: 'Old name',
87+
typeId: 'login',
88+
key: 'Xk9#mQ2$vL7@pR4!zT6&',
89+
url: 'https://example.test',
90+
login: 'svc-acct',
91+
})
92+
const update = vi
93+
.spyOn(useSecretStore(), 'updateSecret')
94+
.mockResolvedValue({ id: 's1' })
95+
96+
wrapper.vm.name = 'New name'
97+
await wrapper.vm.submit()
98+
99+
const diff = update.mock.calls[0][1]
100+
expect(diff.name).toBe('New name')
101+
expect('key' in diff).toBe(false)
102+
expect('login' in diff).toBe(false)
103+
})
104+
105+
it('sends nothing at all when nothing changed', async () => {
106+
// A save with no edits should not produce a version row or a re-encryption.
107+
const wrapper = await mountOver({
108+
id: 's1',
109+
name: 'Unchanged',
110+
typeId: 'login',
111+
key: 'value',
112+
url: null,
113+
login: '',
114+
})
115+
const update = vi
116+
.spyOn(useSecretStore(), 'updateSecret')
117+
.mockResolvedValue({ id: 's1' })
118+
119+
await wrapper.vm.submit()
120+
121+
expect(update).not.toHaveBeenCalled()
122+
})
123+
124+
it('does re-encrypt when the value itself changes', async () => {
125+
// The counterpart: the rule is "only CHANGED sensitive fields", not "never".
126+
const wrapper = await mountOver({
127+
id: 's1',
128+
name: 'Same',
129+
typeId: 'login',
130+
key: 'old-value',
131+
url: null,
132+
login: '',
133+
})
134+
const update = vi
135+
.spyOn(useSecretStore(), 'updateSecret')
136+
.mockResolvedValue({ id: 's1' })
137+
138+
wrapper.vm.value = 'Xk9#mQ2$vL7@pR4!zT6&'
139+
await wrapper.vm.submit()
140+
141+
expect('key' in update.mock.calls[0][1]).toBe(true)
142+
})
143+
})

tests/views/SecretList.registryDispatch.spec.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,8 @@
1818

1919
import { createPinia, setActivePinia } from 'pinia'
2020
import { beforeEach, describe, expect, it, vi } from 'vitest'
21-
22-
import registry from '../../src/registry.js'
2321
import SecretList from '../../src/views/SecretList.vue'
22+
import registry from '../../src/registry.js'
2423

2524
describe('SecretList — registry dispatch', () => {
2625
beforeEach(() => {

0 commit comments

Comments
 (0)