Files
windmill/frontend/src/lib/components/argInputWorkspaceForwarding.test.ts
T
AlexRV12andClaude Opus 5 93081e255f fix(frontend): mint string password secrets in the operating workspace (#10815)
* fix(frontend): mint password secrets in the operating workspace

A `password: true` string argument is rendered by PasswordArgInput, which
mints an ephemeral secret variable on the first keystroke and rebinds the
argument to `$var:<path>`. It minted into `$workspaceStore` — the globally
active navigation workspace.

Session editors operate on a different, possibly forked workspace without
switching `$workspaceStore`, and thread that operating workspace explicitly
as a `workspace` prop. When the two diverged the secret landed where the
user was merely looking while the job ran elsewhere, and the backend failed
with `Variable not found`.

Add the `workspace` prop to PasswordArgInput and thread it through every hop
between a form mount and the minting field, plus the entry points that supply
it. Track `mintedIn` so updates target where the variable actually lives, and
re-mint when the operating workspace moves after a path already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): keep a password field consistent with its argument

A parent can replace the whole args object without remounting this field —
previewing a saved input, say — leaving `path` and `password` describing a
secret the argument no longer points at. Minting from them then copies the
old plaintext over the replacement, and the replacement is lost.

State that rule once as `argReplaced` and gate every mint on it. The
replacement can also land while the create is in flight, so the bound value
is captured before the request and re-checked after it resolves; the variable
that mint produced was never referenced, so it is deleted outright. A mint
that ends without binding re-seeds `password` from what the argument now
holds, so the field stops displaying a secret that will not be submitted and
a later workspace move cannot re-mint the stale plaintext. `updateValue`
returns early before anything is minted, since its 404 retry would otherwise
bind over a replacement it cannot see.

A failed initial mint now raises a toast rather than passing silently, which
also removes the component's last unhandled rejection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(frontend): guard workspace forwarding to PasswordArgInput

Every hop between the form a caller mounts and the PasswordArgInput that
mints the secret must forward `workspace`, and so must the entry points that
supply it. A hop that drops the prop falls back to the navigation workspace
while the top-level case keeps passing, and no typechecker catches it because
every hop declares `workspace?: string | undefined`.

The forwarded expression is checked rather than the prop's presence, so
`workspace={$workspaceStore}` and `workspace={undefined}` fail. Two ways the
scan could stop guarding without failing are asserted too: an unterminated
mount raises instead of swallowing the rest of the file, and the number of
mounts parsed must equal the number of tag occurrences, so a mount written
inline rather than at the start of a line fails loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): list and create variables in the operating workspace

* fix(frontend): surface and bound a failed recovery mint

* test(frontend): end a mount at the first line closing it

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:36:07 +02:00

95 lines
3.8 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const TAGS = [
'SchemaFormDnd',
'SchemaForm',
'PasswordArgInput',
'ArgInput',
'FlowPropertyEditor',
'PropertyEditor',
'EditableSchemaForm',
'EditableSchemaDrawer',
'ArrayTypeNarrowing',
'InputTransformSchemaForm',
'InputTransformForm',
'ScriptSchema'
]
// `SchemaFormDnd` precedes `SchemaForm` so the longer tag is not matched as the shorter one.
const OPENING = new RegExp(`<(${TAGS.join('|')})(?=[\\s/>]|$)`, 'g')
function formMounts(source: string): { tag: string; line: number; block: string }[] {
const lines = source.split('\n')
const mounts: { tag: string; line: number; block: string }[] = []
for (let i = 0; i < lines.length; i++) {
const open = lines[i].trim().match(new RegExp(`^${OPENING.source}`))
if (!open) continue
// Requiring a lone `>` would run past a mount whose last prop shares the closing line, into
// the next component, and read its `workspace` as this one's — a false pass on exactly the
// regression this guards.
let end = i
while (end < lines.length && !lines[end].trim().endsWith('>')) end++
// Running off the end means the props were never delimited, so the block would swallow the
// rest of the file and match any `workspace` in it — a false pass, not a failure.
if (end >= lines.length) {
throw new Error(`unterminated <${open[1]}> mount at line ${i + 1}`)
}
mounts.push({ tag: open[1], line: i + 1, block: lines.slice(i, end + 1).join('\n') })
i = end
}
return mounts
}
// `workspace={$workspaceStore}` and `workspace={undefined}` are the nav-workspace fallback this
// guards against, so presence of the prop is not enough.
function forwardsWorkspace(block: string): boolean {
const m = block.match(/\{workspace\}|workspace=\{([^{}]*)\}/)
if (!m) return false
const expr = m[1]?.trim()
return expr === undefined || (expr !== 'undefined' && expr !== '$workspaceStore')
}
function read(relPath: string): string {
return readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), relPath), 'utf-8')
}
// Every hop between the form a caller mounts and the PasswordArgInput that mints the secret, plus
// the entry points that supply the workspace in the first place. A hop that drops `workspace` falls
// back to the navigation workspace, so the secret lands where the job will not run — while the
// top-level case keeps passing.
describe.each([
['ArgInput.svelte', 7],
['schema/SchemaFormDND.svelte', 1],
['SchemaForm.svelte', 2],
['EditableSchemaForm.svelte', 3],
['schema/FlowPropertyEditor.svelte', 3],
['schema/EditableSchemaDrawer.svelte', 2],
['schema/PropertyEditor.svelte', 3],
['ArrayTypeNarrowing.svelte', 1],
['InputTransformSchemaForm.svelte', 1],
['InputTransformForm.svelte', 1],
['ScriptSchema.svelte', 1],
['ScriptBuilder.svelte', 1],
['flows/content/FlowInput.svelte', 2],
['flows/content/FlowModuleComponent.svelte', 1],
['flows/content/AgentToolBindings.svelte', 1],
['ModulePreviewForm.svelte', 1],
['dbt/DbtEditor.svelte', 1]
// `flows/content/FlowModuleSuspend.svelte` stays out: its two unthreaded mounts render the
// locally built `groups` schema and a preview whose args stay empty, so neither can mint.
])('%s nested forms', (relPath, minMounts) => {
it('forwards workspace to every nested form', () => {
const source = read(relPath)
const mounts = formMounts(source)
expect(mounts.length).toBeGreaterThanOrEqual(minMounts)
// The scan only recognises a mount opening its own line, so one written inline would be
// skipped and silently unguarded. Every opening tag in the file has to be accounted for.
expect(mounts.length).toBe(source.match(OPENING)?.length ?? 0)
expect(
mounts.filter((m) => !forwardsWorkspace(m.block)).map((m) => `${m.tag}:${m.line}`)
).toEqual([])
})
})