Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions cmd/gen-permissions/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// gen-permissions generates frontend/src/permissions.generated.ts from the
// backend's DefaultAdminPermissions / DefaultUserPermissions /
// DefaultReadOnlyPermissions constants in internal/auth/types.go.
// DefaultReadOnlyPermissions / DefaultPurchaserPermissions constants in
// internal/auth/types.go.
//
// The generated file is imported by the hand-written
// frontend/src/permissions.ts wrapper so the small data surface that
Expand Down Expand Up @@ -61,8 +62,9 @@ func main() {
buf.WriteString(`// CODE GENERATED by ` + "`go run ./cmd/gen-permissions`" + `. DO NOT EDIT MANUALLY.
//
// Source of truth: internal/auth/types.go (DefaultAdminPermissions,
// DefaultUserPermissions, DefaultReadOnlyPermissions). To regenerate after
// editing the Go defaults, run:
// DefaultUserPermissions, DefaultReadOnlyPermissions,
// DefaultPurchaserPermissions). To regenerate after editing the Go
// defaults, run:
//
// go run ./cmd/gen-permissions
//
Expand All @@ -81,6 +83,8 @@ func main() {
render("USER_PERMS", collect(auth.DefaultUserPermissions()), &buf)
buf.WriteString("\n")
render("READONLY_PERMS", collect(auth.DefaultReadOnlyPermissions()), &buf)
buf.WriteString("\n")
render("PURCHASER_PERMS", collect(auth.DefaultPurchaserPermissions()), &buf)

// Resolve the output path relative to the repo root. The generator is
// always invoked from the repo root (the comment block on the package
Expand Down
46 changes: 43 additions & 3 deletions frontend/src/__tests__/history-approve-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,20 @@ import * as api from '../api';
import { confirmDialog } from '../confirmDialog';
import { showToast } from '../toast';
import { getCurrentUser } from '../state';
import { ADMINISTRATORS_GROUP_ID } from '../permissions';

const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID] };
import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions';

// Admin user includes Purchaser membership (mirrors the auto-migration for
// existing admins on first deploy of issue #923). approve-any:purchases is
// carved out of admin:* and requires Purchaser group membership.
const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID] };
// Admin without Purchaser membership and without effectivePermissions
// for any carved-out spending verb. Issue #923 explicitly carves
// approve-any:purchases / retry-any:purchases / execute:purchases OUT
// of admin:*, so this user MUST NOT see Approve / Retry buttons on
// rows they did not create. Regression guard for CR #924 F5 — if a
// future refactor reintroduces isAdmin() as the gate, this test
// catches it.
const ADMIN_NO_PURCH = { id: 'admin-no-purch-uuid', email: 'admin-no-purch@example.com', groups: [ADMINISTRATORS_GROUP_ID] };
// REG_USER carries the default-user effective permission set (approve-own
// + cancel-own + retry-own on purchases) so canAccess returns true for
// own-row actions without needing the bootstrap fetch. The previous
Expand Down Expand Up @@ -367,4 +378,33 @@ describe('History inline Approve button (issue #286)', () => {
expect(cancelBtn?.disabled).toBe(false);
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
});

test('admin WITHOUT Purchaser membership does not see Approve on rows they did not create (CR #924 F5)', async () => {
// Issue #923 + CR #924 F5: approve-any:purchases is carved out of
// admin:*. canApprovePendingRow must gate on
// canAccess('approve-any', 'purchases'), NOT on isAdmin() or
// isPurchaser() group membership alone. A bare admin (no Purchaser
// group, no effectivePermissions yet) is exactly the case where
// the carve-out matters: the legacy implementation would have
// shown Approve on every pending row.
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_NO_PURCH);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({ purchase_id: 'exec-mine', created_by_user_id: ADMIN_NO_PURCH.id }),
makeRow({ purchase_id: 'exec-other', created_by_user_id: OTHER_UUID }),
makeRow({ purchase_id: 'exec-legacy', created_by_user_id: undefined }),
],
});

await loadHistory();

// Scope to the history list (not the approval queue card).
const list = document.getElementById('history-list')!;
const buttons = list.querySelectorAll<HTMLButtonElement>('.history-approve-btn');
const ids = Array.from(buttons).map((b) => b.dataset['approveId']);
// Approve renders only via the approve-own fallback (matching
// created_by_user_id), NOT approve-any.
expect(ids).toEqual(['exec-mine']);
});
});
39 changes: 37 additions & 2 deletions frontend/src/__tests__/history-retry-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,18 @@ import * as api from '../api';
import { confirmDialog } from '../confirmDialog';
import { showToast } from '../toast';
import { getCurrentUser } from '../state';
import { ADMINISTRATORS_GROUP_ID } from '../permissions';
import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions';

const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID] };
// Admin user includes Purchaser membership (mirrors the auto-migration for
// existing admins on first deploy of issue #923). retry-any:purchases is
// carved out of admin:* and requires Purchaser group membership.
const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID] };
const REG_USER = { id: 'user-uuid', email: 'user@example.com', groups: [] };
// Admin WITHOUT Purchaser membership. retry-any:purchases is carved
// out of admin:* by issue #923, so this user MUST NOT see Retry on
// rows they did not create. Regression guard for CR #924 F5 -- if a
// future refactor reintroduces isAdmin() as the gate, this catches it.
const ADMIN_NO_PURCH = { id: 'admin-no-purch-uuid', email: 'admin-no-purch@example.com', groups: [ADMINISTRATORS_GROUP_ID] };
const OTHER_UUID = 'other-uuid';

function setupDOM(): void {
Expand Down Expand Up @@ -430,4 +438,31 @@ describe('History inline Retry button (issue #47)', () => {
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
expect(btn?.disabled).toBe(false);
});

test('admin WITHOUT Purchaser membership does not see Retry on rows they did not create (CR #924 F5)', async () => {
// Issue #923 + CR #924 F5: retry-any:purchases is carved out of
// admin:*. canRetryFailedRow must gate on
// canAccess('retry-any', 'purchases'), NOT on isAdmin() or
// isPurchaser() group membership alone. A bare admin (no Purchaser
// group, no effectivePermissions yet) is exactly the case where
// the carve-out matters: the legacy implementation would have
// shown Retry on every failed row.
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_NO_PURCH);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({ purchase_id: 'fail-mine', created_by_user_id: ADMIN_NO_PURCH.id }),
makeRow({ purchase_id: 'fail-other', created_by_user_id: OTHER_UUID }),
makeRow({ purchase_id: 'fail-legacy', created_by_user_id: undefined }),
],
});

await loadHistory();

const buttons = document.querySelectorAll<HTMLButtonElement>('.history-retry-btn');
const ids = Array.from(buttons).map((b) => b.dataset['retryId']);
// Retry renders only via the retry-own fallback (matching
// created_by_user_id), NOT retry-any.
expect(ids).toEqual(['fail-mine']);
});
});
Loading
Loading