@cosmicdrift/kumiko-renderer-web 0.234.0 → 0.235.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.235.1",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.235.1",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.235.1",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.235.1",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"jsdom": "^29.1.1",
|
|
66
66
|
"tailwindcss": "^4.3.0",
|
|
67
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.235.1"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -486,6 +486,34 @@ describe("KumikoScreen / projectionDetail — layout.mode: 'tabs'", () => {
|
|
|
486
486
|
// draws its own card frame, so a second nested Card would double it up.
|
|
487
487
|
expect(screen.queryByTestId("related-list-Payments")).toBeNull();
|
|
488
488
|
});
|
|
489
|
+
|
|
490
|
+
test("without a Tabs primitive, falls back to the stacked all-sections layout instead of truncating to the first section (fw#2501)", async () => {
|
|
491
|
+
const dispatcher = dispatcherReturning(rowData);
|
|
492
|
+
const { navApi } = navWithTab(undefined);
|
|
493
|
+
|
|
494
|
+
renderWithPrimitivesOverride(
|
|
495
|
+
<NavProvider value={navApi}>
|
|
496
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
497
|
+
<KumikoScreen
|
|
498
|
+
schema={schemaFor(tabsScreen)}
|
|
499
|
+
qn="rentals:screen:rent-detail"
|
|
500
|
+
entityId="rent-1"
|
|
501
|
+
/>
|
|
502
|
+
</DispatcherProvider>
|
|
503
|
+
</NavProvider>,
|
|
504
|
+
{ Tabs: undefined },
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
await waitFor(() => screen.getByTestId("field-description"));
|
|
508
|
+
// Pre-fix: activeSection stayed truncated to sections[0] even without a
|
|
509
|
+
// Tabs primitive to switch away from it — payments/invoices content was
|
|
510
|
+
// permanently unreachable, with no tab strip to reach it either.
|
|
511
|
+
await waitFor(() =>
|
|
512
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:payments")).toBe(true),
|
|
513
|
+
);
|
|
514
|
+
expect(dispatcher.calls.some((c) => c.type === "rentals:query:rent:invoices")).toBe(true);
|
|
515
|
+
expect(screen.queryByTestId("kumiko-screen-projection-detail-tabs")).toBeNull();
|
|
516
|
+
});
|
|
489
517
|
});
|
|
490
518
|
|
|
491
519
|
// Only guard against a solon-shaped screen (relatedList-heavy) silently regressing.
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Unit tests for the secretsEdit screen type. Secrets are write-only — the
|
|
3
|
+
// security invariant this file exists to pin: an input NEVER starts pre-filled
|
|
4
|
+
// with the redacted preview, and no dispatched payload ever carries that
|
|
5
|
+
// preview string next to the plaintext the user typed.
|
|
6
|
+
//
|
|
7
|
+
// `as unknown as Dispatcher["query"/"batch"/"write"]` throughout: each inline
|
|
8
|
+
// mock lambda only implements the one overload a given test exercises, never
|
|
9
|
+
// the full overloaded Dispatcher signature — the missing overloads are never
|
|
10
|
+
// called at runtime.
|
|
11
|
+
|
|
12
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
13
|
+
import type { SecretsEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
14
|
+
import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
15
|
+
import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
16
|
+
import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import userEvent from "@testing-library/user-event";
|
|
18
|
+
import { createMockDispatcher, render, screen, waitFor } from "./test-utils";
|
|
19
|
+
|
|
20
|
+
const secretsScreen: SecretsEditScreenDefinition = {
|
|
21
|
+
id: "secrets",
|
|
22
|
+
type: "secretsEdit",
|
|
23
|
+
secretKeys: { "stripe-api-key": "stripe:secret:api-key" },
|
|
24
|
+
fieldLabels: { "stripe-api-key": "config.secret.stripe.api-key.label" },
|
|
25
|
+
sections: [{ fields: ["stripe-api-key"] }],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const schema: FeatureSchema = {
|
|
29
|
+
featureName: "config",
|
|
30
|
+
entities: {},
|
|
31
|
+
screens: [secretsScreen],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("KumikoScreen / secretsEdit", () => {
|
|
35
|
+
test("a set secret shows its redacted preview while the input stays empty", async () => {
|
|
36
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
37
|
+
query: (async () => ({
|
|
38
|
+
isSuccess: true,
|
|
39
|
+
data: [{ key: "stripe:secret:api-key", redactedPreview: "sk_***abc", hint: null }],
|
|
40
|
+
})) as unknown as Dispatcher["query"],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
render(
|
|
44
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
45
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
46
|
+
</DispatcherProvider>,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
50
|
+
await waitFor(() => screen.getByText("sk_***abc"));
|
|
51
|
+
const input = screen.getByTestId("secret-input-stripe-api-key") as HTMLInputElement;
|
|
52
|
+
expect(input.value).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("submitting with no input dispatches nothing", async () => {
|
|
56
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
57
|
+
isSuccess: true as const,
|
|
58
|
+
results: [],
|
|
59
|
+
}));
|
|
60
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
61
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
62
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
render(
|
|
67
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
68
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
69
|
+
</DispatcherProvider>,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
73
|
+
await user.click(screen.getByTestId("secrets-edit-submit"));
|
|
74
|
+
expect(batchSpy).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("typing a value and saving dispatches exactly one secrets:write:set with the plaintext, never the redacted preview", async () => {
|
|
78
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
79
|
+
isSuccess: true as const,
|
|
80
|
+
results: [],
|
|
81
|
+
}));
|
|
82
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
83
|
+
query: (async () => ({
|
|
84
|
+
isSuccess: true,
|
|
85
|
+
data: [{ key: "stripe:secret:api-key", redactedPreview: "sk_***abc", hint: null }],
|
|
86
|
+
})) as unknown as Dispatcher["query"],
|
|
87
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const user = userEvent.setup();
|
|
91
|
+
render(
|
|
92
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
93
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
94
|
+
</DispatcherProvider>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
98
|
+
const input = screen.getByTestId("secret-input-stripe-api-key");
|
|
99
|
+
await user.type(input, "sk_live_newvalue");
|
|
100
|
+
await user.click(screen.getByTestId("secrets-edit-submit"));
|
|
101
|
+
|
|
102
|
+
await waitFor(() => expect(batchSpy).toHaveBeenCalledTimes(1));
|
|
103
|
+
const commands = batchSpy.mock.calls[0]?.[0];
|
|
104
|
+
if (!commands) throw new Error("batchSpy not called");
|
|
105
|
+
expect(commands).toEqual([
|
|
106
|
+
{
|
|
107
|
+
type: "secrets:write:set",
|
|
108
|
+
payload: { key: "stripe:secret:api-key", value: "sk_live_newvalue" },
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
// The security invariant this test exists for: the preview never rides
|
|
112
|
+
// along in a write payload next to the plaintext.
|
|
113
|
+
expect(JSON.stringify(commands)).not.toContain("sk_***abc");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("an unset required secret shows the required marker; a set one does not", async () => {
|
|
117
|
+
const requiredScreen: SecretsEditScreenDefinition = {
|
|
118
|
+
...secretsScreen,
|
|
119
|
+
requiredFields: ["stripe-api-key"],
|
|
120
|
+
};
|
|
121
|
+
const requiredSchema: FeatureSchema = {
|
|
122
|
+
featureName: "config",
|
|
123
|
+
entities: {},
|
|
124
|
+
screens: [requiredScreen],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
128
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
render(
|
|
132
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
133
|
+
<KumikoScreen schema={requiredSchema} qn="config:screen:secrets" />
|
|
134
|
+
</DispatcherProvider>,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
138
|
+
await waitFor(() => screen.getByTestId("required-marker-stripe-api-key"));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a set required secret does not show the required marker", async () => {
|
|
142
|
+
const requiredScreen: SecretsEditScreenDefinition = {
|
|
143
|
+
...secretsScreen,
|
|
144
|
+
requiredFields: ["stripe-api-key"],
|
|
145
|
+
};
|
|
146
|
+
const requiredSchema: FeatureSchema = {
|
|
147
|
+
featureName: "config",
|
|
148
|
+
entities: {},
|
|
149
|
+
screens: [requiredScreen],
|
|
150
|
+
};
|
|
151
|
+
const qualifiedKey = requiredScreen.secretKeys["stripe-api-key"];
|
|
152
|
+
|
|
153
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
154
|
+
query: (async () => ({
|
|
155
|
+
isSuccess: true,
|
|
156
|
+
data: [{ key: qualifiedKey, redactedPreview: "sk_***abc", hint: null }],
|
|
157
|
+
})) as unknown as Dispatcher["query"],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
render(
|
|
161
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
162
|
+
<KumikoScreen schema={requiredSchema} qn="config:screen:secrets" />
|
|
163
|
+
</DispatcherProvider>,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
167
|
+
await waitFor(() => screen.getByText("sk_***abc"));
|
|
168
|
+
expect(screen.queryByTestId("required-marker-stripe-api-key")).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("an unset required secret does not block saving a different secret", async () => {
|
|
172
|
+
const twoFieldScreen: SecretsEditScreenDefinition = {
|
|
173
|
+
id: "secrets",
|
|
174
|
+
type: "secretsEdit",
|
|
175
|
+
secretKeys: {
|
|
176
|
+
"stripe-api-key": "stripe:secret:[REDACTED:API key param]",
|
|
177
|
+
"webhook-secret": "stripe:secret:[REDACTED:webhook param]",
|
|
178
|
+
},
|
|
179
|
+
fieldLabels: {
|
|
180
|
+
"stripe-api-key": "config.secret.stripe.api-key.label",
|
|
181
|
+
"webhook-secret": "config.secret.stripe.webhook.label",
|
|
182
|
+
},
|
|
183
|
+
sections: [{ fields: ["stripe-api-key", "webhook-secret"] }],
|
|
184
|
+
requiredFields: ["stripe-api-key"],
|
|
185
|
+
};
|
|
186
|
+
const twoFieldSchema: FeatureSchema = {
|
|
187
|
+
featureName: "config",
|
|
188
|
+
entities: {},
|
|
189
|
+
screens: [twoFieldScreen],
|
|
190
|
+
};
|
|
191
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
192
|
+
isSuccess: true as const,
|
|
193
|
+
results: [],
|
|
194
|
+
}));
|
|
195
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
196
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
197
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const user = userEvent.setup();
|
|
201
|
+
render(
|
|
202
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
203
|
+
<KumikoScreen schema={twoFieldSchema} qn="config:screen:secrets" />
|
|
204
|
+
</DispatcherProvider>,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
208
|
+
const requiredInput = screen.getByTestId("secret-input-stripe-api-key") as HTMLInputElement;
|
|
209
|
+
expect(requiredInput.required).toBe(false);
|
|
210
|
+
|
|
211
|
+
await user.type(screen.getByTestId("secret-input-webhook-secret"), "whsec_newvalue");
|
|
212
|
+
await user.click(screen.getByTestId("secrets-edit-submit"));
|
|
213
|
+
|
|
214
|
+
await waitFor(() => expect(batchSpy).toHaveBeenCalledTimes(1));
|
|
215
|
+
const commands = batchSpy.mock.calls[0]?.[0];
|
|
216
|
+
if (!commands) throw new Error("batchSpy not called");
|
|
217
|
+
expect(commands).toEqual([
|
|
218
|
+
{
|
|
219
|
+
type: "secrets:write:set",
|
|
220
|
+
payload: { key: "stripe:secret:[REDACTED:webhook param]", value: "whsec_newvalue" },
|
|
221
|
+
},
|
|
222
|
+
]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("delete dispatches secrets:write:delete with the qualified key", async () => {
|
|
226
|
+
const writeSpy = mock(async (_type: string, _payload: unknown) => ({
|
|
227
|
+
isSuccess: true as const,
|
|
228
|
+
data: {},
|
|
229
|
+
}));
|
|
230
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
231
|
+
query: (async () => ({
|
|
232
|
+
isSuccess: true,
|
|
233
|
+
data: [{ key: "stripe:secret:api-key", redactedPreview: "sk_***abc", hint: null }],
|
|
234
|
+
})) as unknown as Dispatcher["query"],
|
|
235
|
+
write: writeSpy as unknown as Dispatcher["write"],
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const user = userEvent.setup();
|
|
239
|
+
render(
|
|
240
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
241
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
242
|
+
</DispatcherProvider>,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
246
|
+
await user.click(screen.getByTestId("secret-delete-stripe-api-key"));
|
|
247
|
+
|
|
248
|
+
await waitFor(() => expect(writeSpy).toHaveBeenCalledTimes(1));
|
|
249
|
+
expect(writeSpy).toHaveBeenCalledWith("secrets:write:delete", { key: "stripe:secret:api-key" });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("a failed save shows the error and never renders the typed plaintext", async () => {
|
|
253
|
+
const plaintext = "sk_live_wouldbeleakedifechoed";
|
|
254
|
+
const serverError: DispatcherError = {
|
|
255
|
+
code: "TENANT_SECRET_WRITE_CONFLICT",
|
|
256
|
+
httpStatus: 409,
|
|
257
|
+
i18nKey: "secrets:errors.writeConflict",
|
|
258
|
+
message: "Could not save secret: a concurrent update conflicted, please retry.",
|
|
259
|
+
};
|
|
260
|
+
const batchSpy = mock(async (_commands: ReadonlyArray<{ type: string; payload: unknown }>) => ({
|
|
261
|
+
isSuccess: false as const,
|
|
262
|
+
error: serverError,
|
|
263
|
+
}));
|
|
264
|
+
const dispatcher: Dispatcher = createMockDispatcher({
|
|
265
|
+
query: (async () => ({ isSuccess: true, data: [] })) as unknown as Dispatcher["query"],
|
|
266
|
+
batch: batchSpy as unknown as Dispatcher["batch"],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const user = userEvent.setup();
|
|
270
|
+
render(
|
|
271
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
272
|
+
<KumikoScreen schema={schema} qn="config:screen:secrets" />
|
|
273
|
+
</DispatcherProvider>,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
await waitFor(() => screen.getByTestId("secrets-edit-form"));
|
|
277
|
+
const input = screen.getByTestId("secret-input-stripe-api-key");
|
|
278
|
+
await user.type(input, plaintext);
|
|
279
|
+
await user.click(screen.getByTestId("secrets-edit-submit"));
|
|
280
|
+
|
|
281
|
+
await waitFor(() => screen.getByTestId("secrets-edit-error"));
|
|
282
|
+
expect(screen.getByTestId("secrets-edit-error").textContent).toContain(serverError.message);
|
|
283
|
+
// The drafted plaintext must never leak into the error surface (or
|
|
284
|
+
// anywhere else in the DOM besides the input's own `value`).
|
|
285
|
+
expect(document.body.textContent).not.toContain(plaintext);
|
|
286
|
+
});
|
|
287
|
+
});
|