@clef-sh/ui 0.1.20 → 0.1.21

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 (103) hide show
  1. package/dist/client/assets/index-DPWHjBbB.js +34 -0
  2. package/dist/client/assets/index-qsLTYpc9.css +2 -0
  3. package/dist/client/clef.svg +2 -0
  4. package/dist/client/index.html +3 -31
  5. package/dist/client-lib/components/Button.d.ts +1 -1
  6. package/dist/client-lib/components/Button.d.ts.map +1 -1
  7. package/dist/client-lib/components/CopyButton.d.ts.map +1 -1
  8. package/dist/client-lib/components/EnvBadge.d.ts.map +1 -1
  9. package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -1
  10. package/dist/client-lib/components/Sidebar.d.ts +1 -1
  11. package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
  12. package/dist/client-lib/components/StatusDot.d.ts.map +1 -1
  13. package/dist/client-lib/components/SyncPanel.d.ts.map +1 -1
  14. package/dist/client-lib/components/TopBar.d.ts +6 -0
  15. package/dist/client-lib/components/TopBar.d.ts.map +1 -1
  16. package/dist/client-lib/primitives/Badge.d.ts +11 -0
  17. package/dist/client-lib/primitives/Badge.d.ts.map +1 -0
  18. package/dist/client-lib/primitives/Card.d.ts +28 -0
  19. package/dist/client-lib/primitives/Card.d.ts.map +1 -0
  20. package/dist/client-lib/primitives/Dialog.d.ts +30 -0
  21. package/dist/client-lib/primitives/Dialog.d.ts.map +1 -0
  22. package/dist/client-lib/primitives/EmptyState.d.ts +10 -0
  23. package/dist/client-lib/primitives/EmptyState.d.ts.map +1 -0
  24. package/dist/client-lib/primitives/Field.d.ts +36 -0
  25. package/dist/client-lib/primitives/Field.d.ts.map +1 -0
  26. package/dist/client-lib/primitives/Input.d.ts +6 -0
  27. package/dist/client-lib/primitives/Input.d.ts.map +1 -0
  28. package/dist/client-lib/primitives/Stat.d.ts +11 -0
  29. package/dist/client-lib/primitives/Stat.d.ts.map +1 -0
  30. package/dist/client-lib/primitives/Table.d.ts +37 -0
  31. package/dist/client-lib/primitives/Table.d.ts.map +1 -0
  32. package/dist/client-lib/primitives/Tabs.d.ts +29 -0
  33. package/dist/client-lib/primitives/Tabs.d.ts.map +1 -0
  34. package/dist/client-lib/primitives/Toast.d.ts +16 -0
  35. package/dist/client-lib/primitives/Toast.d.ts.map +1 -0
  36. package/dist/client-lib/primitives/Toolbar.d.ts +29 -0
  37. package/dist/client-lib/primitives/Toolbar.d.ts.map +1 -0
  38. package/dist/client-lib/primitives/index.d.ts +23 -0
  39. package/dist/client-lib/primitives/index.d.ts.map +1 -0
  40. package/dist/client-lib/theme.d.ts +18 -41
  41. package/dist/client-lib/theme.d.ts.map +1 -1
  42. package/dist/server/api.d.ts.map +1 -1
  43. package/dist/server/api.js +215 -0
  44. package/dist/server/api.js.map +1 -1
  45. package/dist/server/envelope.d.ts +15 -0
  46. package/dist/server/envelope.d.ts.map +1 -0
  47. package/dist/server/envelope.js +310 -0
  48. package/dist/server/envelope.js.map +1 -0
  49. package/package.json +7 -2
  50. package/src/client/App.tsx +16 -41
  51. package/src/client/components/Button.tsx +13 -22
  52. package/src/client/components/CopyButton.tsx +5 -12
  53. package/src/client/components/EnvBadge.tsx +30 -15
  54. package/src/client/components/MatrixGrid.tsx +108 -252
  55. package/src/client/components/Sidebar.tsx +123 -199
  56. package/src/client/components/StatusDot.tsx +10 -15
  57. package/src/client/components/SyncPanel.tsx +14 -62
  58. package/src/client/components/TopBar.tsx +11 -36
  59. package/src/client/index.html +1 -30
  60. package/src/client/main.tsx +1 -0
  61. package/src/client/primitives/Badge.test.tsx +47 -0
  62. package/src/client/primitives/Badge.tsx +64 -0
  63. package/src/client/primitives/Card.test.tsx +50 -0
  64. package/src/client/primitives/Card.tsx +85 -0
  65. package/src/client/primitives/Dialog.test.tsx +55 -0
  66. package/src/client/primitives/Dialog.tsx +96 -0
  67. package/src/client/primitives/EmptyState.test.tsx +25 -0
  68. package/src/client/primitives/EmptyState.tsx +38 -0
  69. package/src/client/primitives/Field.test.tsx +46 -0
  70. package/src/client/primitives/Field.tsx +95 -0
  71. package/src/client/primitives/Input.tsx +26 -0
  72. package/src/client/primitives/Stat.test.tsx +32 -0
  73. package/src/client/primitives/Stat.tsx +52 -0
  74. package/src/client/primitives/Table.test.tsx +58 -0
  75. package/src/client/primitives/Table.tsx +113 -0
  76. package/src/client/primitives/Tabs.test.tsx +44 -0
  77. package/src/client/primitives/Tabs.tsx +100 -0
  78. package/src/client/primitives/Toast.test.tsx +77 -0
  79. package/src/client/primitives/Toast.tsx +89 -0
  80. package/src/client/primitives/Toolbar.test.tsx +50 -0
  81. package/src/client/primitives/Toolbar.tsx +86 -0
  82. package/src/client/primitives/index.ts +43 -0
  83. package/src/client/public/clef.svg +2 -0
  84. package/src/client/screens/BackendScreen.tsx +104 -363
  85. package/src/client/screens/DiffView.tsx +187 -378
  86. package/src/client/screens/EnvelopeScreen.test.tsx +542 -0
  87. package/src/client/screens/EnvelopeScreen.tsx +948 -0
  88. package/src/client/screens/GitLogView.tsx +48 -106
  89. package/src/client/screens/ImportScreen.tsx +105 -308
  90. package/src/client/screens/LintView.tsx +184 -379
  91. package/src/client/screens/ManifestScreen.tsx +283 -445
  92. package/src/client/screens/MatrixView.tsx +75 -91
  93. package/src/client/screens/NamespaceEditor.tsx +234 -609
  94. package/src/client/screens/PolicyView.tsx +183 -453
  95. package/src/client/screens/RecipientsScreen.tsx +71 -350
  96. package/src/client/screens/ResetScreen.tsx +67 -237
  97. package/src/client/screens/ScanScreen.tsx +85 -249
  98. package/src/client/screens/SchemaEditor.test.tsx +237 -0
  99. package/src/client/screens/SchemaEditor.tsx +435 -0
  100. package/src/client/screens/ServiceIdentitiesScreen.tsx +251 -788
  101. package/src/client/styles.css +77 -0
  102. package/src/client/theme.ts +27 -48
  103. package/dist/client/assets/index-Db6WgHgY.js +0 -38
@@ -0,0 +1,542 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act, within } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { EnvelopeScreen } from "./EnvelopeScreen";
5
+
6
+ /* eslint-disable @typescript-eslint/no-explicit-any */
7
+ declare let global: any;
8
+
9
+ // ──────────────────────────────────────────────────────────────────────
10
+ // Fixtures
11
+ // ──────────────────────────────────────────────────────────────────────
12
+
13
+ const ARTIFACT_JSON = JSON.stringify({
14
+ version: 1,
15
+ identity: "aws-lambda",
16
+ environment: "dev",
17
+ packedAt: "2026-04-23T06:00:00.000Z",
18
+ revision: "1776880279983-24310ee5",
19
+ ciphertext: "ZmFrZS1hZ2UtY2lwaGVydGV4dC1mb3ItdGVzdGluZw==",
20
+ ciphertextHash: "b555077dd41b180ebae2c2fc96665cebe1b9c164ca418c2b132786fdbec267fb",
21
+ });
22
+
23
+ const inspectOk = {
24
+ source: "paste",
25
+ version: 1,
26
+ identity: "aws-lambda",
27
+ environment: "dev",
28
+ packedAt: "2026-04-23T06:00:00.000Z",
29
+ packedAtAgeMs: 21600000,
30
+ revision: "1776880279983-24310ee5",
31
+ ciphertextHash: "b555077dd41b180ebae2c2fc96665cebe1b9c164ca418c2b132786fdbec267fb",
32
+ ciphertextHashVerified: true,
33
+ ciphertextBytes: 31,
34
+ expiresAt: null,
35
+ expired: null,
36
+ revokedAt: null,
37
+ revoked: false,
38
+ envelope: { provider: "age", kms: null },
39
+ signature: { present: false, algorithm: null, verified: null },
40
+ error: null,
41
+ };
42
+
43
+ const verifyPass = {
44
+ source: "paste",
45
+ checks: {
46
+ hash: { status: "ok" },
47
+ signature: { status: "absent", algorithm: null },
48
+ expiry: { status: "absent", expiresAt: null },
49
+ revocation: { status: "absent", revokedAt: null },
50
+ },
51
+ overall: "pass",
52
+ error: null,
53
+ };
54
+
55
+ const decryptKeysOnly = {
56
+ source: "paste",
57
+ status: "ok",
58
+ error: null,
59
+ revealed: false,
60
+ keys: ["API_KEY", "DB_URL", "REDIS_URL"],
61
+ values: null,
62
+ };
63
+
64
+ const decryptRevealed = {
65
+ source: "paste",
66
+ status: "ok",
67
+ error: null,
68
+ revealed: true,
69
+ keys: ["API_KEY", "DB_URL", "REDIS_URL"],
70
+ values: { DB_URL: "postgres://prod", REDIS_URL: "redis://prod", API_KEY: "sk-123" },
71
+ };
72
+
73
+ const decryptSingleKey = (key: string, value: string) => ({
74
+ source: "paste",
75
+ status: "ok",
76
+ error: null,
77
+ revealed: true,
78
+ keys: ["API_KEY", "DB_URL", "REDIS_URL"],
79
+ values: { [key]: value },
80
+ });
81
+
82
+ const configConfigured = {
83
+ ageIdentity: { configured: true, source: "CLEF_AGE_KEY_FILE", path: "/home/op/.age/key.txt" },
84
+ aws: { hasCredentials: false, profile: null },
85
+ };
86
+
87
+ const configMissing = {
88
+ ageIdentity: { configured: false, source: null, path: null },
89
+ aws: { hasCredentials: false, profile: null },
90
+ };
91
+
92
+ // ──────────────────────────────────────────────────────────────────────
93
+ // Helpers
94
+ // ──────────────────────────────────────────────────────────────────────
95
+
96
+ interface RouteStub {
97
+ match: (url: string, init?: RequestInit) => boolean;
98
+ status?: number;
99
+ body: unknown;
100
+ }
101
+
102
+ function routeStubs(stubs: RouteStub[]): jest.Mock {
103
+ const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
104
+ const u = typeof url === "string" ? url : url.toString();
105
+ const match = stubs.find((s) => s.match(u, init));
106
+ if (!match) throw new Error(`unexpected fetch: ${init?.method ?? "GET"} ${u}`);
107
+ return {
108
+ ok: (match.status ?? 200) < 400,
109
+ status: match.status ?? 200,
110
+ json: async () => match.body,
111
+ } as Response;
112
+ });
113
+ return fetchMock as unknown as jest.Mock;
114
+ }
115
+
116
+ async function typeAndLoad(json: string): Promise<void> {
117
+ const textarea = screen.getByTestId("envelope-paste-textarea");
118
+ await act(async () => {
119
+ fireEvent.change(textarea, { target: { value: json } });
120
+ });
121
+ await act(async () => {
122
+ fireEvent.click(screen.getByRole("button", { name: /Load/i }));
123
+ });
124
+ }
125
+
126
+ // ──────────────────────────────────────────────────────────────────────
127
+
128
+ beforeEach(() => {
129
+ jest.clearAllMocks();
130
+ jest.useRealTimers();
131
+ delete (global as any).fetch;
132
+ // jsdom: provide a stable window.URL.createObjectURL for Export JSON.
133
+ if (typeof URL.createObjectURL !== "function") {
134
+ (URL as any).createObjectURL = jest.fn(() => "blob:test");
135
+ }
136
+ if (typeof URL.revokeObjectURL !== "function") {
137
+ (URL as any).revokeObjectURL = jest.fn();
138
+ }
139
+ });
140
+
141
+ afterEach(() => {
142
+ jest.useRealTimers();
143
+ });
144
+
145
+ describe("EnvelopeScreen", () => {
146
+ it("loads config on mount and shows identity source on the Decrypt card before paste", async () => {
147
+ global.fetch = routeStubs([
148
+ {
149
+ match: (u) => u.endsWith("/api/envelope/config"),
150
+ body: configConfigured,
151
+ },
152
+ ]);
153
+
154
+ await act(async () => {
155
+ render(<EnvelopeScreen />);
156
+ });
157
+
158
+ // Decrypt card is only rendered after a valid paste, so just pin
159
+ // the initial config call.
160
+ expect(global.fetch).toHaveBeenCalledWith("/api/envelope/config", expect.objectContaining({}));
161
+ });
162
+
163
+ it("paste → Load populates the Inspect card; Verify + Decrypt cards appear", async () => {
164
+ global.fetch = routeStubs([
165
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
166
+ {
167
+ match: (u, init) => u.endsWith("/api/envelope/inspect") && init?.method === "POST",
168
+ body: inspectOk,
169
+ },
170
+ ]);
171
+
172
+ await act(async () => {
173
+ render(<EnvelopeScreen />);
174
+ });
175
+ await typeAndLoad(ARTIFACT_JSON);
176
+
177
+ expect(screen.getByTestId("envelope-card-inspect")).toBeInTheDocument();
178
+ expect(screen.getByTestId("envelope-card-verify")).toBeInTheDocument();
179
+ expect(screen.getByTestId("envelope-card-decrypt")).toBeInTheDocument();
180
+ expect(screen.getByText("aws-lambda")).toBeInTheDocument();
181
+ });
182
+
183
+ it("disables Load and shows an invalid state when the paste is not JSON", async () => {
184
+ global.fetch = routeStubs([
185
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
186
+ ]);
187
+
188
+ await act(async () => {
189
+ render(<EnvelopeScreen />);
190
+ });
191
+ const textarea = screen.getByTestId("envelope-paste-textarea");
192
+ await act(async () => {
193
+ fireEvent.change(textarea, { target: { value: "{ not-json" } });
194
+ });
195
+
196
+ expect(screen.getByTestId("paste-status").textContent).toMatch(/invalid/i);
197
+ expect(screen.getByRole("button", { name: /Load/i })).toBeDisabled();
198
+ });
199
+
200
+ it("runs verify and renders an OVERALL: PASS banner when the server returns pass", async () => {
201
+ global.fetch = routeStubs([
202
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
203
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
204
+ { match: (u) => u.endsWith("/api/envelope/verify"), body: verifyPass },
205
+ ]);
206
+
207
+ await act(async () => {
208
+ render(<EnvelopeScreen />);
209
+ });
210
+ await typeAndLoad(ARTIFACT_JSON);
211
+ await act(async () => {
212
+ fireEvent.click(screen.getByRole("button", { name: /Run verify/i }));
213
+ });
214
+
215
+ expect(screen.getByTestId("verify-overall").textContent).toMatch(/PASS/);
216
+ });
217
+
218
+ it("decrypt (keys) shows a row per key with masked values; per-row reveal populates one value", async () => {
219
+ global.fetch = routeStubs([
220
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
221
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
222
+ {
223
+ match: (u, init) => {
224
+ if (!u.endsWith("/api/envelope/decrypt") || init?.method !== "POST") return false;
225
+ const body = JSON.parse(String(init?.body ?? "{}"));
226
+ return body.reveal !== true && body.key === undefined;
227
+ },
228
+ body: decryptKeysOnly,
229
+ },
230
+ {
231
+ match: (u, init) => {
232
+ if (!u.endsWith("/api/envelope/decrypt") || init?.method !== "POST") return false;
233
+ const body = JSON.parse(String(init?.body ?? "{}"));
234
+ return body.key === "DB_URL";
235
+ },
236
+ body: decryptSingleKey("DB_URL", "postgres://prod"),
237
+ },
238
+ ]);
239
+
240
+ await act(async () => {
241
+ render(<EnvelopeScreen />);
242
+ });
243
+ await typeAndLoad(ARTIFACT_JSON);
244
+ await act(async () => {
245
+ fireEvent.click(screen.getByTestId("decrypt-keys"));
246
+ });
247
+
248
+ // All three rows present with masked placeholders.
249
+ expect(screen.getByTestId("decrypt-row-DB_URL")).toBeInTheDocument();
250
+ expect(screen.getByTestId("decrypt-value-DB_URL").textContent).toMatch(/●/);
251
+
252
+ await act(async () => {
253
+ fireEvent.click(screen.getByTestId("reveal-toggle-DB_URL"));
254
+ });
255
+
256
+ expect(screen.getByTestId("decrypt-value-DB_URL").textContent).toBe("postgres://prod");
257
+ // The other rows remain masked.
258
+ expect(screen.getByTestId("decrypt-value-API_KEY").textContent).toMatch(/●/);
259
+ // Reveal banner shows the key-named copy.
260
+ expect(screen.getByTestId("reveal-banner").textContent).toMatch(/"DB_URL"/);
261
+ });
262
+
263
+ it("Reveal all populates every value and shows the general warning banner", async () => {
264
+ global.fetch = routeStubs([
265
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
266
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
267
+ {
268
+ match: (u, init) => {
269
+ if (!u.endsWith("/api/envelope/decrypt") || init?.method !== "POST") return false;
270
+ const body = JSON.parse(String(init?.body ?? "{}"));
271
+ return body.reveal !== true && body.key === undefined;
272
+ },
273
+ body: decryptKeysOnly,
274
+ },
275
+ {
276
+ match: (u, init) => {
277
+ if (!u.endsWith("/api/envelope/decrypt") || init?.method !== "POST") return false;
278
+ const body = JSON.parse(String(init?.body ?? "{}"));
279
+ return body.reveal === true && body.key === undefined;
280
+ },
281
+ body: decryptRevealed,
282
+ },
283
+ ]);
284
+
285
+ await act(async () => {
286
+ render(<EnvelopeScreen />);
287
+ });
288
+ await typeAndLoad(ARTIFACT_JSON);
289
+ await act(async () => {
290
+ fireEvent.click(screen.getByTestId("decrypt-keys"));
291
+ });
292
+ await act(async () => {
293
+ fireEvent.click(screen.getByTestId("reveal-all"));
294
+ });
295
+
296
+ expect(screen.getByTestId("decrypt-value-DB_URL").textContent).toBe("postgres://prod");
297
+ expect(screen.getByTestId("decrypt-value-API_KEY").textContent).toBe("sk-123");
298
+ expect(screen.getByTestId("reveal-banner").textContent).toMatch(/all decrypted values/);
299
+ expect(screen.getByTestId("reveal-countdown").textContent).toMatch(/0:1[0-5]/);
300
+ });
301
+
302
+ it("clears revealed values after the 15-second auto-clear timer fires", async () => {
303
+ global.fetch = routeStubs([
304
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
305
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
306
+ {
307
+ match: (u, init) => {
308
+ if (!u.endsWith("/api/envelope/decrypt") || init?.method !== "POST") return false;
309
+ const body = JSON.parse(String(init?.body ?? "{}"));
310
+ return body.reveal !== true && body.key === undefined;
311
+ },
312
+ body: decryptKeysOnly,
313
+ },
314
+ {
315
+ match: (u, init) => {
316
+ if (!u.endsWith("/api/envelope/decrypt") || init?.method !== "POST") return false;
317
+ const body = JSON.parse(String(init?.body ?? "{}"));
318
+ return body.reveal === true;
319
+ },
320
+ body: decryptRevealed,
321
+ },
322
+ ]);
323
+
324
+ jest.useFakeTimers();
325
+ await act(async () => {
326
+ render(<EnvelopeScreen />);
327
+ });
328
+ await typeAndLoad(ARTIFACT_JSON);
329
+ await act(async () => {
330
+ fireEvent.click(screen.getByTestId("decrypt-keys"));
331
+ });
332
+ await act(async () => {
333
+ fireEvent.click(screen.getByTestId("reveal-all"));
334
+ });
335
+ expect(screen.getByTestId("decrypt-value-DB_URL").textContent).toBe("postgres://prod");
336
+
337
+ await act(async () => {
338
+ jest.advanceTimersByTime(15 * 1000 + 10);
339
+ });
340
+
341
+ expect(screen.getByTestId("decrypt-value-DB_URL").textContent).toMatch(/●/);
342
+ expect(screen.queryByTestId("reveal-banner")).toBeNull();
343
+ });
344
+
345
+ it("disables the initial Decrypt button when the server has no identity configured", async () => {
346
+ global.fetch = routeStubs([
347
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configMissing },
348
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
349
+ ]);
350
+
351
+ await act(async () => {
352
+ render(<EnvelopeScreen />);
353
+ });
354
+ await typeAndLoad(ARTIFACT_JSON);
355
+
356
+ const btn = screen.getByTestId("decrypt-keys");
357
+ expect(btn).toBeDisabled();
358
+ // Server-identity hint is visible in the card subtitle.
359
+ expect(screen.getByTestId("envelope-card-decrypt").textContent).toMatch(
360
+ /no identity configured/i,
361
+ );
362
+ });
363
+
364
+ it("never writes any revealed value or raw JSON to localStorage/sessionStorage", async () => {
365
+ const setItemLS = jest.spyOn(Storage.prototype, "setItem");
366
+ global.fetch = routeStubs([
367
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
368
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
369
+ {
370
+ match: (u, init) => u.endsWith("/api/envelope/decrypt") && init?.method === "POST",
371
+ body: decryptRevealed,
372
+ },
373
+ ]);
374
+
375
+ await act(async () => {
376
+ render(<EnvelopeScreen />);
377
+ });
378
+ await typeAndLoad(ARTIFACT_JSON);
379
+ await act(async () => {
380
+ fireEvent.click(screen.getByTestId("decrypt-keys"));
381
+ });
382
+ await act(async () => {
383
+ fireEvent.click(screen.getByTestId("reveal-all"));
384
+ });
385
+
386
+ for (const call of setItemLS.mock.calls) {
387
+ const [, value] = call;
388
+ expect(String(value)).not.toContain("postgres://prod");
389
+ expect(String(value)).not.toContain("sk-123");
390
+ }
391
+ });
392
+
393
+ it("shows a relaunch-command hint when decrypt returns key_resolution_failed", async () => {
394
+ // Config reports configured: true (so the Decrypt button is enabled),
395
+ // but the decrypt endpoint then returns key_resolution_failed — the
396
+ // realistic case where an env var points to a missing/unreadable file.
397
+ global.fetch = routeStubs([
398
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
399
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
400
+ {
401
+ match: (u, init) => u.endsWith("/api/envelope/decrypt") && init?.method === "POST",
402
+ body: {
403
+ source: "paste",
404
+ status: "error",
405
+ error: { code: "key_resolution_failed", message: "No age identity configured" },
406
+ revealed: false,
407
+ keys: [],
408
+ values: null,
409
+ },
410
+ },
411
+ ]);
412
+
413
+ await act(async () => {
414
+ render(<EnvelopeScreen />);
415
+ });
416
+ await typeAndLoad(ARTIFACT_JSON);
417
+ await act(async () => {
418
+ fireEvent.click(screen.getByTestId("decrypt-keys"));
419
+ });
420
+
421
+ const hint = screen.getByTestId("envelope-error-hint");
422
+ expect(hint.textContent).toMatch(/relaunch clef ui/i);
423
+ expect(hint.textContent).toMatch(/CLEF_AGE_KEY_FILE=/);
424
+ // We deliberately no longer suggest the inline CLEF_AGE_KEY=... form —
425
+ // that lands the secret in shell history. The hint should actively
426
+ // warn against it, not offer it as an alternative.
427
+ expect(hint.textContent).toMatch(/avoid CLEF_AGE_KEY=/);
428
+ expect(hint.textContent).not.toMatch(/CLEF_AGE_KEY='AGE-SECRET-KEY-1\.\.\.'/);
429
+ });
430
+
431
+ it("warns that CLEF_AGE_KEY (inline) leaks to shell history when the server uses it", async () => {
432
+ global.fetch = routeStubs([
433
+ {
434
+ match: (u) => u.endsWith("/api/envelope/config"),
435
+ body: {
436
+ ageIdentity: { configured: true, source: "CLEF_AGE_KEY", path: null },
437
+ aws: { hasCredentials: false, profile: null },
438
+ },
439
+ },
440
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
441
+ ]);
442
+
443
+ await act(async () => {
444
+ render(<EnvelopeScreen />);
445
+ });
446
+ await typeAndLoad(ARTIFACT_JSON);
447
+
448
+ const warning = screen.getByTestId("inline-key-warning");
449
+ expect(warning.textContent).toMatch(/shell history/i);
450
+ expect(warning.textContent).toMatch(/CLEF_AGE_KEY_FILE=/);
451
+ expect(warning.textContent).toMatch(/Rotate/i);
452
+ });
453
+
454
+ it("does not render the inline-key warning when the source is CLEF_AGE_KEY_FILE", async () => {
455
+ global.fetch = routeStubs([
456
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
457
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
458
+ ]);
459
+
460
+ await act(async () => {
461
+ render(<EnvelopeScreen />);
462
+ });
463
+ await typeAndLoad(ARTIFACT_JSON);
464
+
465
+ expect(screen.queryByTestId("inline-key-warning")).toBeNull();
466
+ });
467
+
468
+ it("shows a service-identity hint when decrypt fails with 'no identity matched'", async () => {
469
+ global.fetch = routeStubs([
470
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
471
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
472
+ {
473
+ match: (u, init) => u.endsWith("/api/envelope/decrypt") && init?.method === "POST",
474
+ body: {
475
+ source: "paste",
476
+ status: "error",
477
+ error: {
478
+ code: "decrypt_failed",
479
+ message: "no identity matched any of the file's recipients",
480
+ },
481
+ revealed: false,
482
+ keys: [],
483
+ values: null,
484
+ },
485
+ },
486
+ ]);
487
+
488
+ await act(async () => {
489
+ render(<EnvelopeScreen />);
490
+ });
491
+ await typeAndLoad(ARTIFACT_JSON);
492
+ await act(async () => {
493
+ fireEvent.click(screen.getByTestId("decrypt-keys"));
494
+ });
495
+
496
+ const hint = screen.getByTestId("envelope-error-hint");
497
+ expect(hint.textContent).toMatch(/service identity/i);
498
+ expect(hint.textContent).toMatch(/CLEF_AGE_KEY_FILE=\/path\/to\/service-identity\.key/);
499
+ });
500
+
501
+ it("spells out the server-identity invariant in the Decrypt card subtitle", async () => {
502
+ global.fetch = routeStubs([
503
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
504
+ { match: (u) => u.endsWith("/api/envelope/inspect"), body: inspectOk },
505
+ ]);
506
+
507
+ await act(async () => {
508
+ render(<EnvelopeScreen />);
509
+ });
510
+ await typeAndLoad(ARTIFACT_JSON);
511
+
512
+ const card = screen.getByTestId("envelope-card-decrypt");
513
+ expect(card.textContent).toMatch(/must be encrypted for this key/i);
514
+ expect(card.textContent).toMatch(/service identity/i);
515
+ });
516
+
517
+ it("renders an inspect error in-band when the server reports invalid_artifact", async () => {
518
+ global.fetch = routeStubs([
519
+ { match: (u) => u.endsWith("/api/envelope/config"), body: configConfigured },
520
+ {
521
+ match: (u) => u.endsWith("/api/envelope/inspect"),
522
+ body: {
523
+ source: "paste",
524
+ error: { code: "invalid_artifact", message: "missing field version" },
525
+ },
526
+ },
527
+ ]);
528
+
529
+ await act(async () => {
530
+ render(<EnvelopeScreen />);
531
+ });
532
+ await typeAndLoad(ARTIFACT_JSON);
533
+
534
+ const inspectCard = screen.getByTestId("envelope-card-inspect");
535
+ expect(within(inspectCard).getByTestId("envelope-error").textContent).toMatch(
536
+ /invalid_artifact/,
537
+ );
538
+ // Verify / decrypt cards should not render when inspect errored.
539
+ expect(screen.queryByTestId("envelope-card-verify")).toBeNull();
540
+ expect(screen.queryByTestId("envelope-card-decrypt")).toBeNull();
541
+ });
542
+ });