@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.
- package/dist/client/assets/index-DA0UG2qb.js +38 -0
- package/dist/client/index.html +1 -1
- package/dist/client-lib/components/Sidebar.d.ts +3 -2
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +106 -4
- package/dist/server/api.js.map +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +18 -1
- package/src/client/components/Sidebar.tsx +28 -2
- package/src/client/screens/NamespaceEditor.test.tsx +100 -0
- package/src/client/screens/NamespaceEditor.tsx +24 -3
- package/src/client/screens/PolicyView.test.tsx +278 -0
- package/src/client/screens/PolicyView.tsx +731 -0
- package/dist/client/assets/index-CogUSGa_.js +0 -26
|
@@ -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 &&
|
|
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
|
+
});
|