@clef-sh/ui 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -691,4 +691,104 @@ describe("NamespaceEditor", () => {
691
691
  // Key should still be there
692
692
  expect(screen.getByText("DB_HOST")).toBeInTheDocument();
693
693
  });
694
+
695
+ it("surfaces the server error and resets UI to on-disk state on Save failure", async () => {
696
+ // Route by method: all GETs (decrypt + lint) succeed; the PUT that
697
+ // handleSave issues rejects with a 500 (dirty-tree preflight failure).
698
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
699
+ const fetchMock = jest.fn().mockImplementation((_url: string, init?: any) => {
700
+ if (init?.method === "PUT") {
701
+ return Promise.resolve({
702
+ ok: false,
703
+ status: 500,
704
+ json: () =>
705
+ Promise.resolve({
706
+ error: "Working tree has uncommitted changes. Refusing to mutate.",
707
+ code: "SET_ERROR",
708
+ }),
709
+ } as Response);
710
+ }
711
+ return Promise.resolve({
712
+ ok: true,
713
+ json: () => Promise.resolve(mockDecrypted),
714
+ } as Response);
715
+ });
716
+ global.fetch = fetchMock;
717
+
718
+ await act(async () => {
719
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
720
+ });
721
+
722
+ // Reveal + edit DB_HOST to produce a dirty row.
723
+ await act(async () => {
724
+ fireEvent.click(screen.getByTestId("eye-DB_HOST"));
725
+ });
726
+ await act(async () => {
727
+ fireEvent.change(screen.getByTestId("value-input-DB_HOST"), {
728
+ target: { value: "rotated" },
729
+ });
730
+ });
731
+
732
+ // Click the Save button (rendered once a row is dirty).
733
+ await act(async () => {
734
+ fireEvent.click(screen.getByText("Save"));
735
+ });
736
+
737
+ // The server's error message reaches the user — no silent success.
738
+ expect(screen.getByText(/Working tree has uncommitted changes/)).toBeInTheDocument();
739
+ // UI resets to on-disk state: dirty indicator gone, value re-masked
740
+ // (value-input replaced by bullet-mask span), Save button hidden.
741
+ expect(screen.queryByTestId("dirty-dot")).not.toBeInTheDocument();
742
+ expect(screen.queryByTestId("value-input-DB_HOST")).not.toBeInTheDocument();
743
+ expect(screen.queryByText("Save")).not.toBeInTheDocument();
744
+ });
745
+
746
+ it("bails on first failure in a batch save without issuing further PUTs", async () => {
747
+ // All GETs succeed; every PUT 500s. We expect handleSave to abort
748
+ // after the first PUT and never issue the second.
749
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
750
+ const fetchMock = jest.fn().mockImplementation((_url: string, init?: any) => {
751
+ if (init?.method === "PUT") {
752
+ return Promise.resolve({
753
+ ok: false,
754
+ status: 500,
755
+ json: () => Promise.resolve({ error: "boom" }),
756
+ } as Response);
757
+ }
758
+ return Promise.resolve({
759
+ ok: true,
760
+ json: () => Promise.resolve(mockDecrypted),
761
+ } as Response);
762
+ });
763
+ global.fetch = fetchMock;
764
+
765
+ await act(async () => {
766
+ render(<NamespaceEditor ns="database" manifest={manifest} />);
767
+ });
768
+
769
+ // Dirty both rows.
770
+ for (const key of ["DB_HOST", "DB_PORT"]) {
771
+ await act(async () => {
772
+ fireEvent.click(screen.getByTestId(`eye-${key}`));
773
+ });
774
+ await act(async () => {
775
+ fireEvent.change(screen.getByTestId(`value-input-${key}`), {
776
+ target: { value: `${key}-new` },
777
+ });
778
+ });
779
+ }
780
+
781
+ await act(async () => {
782
+ fireEvent.click(screen.getByText("Save"));
783
+ });
784
+
785
+ // Exactly one PUT was attempted — the second row was skipped after the
786
+ // first failure. Initial GET + one PUT = 2 fetch calls total.
787
+ const putCalls = fetchMock.mock.calls.filter(
788
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
789
+ (c: any[]) => c[1]?.method === "PUT",
790
+ );
791
+ expect(putCalls).toHaveLength(1);
792
+ expect(screen.getByText("boom")).toBeInTheDocument();
793
+ });
694
794
  });
@@ -130,20 +130,38 @@ export function NamespaceEditor({ ns, initialEnv, manifest }: NamespaceEditorPro
130
130
  const dirtyRows = rows.filter((r) => r.edited);
131
131
  if (dirtyRows.length === 0) return;
132
132
  setSaving(true);
133
+ setError(null);
133
134
  try {
134
135
  // Each PUT auto-commits via the transaction manager, matching CLI
135
136
  // behavior where each `clef set` is its own commit. Serialize so
136
137
  // each transaction completes before the next starts.
138
+ let failure: string | null = null;
137
139
  for (const row of dirtyRows) {
138
140
  const payload: Record<string, unknown> = { value: row.value };
139
141
  if (confirmed) payload.confirmed = true;
140
- await apiFetch(`/api/namespace/${ns}/${env}/${row.key}`, {
142
+ const res = await apiFetch(`/api/namespace/${ns}/${env}/${row.key}`, {
141
143
  method: "PUT",
142
144
  headers: { "Content-Type": "application/json" },
143
145
  body: JSON.stringify(payload),
144
146
  });
147
+ if (!res.ok) {
148
+ // Bail on first failure — for dirty-tree / recipient errors the
149
+ // remaining PUTs will fail the same way, and piling errors on top
150
+ // of each other just obscures the root cause.
151
+ const data = await res.json().catch(() => ({}));
152
+ failure = data.error || `Failed to save ${row.key}`;
153
+ break;
154
+ }
145
155
  }
156
+ // Always reload from disk — on success to pick up server-side state
157
+ // (lastModified, metadata), on failure to snap the UI back to the
158
+ // current on-disk values and re-mask the inputs rather than leaving
159
+ // unsaved plaintext edits visible in an illusory "pending" state.
160
+ await loadData();
161
+ if (failure) setError(failure);
162
+ } catch {
146
163
  await loadData();
164
+ setError("Failed to save changes");
147
165
  } finally {
148
166
  setSaving(false);
149
167
  }
@@ -401,9 +419,12 @@ export function NamespaceEditor({ ns, initialEnv, manifest }: NamespaceEditorPro
401
419
  </div>
402
420
  )}
403
421
 
404
- {!loading && !error && (
422
+ {!loading && (
405
423
  <>
406
- {/* Keys table */}
424
+ {/* Keys table. Rendered even when `error` is set so a failed
425
+ save still shows the dirty rows + Save button for retry.
426
+ On load failure `rows` is empty, so the table renders
427
+ gracefully with no row body. */}
407
428
  <div
408
429
  style={{
409
430
  background: theme.surface,
@@ -0,0 +1,278 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { PolicyView } from "./PolicyView";
5
+ import type { FileRotationStatus, KeyRotationStatus } from "@clef-sh/core";
6
+
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ declare let global: any;
9
+
10
+ function okKey(name: string): KeyRotationStatus {
11
+ return {
12
+ key: name,
13
+ last_rotated_at: "2026-01-01T00:00:00Z",
14
+ last_rotated_known: true,
15
+ rotated_by: "alice <alice@example.com>",
16
+ rotation_count: 1,
17
+ rotation_due: "2026-04-01T00:00:00Z",
18
+ rotation_overdue: false,
19
+ days_overdue: 0,
20
+ compliant: true,
21
+ };
22
+ }
23
+
24
+ function overdueKey(name: string, daysOverdue: number): KeyRotationStatus {
25
+ return {
26
+ key: name,
27
+ last_rotated_at: "2025-01-01T00:00:00Z",
28
+ last_rotated_known: true,
29
+ rotated_by: "alice <alice@example.com>",
30
+ rotation_count: 2,
31
+ rotation_due: "2025-04-01T00:00:00Z",
32
+ rotation_overdue: true,
33
+ days_overdue: daysOverdue,
34
+ compliant: false,
35
+ };
36
+ }
37
+
38
+ function unknownKey(name: string): KeyRotationStatus {
39
+ return {
40
+ key: name,
41
+ last_rotated_at: null,
42
+ last_rotated_known: false,
43
+ rotated_by: null,
44
+ rotation_count: 0,
45
+ rotation_due: null,
46
+ rotation_overdue: false,
47
+ days_overdue: 0,
48
+ compliant: false,
49
+ };
50
+ }
51
+
52
+ function makeFile(overrides: Partial<FileRotationStatus> = {}): FileRotationStatus {
53
+ const base: FileRotationStatus = {
54
+ path: "database/dev.enc.yaml",
55
+ environment: "dev",
56
+ backend: "age",
57
+ recipients: ["age1abc"],
58
+ last_modified: "2026-01-01T00:00:00Z",
59
+ last_modified_known: true,
60
+ keys: [okKey("DB_URL")],
61
+ compliant: true,
62
+ };
63
+ const merged = { ...base, ...overrides };
64
+ if (!("compliant" in overrides)) {
65
+ merged.compliant = merged.keys.every((k) => k.compliant);
66
+ }
67
+ return merged;
68
+ }
69
+
70
+ const mixedResponse = {
71
+ files: [
72
+ makeFile({
73
+ path: "database/production.enc.yaml",
74
+ environment: "production",
75
+ keys: [overdueKey("DB_PROD_URL", 380)],
76
+ }),
77
+ makeFile({
78
+ path: "payments/staging.enc.yaml",
79
+ environment: "staging",
80
+ keys: [unknownKey("STRIPE_KEY")],
81
+ }),
82
+ makeFile({ path: "auth/dev.enc.yaml", environment: "dev", keys: [okKey("AUTH0_SECRET")] }),
83
+ ],
84
+ summary: { total_files: 3, compliant: 1, rotation_overdue: 2, unknown_metadata: 1 },
85
+ policy: { version: 1, rotation: { max_age_days: 90 } },
86
+ source: "default" as const,
87
+ };
88
+
89
+ const policyShowResponse = {
90
+ policy: { version: 1, rotation: { max_age_days: 90 } },
91
+ source: "default" as const,
92
+ path: ".clef/policy.yaml",
93
+ rawYaml: "version: 1\nrotation:\n max_age_days: 90\n",
94
+ };
95
+
96
+ const allCompliantResponse = {
97
+ files: [makeFile()],
98
+ summary: { total_files: 1, compliant: 1, rotation_overdue: 0, unknown_metadata: 0 },
99
+ policy: { version: 1, rotation: { max_age_days: 90 } },
100
+ source: "file" as const,
101
+ };
102
+
103
+ function mockFetchSequence(checkBody: object, policyBody: object): jest.Mock {
104
+ const fn = jest.fn().mockImplementation(async (url: string) => {
105
+ if (url.endsWith("/api/policy/check")) {
106
+ return { ok: true, json: () => Promise.resolve(checkBody) } as Response;
107
+ }
108
+ if (url.endsWith("/api/policy")) {
109
+ return { ok: true, json: () => Promise.resolve(policyBody) } as Response;
110
+ }
111
+ return { ok: false, json: () => Promise.resolve({}) } as Response;
112
+ });
113
+ global.fetch = fn;
114
+ return fn;
115
+ }
116
+
117
+ beforeEach(() => {
118
+ jest.clearAllMocks();
119
+ jest.restoreAllMocks();
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ delete (global as any).fetch;
122
+ });
123
+
124
+ describe("PolicyView", () => {
125
+ it("renders rotation rows grouped by status with summary chips", async () => {
126
+ mockFetchSequence(mixedResponse, policyShowResponse);
127
+
128
+ await act(async () => {
129
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
130
+ });
131
+
132
+ expect(screen.getByText("database/production.enc.yaml")).toBeInTheDocument();
133
+ expect(screen.getByText("payments/staging.enc.yaml")).toBeInTheDocument();
134
+ expect(screen.getByText("auth/dev.enc.yaml")).toBeInTheDocument();
135
+
136
+ expect(screen.getByTestId("filter-overdue")).toHaveTextContent("1");
137
+ expect(screen.getByTestId("filter-unknown")).toHaveTextContent("1");
138
+ expect(screen.getByTestId("filter-ok")).toHaveTextContent("1");
139
+ });
140
+
141
+ it("filters rows by status when a chip is clicked", async () => {
142
+ mockFetchSequence(mixedResponse, policyShowResponse);
143
+
144
+ await act(async () => {
145
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
146
+ });
147
+
148
+ await act(async () => {
149
+ fireEvent.click(screen.getByTestId("filter-overdue"));
150
+ });
151
+
152
+ expect(screen.getByText("database/production.enc.yaml")).toBeInTheDocument();
153
+ expect(screen.queryByText("auth/dev.enc.yaml")).not.toBeInTheDocument();
154
+ expect(screen.queryByText("payments/staging.enc.yaml")).not.toBeInTheDocument();
155
+ });
156
+
157
+ it("shows the all-compliant state when nothing is overdue or unknown", async () => {
158
+ mockFetchSequence(allCompliantResponse, policyShowResponse);
159
+
160
+ await act(async () => {
161
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
162
+ });
163
+
164
+ expect(screen.getByTestId("all-compliant")).toBeInTheDocument();
165
+ expect(screen.getByText("All compliant")).toBeInTheDocument();
166
+ // Per-key summary: 1 key across 1 file. Regex matches "1 key within ..." with any suffix.
167
+ expect(screen.getByText(/1 key within rotation window across 1 file/)).toBeInTheDocument();
168
+ });
169
+
170
+ it("shows the source badge — 'Built-in default' when source is 'default'", async () => {
171
+ mockFetchSequence(mixedResponse, policyShowResponse);
172
+
173
+ await act(async () => {
174
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
175
+ });
176
+
177
+ expect(screen.getByTestId("policy-source")).toHaveTextContent("Built-in default");
178
+ });
179
+
180
+ it("shows the source badge — '.clef/policy.yaml' when source is 'file'", async () => {
181
+ mockFetchSequence(allCompliantResponse, { ...policyShowResponse, source: "file" });
182
+
183
+ await act(async () => {
184
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
185
+ });
186
+
187
+ expect(screen.getByTestId("policy-source")).toHaveTextContent(".clef/policy.yaml");
188
+ });
189
+
190
+ it("toggles the YAML view via the View YAML button", async () => {
191
+ mockFetchSequence(mixedResponse, policyShowResponse);
192
+
193
+ await act(async () => {
194
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
195
+ });
196
+
197
+ expect(screen.queryByTestId("raw-yaml")).not.toBeInTheDocument();
198
+
199
+ await act(async () => {
200
+ fireEvent.click(screen.getByTestId("toggle-yaml"));
201
+ });
202
+
203
+ expect(screen.getByTestId("raw-yaml")).toBeInTheDocument();
204
+ expect(screen.getByTestId("raw-yaml")).toHaveTextContent("max_age_days: 90");
205
+ });
206
+
207
+ it("navigates to the editor when a file ref is clicked", async () => {
208
+ mockFetchSequence(mixedResponse, policyShowResponse);
209
+ const setView = jest.fn();
210
+ const setNs = jest.fn();
211
+
212
+ await act(async () => {
213
+ render(<PolicyView setView={setView} setNs={setNs} />);
214
+ });
215
+
216
+ fireEvent.click(screen.getByTestId("file-ref-database/production.enc.yaml"));
217
+
218
+ expect(setView).toHaveBeenCalledWith("editor");
219
+ expect(setNs).toHaveBeenCalledWith("database");
220
+ });
221
+
222
+ it("extracts the namespace from the second-to-last path segment (nested paths)", async () => {
223
+ // Bot repo layout: secrets/<namespace>/<env>.enc.yaml. The namespace is
224
+ // "github", not "secrets" — previously file.path.split("/")[0] was
225
+ // returning "secrets" and producing a wrong `clef set` hint.
226
+ const nested = {
227
+ files: [
228
+ makeFile({
229
+ path: "secrets/github/dev.enc.yaml",
230
+ environment: "dev",
231
+ keys: [unknownKey("GITHUB_CLIENT_SECRET")],
232
+ }),
233
+ ],
234
+ summary: { total_files: 1, compliant: 0, rotation_overdue: 1, unknown_metadata: 1 },
235
+ policy: { version: 1, rotation: { max_age_days: 90 } },
236
+ source: "default" as const,
237
+ };
238
+ mockFetchSequence(nested, policyShowResponse);
239
+ const setView = jest.fn();
240
+ const setNs = jest.fn();
241
+
242
+ await act(async () => {
243
+ render(<PolicyView setView={setView} setNs={setNs} />);
244
+ });
245
+
246
+ fireEvent.click(screen.getByTestId("file-ref-secrets/github/dev.enc.yaml"));
247
+
248
+ expect(setNs).toHaveBeenCalledWith("github");
249
+ // And the unknown-row hint should suggest the correct namespace too.
250
+ expect(
251
+ screen.getByText(/run clef set github\/dev GITHUB_CLIENT_SECRET to establish/),
252
+ ).toBeInTheDocument();
253
+ });
254
+
255
+ it("renders a per-environment override chip when policy has environments block", async () => {
256
+ const withEnvOverride = {
257
+ ...mixedResponse,
258
+ policy: {
259
+ version: 1,
260
+ rotation: {
261
+ max_age_days: 90,
262
+ environments: { production: { max_age_days: 30 } },
263
+ },
264
+ },
265
+ };
266
+ mockFetchSequence(withEnvOverride, {
267
+ ...policyShowResponse,
268
+ policy: withEnvOverride.policy,
269
+ });
270
+
271
+ await act(async () => {
272
+ render(<PolicyView setView={jest.fn()} setNs={jest.fn()} />);
273
+ });
274
+
275
+ expect(screen.getByText("PRODUCTION")).toBeInTheDocument();
276
+ expect(screen.getByText("30d")).toBeInTheDocument();
277
+ });
278
+ });