mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 08:01:35 +00:00
* fix: portal the confirmation modal so drawers cannot cover it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: log a folder acl grant under the permission it granted Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep a table's actions column at its right edge Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * feat: edit a folder in a drawer that saves once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: call the people on a folder or item members Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: edit a folder against the workspace the drawer targets Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: drop the now-unused sticky actions column Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * docs: correct the script editor drawer's modal placement note Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: pin the actions column without losing the row's hover tint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * feat: show the pinned column's seam only while the table overflows Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: draw the pinned column's seam as a shadow so it does not scroll away Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: fade the pinned column's tint in step with its row Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: address review nits on the folder editor and pinned cell Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep the folder draft across a user-store refresh Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * refactor: extract and test the folder draft's dirty check and permission diff Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: stop the folder editor showing state the server refused Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep a folder draft that no request ever reached the server Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K9UCPLT4t8PmrWunjfFsPW * fix: keep unapplied folder edits dirty when a save partially fails * fix: block folder form edits while a save is in flight * fix: commit a typed folder label before save snapshots the draft * fix: count a typed folder label as an unsaved change * fix: keep escape in the label input from closing what encloses it * fix: capitalize folder table headers and drop a dead portal target * refactor: make the confirmation modal portal opt-in per call site * docs: name the stacking context that actually traps the discard dialog * fix: report a half-landed member removal so the baseline reconciles * feat: edit a group in a drawer that saves once * fix: freeze the group name once the group exists * fix: revoke the caller's own group acl last so the rest of the save is authorized * docs: state the group call-ordering invariant once * fix: report a failing post-save reload instead of dropping the rejection * fix: hand the folder list reload back so a failure is reported * fix: treat a rejected group create as inconclusive and catch a throwing onSaved * revert: stop inferring a group was created from its name being taken * fix: say when a failed group create may have saved the group anyway * fix: key the may-have-been-created hint on the name conflict, not the status * fix: skip the may-have-been-created hint when the group is known to exist * feat: open a folder's group member from its row * fix: stop showing the caller as an admin when the read failed * fix: give up the caller's own folder admin last, and label a create as one * fix: drop a folder member's acl before its owner entry * fix: remove a folder owner before their acl, and correct the rls rationale * docs: say the refusal is on the caller's last admin handle * fix: defer only the folder rows the caller is an admin through * docs: describe callerOwners as what the caller passes in * docs: drop the call-site restatement of the diff's own invariant * docs: record manager as a legacy group role * fix: treat a sent request as possibly committed when reconciling * fix: reconcile on any failed edit, and compare members as a set * fix: keep write access when only the reconcile read fails --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
149 lines
5.7 KiB
TypeScript
149 lines
5.7 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
folderPermissionDiff,
|
|
isFolderDraftDirty,
|
|
type FolderDraft,
|
|
type FolderMember,
|
|
type FolderRole
|
|
} from './folderDraft'
|
|
|
|
function member(role: FolderRole): FolderMember {
|
|
return { owner_name: 'u/alice', role }
|
|
}
|
|
|
|
function baseline(): FolderDraft {
|
|
return {
|
|
summary: 'Reporting jobs',
|
|
labels: ['prod'],
|
|
defaultPermissionedAs: [{ path_glob: '**', permissioned_as: 'u/admin' }],
|
|
perms: [
|
|
{ owner_name: 'u/admin', role: 'admin' },
|
|
{ owner_name: 'g/all', role: 'viewer' }
|
|
]
|
|
}
|
|
}
|
|
|
|
describe('folderPermissionDiff', () => {
|
|
// The whole transition matrix: which endpoint each role change maps to. `admin` lives in
|
|
// `owners` and the other two in `extra_perms`, so leaving admin is the one transition that
|
|
// cannot go through the ACL endpoint.
|
|
const transitions: Array<[from: FolderRole | 'absent', to: FolderRole, expected: unknown]> = [
|
|
['absent', 'viewer', { kind: 'setAcl', owner: 'u/alice', write: false }],
|
|
['absent', 'writer', { kind: 'setAcl', owner: 'u/alice', write: true }],
|
|
['absent', 'admin', { kind: 'grantAdmin', owner: 'u/alice' }],
|
|
['viewer', 'writer', { kind: 'setAcl', owner: 'u/alice', write: true }],
|
|
['viewer', 'admin', { kind: 'grantAdmin', owner: 'u/alice' }],
|
|
['writer', 'viewer', { kind: 'setAcl', owner: 'u/alice', write: false }],
|
|
['writer', 'admin', { kind: 'grantAdmin', owner: 'u/alice' }],
|
|
['admin', 'viewer', { kind: 'demoteAdmin', owner: 'u/alice', write: false }],
|
|
['admin', 'writer', { kind: 'demoteAdmin', owner: 'u/alice', write: true }]
|
|
]
|
|
|
|
it.each(transitions)('%s → %s', (from, to, expected) => {
|
|
const prev = from === 'absent' ? [] : [member(from)]
|
|
expect(folderPermissionDiff(prev, [member(to)])).toEqual([expected])
|
|
})
|
|
|
|
it.each(['viewer', 'writer', 'admin'] as const)('%s → removed drops owner and acl', (role) => {
|
|
expect(folderPermissionDiff([member(role)], [])).toEqual([{ kind: 'remove', owner: 'u/alice' }])
|
|
})
|
|
|
|
it.each(['viewer', 'writer', 'admin'] as const)('%s unchanged calls nothing', (role) => {
|
|
expect(folderPermissionDiff([member(role)], [member(role)])).toEqual([])
|
|
})
|
|
|
|
// The caller is a folder admin only through `g/ops`, so that demotion is the one the write
|
|
// policy refuses. Sent first it takes the rest of the save down with it.
|
|
it('gives up the caller own admin last', () => {
|
|
const prev: FolderMember[] = [
|
|
{ owner_name: 'g/ops', role: 'admin' },
|
|
{ owner_name: 'u/bob', role: 'viewer' }
|
|
]
|
|
const next: FolderMember[] = [
|
|
{ owner_name: 'g/ops', role: 'viewer' },
|
|
{ owner_name: 'u/bob', role: 'admin' }
|
|
]
|
|
expect(folderPermissionDiff(prev, next, ['u/alice', 'g/ops'])).toEqual([
|
|
{ kind: 'grantAdmin', owner: 'u/bob' },
|
|
{ kind: 'demoteAdmin', owner: 'g/ops', write: false }
|
|
])
|
|
})
|
|
|
|
// `g/z` is a group the caller belongs to but holds no admin through, so removing it is an
|
|
// ordinary call — queued behind the refused one it would never run.
|
|
it('defers only the rows the caller is an admin through', () => {
|
|
const prev: FolderMember[] = [
|
|
{ owner_name: 'g/a', role: 'admin' },
|
|
{ owner_name: 'g/z', role: 'viewer' }
|
|
]
|
|
expect(folderPermissionDiff(prev, [], ['u/alice', 'g/a', 'g/z'])).toEqual([
|
|
{ kind: 'remove', owner: 'g/z' },
|
|
{ kind: 'remove', owner: 'g/a' }
|
|
])
|
|
})
|
|
|
|
it('touches only the members that changed', () => {
|
|
const prev: FolderMember[] = [
|
|
{ owner_name: 'u/admin', role: 'admin' },
|
|
{ owner_name: 'g/all', role: 'viewer' },
|
|
{ owner_name: 'g/ops', role: 'writer' }
|
|
]
|
|
const next: FolderMember[] = [
|
|
{ owner_name: 'u/admin', role: 'admin' },
|
|
{ owner_name: 'g/all', role: 'writer' }
|
|
]
|
|
expect(folderPermissionDiff(prev, next)).toEqual([
|
|
{ kind: 'setAcl', owner: 'g/all', write: true },
|
|
{ kind: 'remove', owner: 'g/ops' }
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('isFolderDraftDirty', () => {
|
|
it('is clean against its own baseline', () => {
|
|
expect(isFolderDraftDirty(baseline(), baseline())).toBe(false)
|
|
})
|
|
|
|
it('is clean before anything has loaded', () => {
|
|
expect(isFolderDraftDirty(baseline(), undefined)).toBe(false)
|
|
})
|
|
|
|
// A reload rebuilds the members in the server's order, which is not the order they were
|
|
// added in. Order-sensitive, an applied change would keep Save lit with nothing to send.
|
|
it('ignores the order the members are held in', () => {
|
|
const reordered = baseline()
|
|
reordered.perms = [...reordered.perms].reverse()
|
|
expect(isFolderDraftDirty(reordered, baseline())).toBe(false)
|
|
})
|
|
|
|
// Enumerated from the value itself rather than a hand-written list: a field added to
|
|
// `FolderDraft` and to `baseline()` is covered here without anyone remembering to add a
|
|
// case. An edit this misses is one the drawer discards without asking.
|
|
it.each(Object.keys(baseline()) as Array<keyof FolderDraft>)('notices a change to %s', (key) => {
|
|
const edited = baseline()
|
|
if (key === 'summary') edited.summary = 'Something else'
|
|
else if (key === 'labels') edited.labels = [...edited.labels, 'staging']
|
|
else if (key === 'defaultPermissionedAs') edited.defaultPermissionedAs = []
|
|
else if (key === 'perms') edited.perms[1].role = 'writer'
|
|
else throw new Error(`no edit defined for ${key} — add one so the field stays covered`)
|
|
|
|
expect(isFolderDraftDirty(edited, baseline())).toBe(true)
|
|
})
|
|
|
|
it('notices a member added and a member removed', () => {
|
|
const added = baseline()
|
|
added.perms.push({ owner_name: 'g/ops', role: 'writer' })
|
|
expect(isFolderDraftDirty(added, baseline())).toBe(true)
|
|
|
|
const removed = baseline()
|
|
removed.perms.pop()
|
|
expect(isFolderDraftDirty(removed, baseline())).toBe(true)
|
|
})
|
|
|
|
it('is clean again once the baseline catches up', () => {
|
|
const saved = baseline()
|
|
saved.summary = 'Renamed'
|
|
expect(isFolderDraftDirty(saved, structuredClone(saved))).toBe(false)
|
|
})
|
|
})
|