@clef-sh/ui 0.1.13-beta.88

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.
Files changed (70) hide show
  1. package/README.md +38 -0
  2. package/dist/client/assets/index-CVpAmirt.js +26 -0
  3. package/dist/client/favicon-96x96.png +0 -0
  4. package/dist/client/favicon.ico +0 -0
  5. package/dist/client/favicon.svg +16 -0
  6. package/dist/client/index.html +50 -0
  7. package/dist/client-lib/api.d.ts +3 -0
  8. package/dist/client-lib/api.d.ts.map +1 -0
  9. package/dist/client-lib/components/Button.d.ts +10 -0
  10. package/dist/client-lib/components/Button.d.ts.map +1 -0
  11. package/dist/client-lib/components/CopyButton.d.ts +6 -0
  12. package/dist/client-lib/components/CopyButton.d.ts.map +1 -0
  13. package/dist/client-lib/components/EnvBadge.d.ts +7 -0
  14. package/dist/client-lib/components/EnvBadge.d.ts.map +1 -0
  15. package/dist/client-lib/components/MatrixGrid.d.ts +13 -0
  16. package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -0
  17. package/dist/client-lib/components/Sidebar.d.ts +16 -0
  18. package/dist/client-lib/components/Sidebar.d.ts.map +1 -0
  19. package/dist/client-lib/components/StatusDot.d.ts +6 -0
  20. package/dist/client-lib/components/StatusDot.d.ts.map +1 -0
  21. package/dist/client-lib/components/TopBar.d.ts +9 -0
  22. package/dist/client-lib/components/TopBar.d.ts.map +1 -0
  23. package/dist/client-lib/index.d.ts +12 -0
  24. package/dist/client-lib/index.d.ts.map +1 -0
  25. package/dist/client-lib/theme.d.ts +42 -0
  26. package/dist/client-lib/theme.d.ts.map +1 -0
  27. package/dist/server/api.d.ts +11 -0
  28. package/dist/server/api.d.ts.map +1 -0
  29. package/dist/server/api.js +1020 -0
  30. package/dist/server/api.js.map +1 -0
  31. package/dist/server/index.d.ts +12 -0
  32. package/dist/server/index.d.ts.map +1 -0
  33. package/dist/server/index.js +231 -0
  34. package/dist/server/index.js.map +1 -0
  35. package/package.json +74 -0
  36. package/src/client/App.tsx +205 -0
  37. package/src/client/api.test.tsx +94 -0
  38. package/src/client/api.ts +30 -0
  39. package/src/client/components/Button.tsx +52 -0
  40. package/src/client/components/CopyButton.test.tsx +43 -0
  41. package/src/client/components/CopyButton.tsx +36 -0
  42. package/src/client/components/EnvBadge.tsx +32 -0
  43. package/src/client/components/MatrixGrid.tsx +265 -0
  44. package/src/client/components/Sidebar.tsx +337 -0
  45. package/src/client/components/StatusDot.tsx +30 -0
  46. package/src/client/components/TopBar.tsx +50 -0
  47. package/src/client/index.html +50 -0
  48. package/src/client/index.ts +18 -0
  49. package/src/client/main.tsx +15 -0
  50. package/src/client/public/favicon-96x96.png +0 -0
  51. package/src/client/public/favicon.ico +0 -0
  52. package/src/client/public/favicon.svg +16 -0
  53. package/src/client/screens/BackendScreen.test.tsx +611 -0
  54. package/src/client/screens/BackendScreen.tsx +836 -0
  55. package/src/client/screens/DiffView.test.tsx +130 -0
  56. package/src/client/screens/DiffView.tsx +547 -0
  57. package/src/client/screens/GitLogView.test.tsx +113 -0
  58. package/src/client/screens/GitLogView.tsx +192 -0
  59. package/src/client/screens/ImportScreen.tsx +710 -0
  60. package/src/client/screens/LintView.test.tsx +143 -0
  61. package/src/client/screens/LintView.tsx +589 -0
  62. package/src/client/screens/MatrixView.test.tsx +138 -0
  63. package/src/client/screens/MatrixView.tsx +143 -0
  64. package/src/client/screens/NamespaceEditor.test.tsx +694 -0
  65. package/src/client/screens/NamespaceEditor.tsx +1122 -0
  66. package/src/client/screens/RecipientsScreen.tsx +696 -0
  67. package/src/client/screens/ScanScreen.test.tsx +323 -0
  68. package/src/client/screens/ScanScreen.tsx +523 -0
  69. package/src/client/screens/ServiceIdentitiesScreen.tsx +1398 -0
  70. package/src/client/theme.ts +48 -0
@@ -0,0 +1,694 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { NamespaceEditor } from "./NamespaceEditor";
5
+ import type { ClefManifest } from "@clef-sh/core";
6
+
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ declare let global: any;
9
+
10
+ const manifest: ClefManifest = {
11
+ version: 1,
12
+ environments: [
13
+ { name: "dev", description: "Dev" },
14
+ { name: "production", description: "Prod", protected: true },
15
+ ],
16
+ namespaces: [{ name: "database", description: "DB" }],
17
+ sops: { default_backend: "age" },
18
+ file_pattern: "{namespace}/{environment}.enc.yaml",
19
+ };
20
+
21
+ const mockDecrypted = {
22
+ values: { DB_HOST: "localhost", DB_PORT: "5432" },
23
+ metadata: {
24
+ backend: "age",
25
+ recipients: ["age1abc", "age1def"],
26
+ lastModified: "2024-01-15",
27
+ },
28
+ };
29
+
30
+ beforeEach(() => {
31
+ jest.clearAllMocks();
32
+ jest.restoreAllMocks();
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ delete (global as any).fetch;
35
+ });
36
+
37
+ describe("NamespaceEditor", () => {
38
+ it("renders with fetched data", async () => {
39
+ global.fetch = jest.fn().mockResolvedValue({
40
+ ok: true,
41
+ json: () => Promise.resolve(mockDecrypted),
42
+ } as Response);
43
+
44
+ await act(async () => {
45
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
46
+ });
47
+
48
+ expect(screen.getByText("DB_HOST")).toBeInTheDocument();
49
+ expect(screen.getByText("DB_PORT")).toBeInTheDocument();
50
+ });
51
+
52
+ it("shows production warning on production tab", async () => {
53
+ global.fetch = jest.fn().mockResolvedValue({
54
+ ok: true,
55
+ json: () => Promise.resolve(mockDecrypted),
56
+ } as Response);
57
+
58
+ await act(async () => {
59
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
60
+ });
61
+
62
+ // Click production tab
63
+ const tabs = screen.getAllByRole("tab");
64
+ const prodTab = tabs.find((t) => t.textContent?.includes("production"));
65
+
66
+ await act(async () => {
67
+ if (prodTab) fireEvent.click(prodTab);
68
+ });
69
+
70
+ expect(screen.getByTestId("production-warning")).toBeInTheDocument();
71
+ });
72
+
73
+ it("reveals value when eye icon is clicked", async () => {
74
+ global.fetch = jest.fn().mockResolvedValue({
75
+ ok: true,
76
+ json: () => Promise.resolve(mockDecrypted),
77
+ } as Response);
78
+
79
+ await act(async () => {
80
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
81
+ });
82
+
83
+ await act(async () => {
84
+ fireEvent.click(screen.getByTestId("eye-DB_HOST"));
85
+ });
86
+
87
+ expect(screen.getByTestId("value-input-DB_HOST")).toBeInTheDocument();
88
+ });
89
+
90
+ it("shows error state when API fails", async () => {
91
+ global.fetch = jest.fn().mockResolvedValue({
92
+ ok: false,
93
+ json: () => Promise.resolve({ error: "Decrypt failed" }),
94
+ } as Response);
95
+
96
+ await act(async () => {
97
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
98
+ });
99
+
100
+ expect(screen.getByText("Decrypt failed")).toBeInTheDocument();
101
+ });
102
+
103
+ it("shows mode toggle when adding a key", async () => {
104
+ global.fetch = jest.fn().mockResolvedValue({
105
+ ok: true,
106
+ json: () => Promise.resolve(mockDecrypted),
107
+ } as Response);
108
+
109
+ await act(async () => {
110
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
111
+ });
112
+
113
+ await act(async () => {
114
+ fireEvent.click(screen.getByTestId("add-key-btn"));
115
+ });
116
+
117
+ expect(screen.getByTestId("mode-set-value")).toBeInTheDocument();
118
+ expect(screen.getByTestId("mode-random")).toBeInTheDocument();
119
+ expect(screen.getByTestId("new-value-input")).toBeInTheDocument();
120
+ });
121
+
122
+ it("hides value input in random mode and shows Generate button", async () => {
123
+ global.fetch = jest.fn().mockResolvedValue({
124
+ ok: true,
125
+ json: () => Promise.resolve(mockDecrypted),
126
+ } as Response);
127
+
128
+ await act(async () => {
129
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
130
+ });
131
+
132
+ await act(async () => {
133
+ fireEvent.click(screen.getByTestId("add-key-btn"));
134
+ });
135
+
136
+ await act(async () => {
137
+ fireEvent.click(screen.getByTestId("mode-random"));
138
+ });
139
+
140
+ expect(screen.queryByTestId("new-value-input")).not.toBeInTheDocument();
141
+ expect(screen.getByTestId("add-key-submit")).toHaveTextContent("Generate random value");
142
+ });
143
+
144
+ it("sends random: true when adding in random mode", async () => {
145
+ const fetchMock = jest.fn().mockResolvedValue({
146
+ ok: true,
147
+ json: () => Promise.resolve(mockDecrypted),
148
+ } as Response);
149
+ global.fetch = fetchMock;
150
+
151
+ await act(async () => {
152
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
153
+ });
154
+
155
+ await act(async () => {
156
+ fireEvent.click(screen.getByTestId("add-key-btn"));
157
+ });
158
+
159
+ await act(async () => {
160
+ fireEvent.click(screen.getByTestId("mode-random"));
161
+ });
162
+
163
+ await act(async () => {
164
+ fireEvent.change(screen.getByTestId("new-key-input"), {
165
+ target: { value: "NEW_SECRET" },
166
+ });
167
+ });
168
+
169
+ await act(async () => {
170
+ fireEvent.click(screen.getByTestId("add-key-submit"));
171
+ });
172
+
173
+ const putCall = fetchMock.mock.calls.find(
174
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
175
+ (c: any[]) =>
176
+ typeof c[0] === "string" && c[0].includes("/NEW_SECRET") && c[1]?.method === "PUT",
177
+ );
178
+ expect(putCall).toBeDefined();
179
+ const body = JSON.parse(putCall![1].body);
180
+ expect(body.random).toBe(true);
181
+ expect(body.value).toBeUndefined();
182
+ });
183
+
184
+ it("shows overflow menu with Reset to random option", async () => {
185
+ global.fetch = jest.fn().mockResolvedValue({
186
+ ok: true,
187
+ json: () => Promise.resolve(mockDecrypted),
188
+ } as Response);
189
+
190
+ await act(async () => {
191
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
192
+ });
193
+
194
+ await act(async () => {
195
+ fireEvent.click(screen.getByTestId("overflow-DB_HOST"));
196
+ });
197
+
198
+ expect(screen.getByTestId("overflow-menu-DB_HOST")).toBeInTheDocument();
199
+ expect(screen.getByTestId("reset-random-DB_HOST")).toBeInTheDocument();
200
+ });
201
+
202
+ it("shows confirmation dialog before resetting to random", async () => {
203
+ global.fetch = jest.fn().mockResolvedValue({
204
+ ok: true,
205
+ json: () => Promise.resolve(mockDecrypted),
206
+ } as Response);
207
+
208
+ await act(async () => {
209
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
210
+ });
211
+
212
+ // Open overflow menu
213
+ await act(async () => {
214
+ fireEvent.click(screen.getByTestId("overflow-DB_HOST"));
215
+ });
216
+
217
+ // Click reset to random
218
+ await act(async () => {
219
+ fireEvent.click(screen.getByTestId("reset-random-DB_HOST"));
220
+ });
221
+
222
+ expect(screen.getByTestId("confirm-reset-dialog")).toBeInTheDocument();
223
+ expect(screen.getByText(/current value will be overwritten/)).toBeInTheDocument();
224
+ });
225
+
226
+ it("cancels reset when Cancel is clicked in confirmation", async () => {
227
+ global.fetch = jest.fn().mockResolvedValue({
228
+ ok: true,
229
+ json: () => Promise.resolve(mockDecrypted),
230
+ } as Response);
231
+
232
+ await act(async () => {
233
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
234
+ });
235
+
236
+ await act(async () => {
237
+ fireEvent.click(screen.getByTestId("overflow-DB_HOST"));
238
+ });
239
+
240
+ await act(async () => {
241
+ fireEvent.click(screen.getByTestId("reset-random-DB_HOST"));
242
+ });
243
+
244
+ // The confirmation dialog is visible
245
+ expect(screen.getByTestId("confirm-reset-dialog")).toBeInTheDocument();
246
+
247
+ // Click Cancel in the confirmation dialog
248
+ await act(async () => {
249
+ fireEvent.click(screen.getByTestId("confirm-reset-no"));
250
+ });
251
+
252
+ expect(screen.queryByTestId("confirm-reset-dialog")).not.toBeInTheDocument();
253
+ });
254
+
255
+ it("sends random: true when confirming reset to random", async () => {
256
+ const fetchMock = jest.fn().mockResolvedValue({
257
+ ok: true,
258
+ json: () => Promise.resolve(mockDecrypted),
259
+ } as Response);
260
+ global.fetch = fetchMock;
261
+
262
+ await act(async () => {
263
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
264
+ });
265
+
266
+ await act(async () => {
267
+ fireEvent.click(screen.getByTestId("overflow-DB_HOST"));
268
+ });
269
+
270
+ await act(async () => {
271
+ fireEvent.click(screen.getByTestId("reset-random-DB_HOST"));
272
+ });
273
+
274
+ // Click "Reset to random" button in the confirmation dialog
275
+ await act(async () => {
276
+ fireEvent.click(screen.getByTestId("confirm-reset-yes"));
277
+ });
278
+
279
+ const putCall = fetchMock.mock.calls.find(
280
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
281
+ (c: any[]) => typeof c[0] === "string" && c[0].includes("/DB_HOST") && c[1]?.method === "PUT",
282
+ );
283
+ expect(putCall).toBeDefined();
284
+ const body = JSON.parse(putCall![1].body);
285
+ expect(body.random).toBe(true);
286
+ });
287
+
288
+ it("marks row dirty when value is edited", async () => {
289
+ global.fetch = jest.fn().mockResolvedValue({
290
+ ok: true,
291
+ json: () => Promise.resolve(mockDecrypted),
292
+ } as Response);
293
+
294
+ await act(async () => {
295
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
296
+ });
297
+
298
+ // Reveal the value
299
+ await act(async () => {
300
+ fireEvent.click(screen.getByTestId("eye-DB_HOST"));
301
+ });
302
+
303
+ // Edit the value
304
+ await act(async () => {
305
+ fireEvent.change(screen.getByTestId("value-input-DB_HOST"), {
306
+ target: { value: "newhost" },
307
+ });
308
+ });
309
+
310
+ expect(screen.getByTestId("dirty-dot")).toBeInTheDocument();
311
+ });
312
+
313
+ it("clears decrypted values from state on idle timeout", async () => {
314
+ jest.useFakeTimers();
315
+ global.fetch = jest.fn().mockResolvedValue({
316
+ ok: true,
317
+ json: () => Promise.resolve(mockDecrypted),
318
+ } as Response);
319
+
320
+ await act(async () => {
321
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
322
+ });
323
+
324
+ // Reveal a value to start the timer
325
+ await act(async () => {
326
+ fireEvent.click(screen.getByTestId("eye-DB_HOST"));
327
+ });
328
+
329
+ // Value should be visible
330
+ expect(screen.getByTestId("value-input-DB_HOST")).toBeInTheDocument();
331
+
332
+ // Advance time past the 5-minute idle timeout
333
+ await act(async () => {
334
+ jest.advanceTimersByTime(5 * 60 * 1000 + 100);
335
+ });
336
+
337
+ // Value should no longer be visible (masked)
338
+ expect(screen.queryByTestId("value-input-DB_HOST")).not.toBeInTheDocument();
339
+
340
+ jest.useRealTimers();
341
+ });
342
+
343
+ it("shows protected confirmation dialog when adding a key in production", async () => {
344
+ global.fetch = jest.fn().mockResolvedValue({
345
+ ok: true,
346
+ json: () => Promise.resolve(mockDecrypted),
347
+ } as Response);
348
+
349
+ await act(async () => {
350
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
351
+ });
352
+
353
+ // Switch to production tab
354
+ const prodTab = screen.getAllByRole("tab").find((t) => t.textContent?.includes("production"));
355
+ await act(async () => {
356
+ if (prodTab) fireEvent.click(prodTab);
357
+ });
358
+
359
+ // Open add key form
360
+ await act(async () => {
361
+ fireEvent.click(screen.getByTestId("add-key-btn"));
362
+ });
363
+
364
+ await act(async () => {
365
+ fireEvent.change(screen.getByTestId("new-key-input"), {
366
+ target: { value: "PROD_SECRET" },
367
+ });
368
+ });
369
+
370
+ // Click Add — should show confirmation instead of sending request
371
+ await act(async () => {
372
+ fireEvent.click(screen.getByTestId("add-key-submit"));
373
+ });
374
+
375
+ expect(screen.getByTestId("confirm-protected-dialog")).toBeInTheDocument();
376
+ expect(screen.getByText(/Protected environment/)).toBeInTheDocument();
377
+ });
378
+
379
+ it("sends confirmed: true after confirming protected add", async () => {
380
+ const fetchMock = jest.fn().mockResolvedValue({
381
+ ok: true,
382
+ json: () => Promise.resolve(mockDecrypted),
383
+ } as Response);
384
+ global.fetch = fetchMock;
385
+
386
+ await act(async () => {
387
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
388
+ });
389
+
390
+ // Switch to production tab
391
+ const prodTab = screen.getAllByRole("tab").find((t) => t.textContent?.includes("production"));
392
+ await act(async () => {
393
+ if (prodTab) fireEvent.click(prodTab);
394
+ });
395
+
396
+ await act(async () => {
397
+ fireEvent.click(screen.getByTestId("add-key-btn"));
398
+ });
399
+
400
+ await act(async () => {
401
+ fireEvent.change(screen.getByTestId("new-key-input"), {
402
+ target: { value: "PROD_SECRET" },
403
+ });
404
+ fireEvent.change(screen.getByTestId("new-value-input"), {
405
+ target: { value: "s3cret" },
406
+ });
407
+ });
408
+
409
+ // Click Add — triggers confirmation
410
+ await act(async () => {
411
+ fireEvent.click(screen.getByTestId("add-key-submit"));
412
+ });
413
+
414
+ // Confirm
415
+ await act(async () => {
416
+ fireEvent.click(screen.getByTestId("confirm-protected-yes"));
417
+ });
418
+
419
+ const putCall = fetchMock.mock.calls.find(
420
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
421
+ (c: any[]) =>
422
+ typeof c[0] === "string" && c[0].includes("/PROD_SECRET") && c[1]?.method === "PUT",
423
+ );
424
+ expect(putCall).toBeDefined();
425
+ const body = JSON.parse(putCall![1].body);
426
+ expect(body.confirmed).toBe(true);
427
+ expect(body.value).toBe("s3cret");
428
+ });
429
+
430
+ it("cancels protected add when Cancel is clicked", async () => {
431
+ const fetchMock = jest.fn().mockResolvedValue({
432
+ ok: true,
433
+ json: () => Promise.resolve(mockDecrypted),
434
+ } as Response);
435
+ global.fetch = fetchMock;
436
+
437
+ await act(async () => {
438
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
439
+ });
440
+
441
+ // Switch to production tab
442
+ const prodTab = screen.getAllByRole("tab").find((t) => t.textContent?.includes("production"));
443
+ await act(async () => {
444
+ if (prodTab) fireEvent.click(prodTab);
445
+ });
446
+
447
+ await act(async () => {
448
+ fireEvent.click(screen.getByTestId("add-key-btn"));
449
+ });
450
+
451
+ await act(async () => {
452
+ fireEvent.change(screen.getByTestId("new-key-input"), {
453
+ target: { value: "PROD_SECRET" },
454
+ });
455
+ });
456
+
457
+ await act(async () => {
458
+ fireEvent.click(screen.getByTestId("add-key-submit"));
459
+ });
460
+
461
+ // Cancel the confirmation
462
+ await act(async () => {
463
+ fireEvent.click(screen.getByTestId("confirm-protected-no"));
464
+ });
465
+
466
+ expect(screen.queryByTestId("confirm-protected-dialog")).not.toBeInTheDocument();
467
+ // No PUT call to PROD_SECRET should have been made
468
+ const putCall = fetchMock.mock.calls.find(
469
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
470
+ (c: any[]) =>
471
+ typeof c[0] === "string" && c[0].includes("/PROD_SECRET") && c[1]?.method === "PUT",
472
+ );
473
+ expect(putCall).toBeUndefined();
474
+ });
475
+
476
+ it("shows protected warning in reset confirmation for production env", async () => {
477
+ global.fetch = jest.fn().mockResolvedValue({
478
+ ok: true,
479
+ json: () => Promise.resolve(mockDecrypted),
480
+ } as Response);
481
+
482
+ await act(async () => {
483
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
484
+ });
485
+
486
+ // Switch to production tab
487
+ const prodTab = screen.getAllByRole("tab").find((t) => t.textContent?.includes("production"));
488
+ await act(async () => {
489
+ if (prodTab) fireEvent.click(prodTab);
490
+ });
491
+
492
+ // Open overflow menu and click reset
493
+ await act(async () => {
494
+ fireEvent.click(screen.getByTestId("overflow-DB_HOST"));
495
+ });
496
+
497
+ await act(async () => {
498
+ fireEvent.click(screen.getByTestId("reset-random-DB_HOST"));
499
+ });
500
+
501
+ expect(screen.getByTestId("confirm-reset-dialog")).toBeInTheDocument();
502
+ expect(screen.getByText(/This is a protected environment/)).toBeInTheDocument();
503
+ });
504
+
505
+ it("does not show protected confirmation for non-production env", async () => {
506
+ global.fetch = jest.fn().mockResolvedValue({
507
+ ok: true,
508
+ json: () => Promise.resolve(mockDecrypted),
509
+ } as Response);
510
+
511
+ await act(async () => {
512
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
513
+ });
514
+
515
+ // Stay on dev tab (default)
516
+ await act(async () => {
517
+ fireEvent.click(screen.getByTestId("add-key-btn"));
518
+ });
519
+
520
+ await act(async () => {
521
+ fireEvent.change(screen.getByTestId("new-key-input"), {
522
+ target: { value: "DEV_SECRET" },
523
+ });
524
+ });
525
+
526
+ // Click Add — should NOT show confirmation dialog
527
+ await act(async () => {
528
+ fireEvent.click(screen.getByTestId("add-key-submit"));
529
+ });
530
+
531
+ expect(screen.queryByTestId("confirm-protected-dialog")).not.toBeInTheDocument();
532
+ });
533
+
534
+ it("shows accept button for pending keys", async () => {
535
+ const decryptedWithPending = {
536
+ ...mockDecrypted,
537
+ pending: ["DB_HOST"],
538
+ };
539
+ global.fetch = jest.fn().mockResolvedValue({
540
+ ok: true,
541
+ json: () => Promise.resolve(decryptedWithPending),
542
+ } as Response);
543
+
544
+ await act(async () => {
545
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
546
+ });
547
+
548
+ expect(screen.getByTestId("accept-value-DB_HOST")).toBeInTheDocument();
549
+ expect(screen.getByTestId("set-value-DB_HOST")).toBeInTheDocument();
550
+ });
551
+
552
+ it("calls accept endpoint and updates row with returned value", async () => {
553
+ const decryptedWithPending = {
554
+ ...mockDecrypted,
555
+ pending: ["DB_HOST"],
556
+ };
557
+ const fetchMock = jest.fn().mockImplementation((url: string, opts?: RequestInit) => {
558
+ if (typeof url === "string" && url.includes("/accept") && opts?.method === "POST") {
559
+ return Promise.resolve({
560
+ ok: true,
561
+ json: () => Promise.resolve({ success: true, key: "DB_HOST", value: "abc123" }),
562
+ } as Response);
563
+ }
564
+ return Promise.resolve({
565
+ ok: true,
566
+ json: () => Promise.resolve(decryptedWithPending),
567
+ } as Response);
568
+ });
569
+ global.fetch = fetchMock;
570
+
571
+ await act(async () => {
572
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
573
+ });
574
+
575
+ // Click accept
576
+ await act(async () => {
577
+ fireEvent.click(screen.getByTestId("accept-value-DB_HOST"));
578
+ });
579
+
580
+ // Verify the accept endpoint was called
581
+ const acceptCall = fetchMock.mock.calls.find(
582
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
583
+ (c: any[]) =>
584
+ typeof c[0] === "string" && c[0].includes("/DB_HOST/accept") && c[1]?.method === "POST",
585
+ );
586
+ expect(acceptCall).toBeDefined();
587
+
588
+ // Accept button should be gone (no longer pending)
589
+ expect(screen.queryByTestId("accept-value-DB_HOST")).not.toBeInTheDocument();
590
+ // Eye icon should be present (can reveal the value)
591
+ expect(screen.getByTestId("eye-DB_HOST")).toBeInTheDocument();
592
+ });
593
+
594
+ it("shows delete option in overflow menu", async () => {
595
+ global.fetch = jest.fn().mockResolvedValue({
596
+ ok: true,
597
+ json: () => Promise.resolve(mockDecrypted),
598
+ } as Response);
599
+
600
+ await act(async () => {
601
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
602
+ });
603
+
604
+ await act(async () => {
605
+ fireEvent.click(screen.getByTestId("overflow-DB_HOST"));
606
+ });
607
+
608
+ expect(screen.getByTestId("delete-key-DB_HOST")).toBeInTheDocument();
609
+ });
610
+
611
+ it("shows confirmation dialog before deleting a key", async () => {
612
+ global.fetch = jest.fn().mockResolvedValue({
613
+ ok: true,
614
+ json: () => Promise.resolve(mockDecrypted),
615
+ } as Response);
616
+
617
+ await act(async () => {
618
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
619
+ });
620
+
621
+ await act(async () => {
622
+ fireEvent.click(screen.getByTestId("overflow-DB_HOST"));
623
+ });
624
+
625
+ await act(async () => {
626
+ fireEvent.click(screen.getByTestId("delete-key-DB_HOST"));
627
+ });
628
+
629
+ expect(screen.getByTestId("confirm-delete-dialog")).toBeInTheDocument();
630
+ expect(screen.getByText(/Permanently delete/)).toBeInTheDocument();
631
+ });
632
+
633
+ it("deletes key after confirming", async () => {
634
+ const fetchMock = jest.fn().mockResolvedValue({
635
+ ok: true,
636
+ json: () => Promise.resolve(mockDecrypted),
637
+ } as Response);
638
+ global.fetch = fetchMock;
639
+
640
+ await act(async () => {
641
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
642
+ });
643
+
644
+ await act(async () => {
645
+ fireEvent.click(screen.getByTestId("overflow-DB_HOST"));
646
+ });
647
+
648
+ await act(async () => {
649
+ fireEvent.click(screen.getByTestId("delete-key-DB_HOST"));
650
+ });
651
+
652
+ await act(async () => {
653
+ fireEvent.click(screen.getByTestId("confirm-delete-yes"));
654
+ });
655
+
656
+ // Verify DELETE was called
657
+ const deleteCall = fetchMock.mock.calls.find(
658
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
659
+ (c: any[]) =>
660
+ typeof c[0] === "string" && c[0].includes("/DB_HOST") && c[1]?.method === "DELETE",
661
+ );
662
+ expect(deleteCall).toBeDefined();
663
+
664
+ // Row should be removed
665
+ expect(screen.queryByText("DB_HOST")).not.toBeInTheDocument();
666
+ });
667
+
668
+ it("cancels delete when Cancel is clicked", async () => {
669
+ global.fetch = jest.fn().mockResolvedValue({
670
+ ok: true,
671
+ json: () => Promise.resolve(mockDecrypted),
672
+ } as Response);
673
+
674
+ await act(async () => {
675
+ render(<NamespaceEditor ns="database" manifest={manifest} onCommit={jest.fn()} />);
676
+ });
677
+
678
+ await act(async () => {
679
+ fireEvent.click(screen.getByTestId("overflow-DB_HOST"));
680
+ });
681
+
682
+ await act(async () => {
683
+ fireEvent.click(screen.getByTestId("delete-key-DB_HOST"));
684
+ });
685
+
686
+ await act(async () => {
687
+ fireEvent.click(screen.getByTestId("confirm-delete-no"));
688
+ });
689
+
690
+ expect(screen.queryByTestId("confirm-delete-dialog")).not.toBeInTheDocument();
691
+ // Key should still be there
692
+ expect(screen.getByText("DB_HOST")).toBeInTheDocument();
693
+ });
694
+ });