@cosmicdrift/kumiko-framework 0.319.0 → 0.320.0
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 +8 -4
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +139 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +91 -0
- package/src/api/index.ts +15 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +128 -40
- package/src/changes.json +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
package/src/changes.json
CHANGED
|
@@ -1,4 +1,77 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.320.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Centralized client-IP resolver replaces the old first-XFF-entry heuristic",
|
|
6
|
+
"detail": "New `trustedProxyHops` option on `ServerOptions` (default 0 — trusts no\nproxy header, only the socket address counts). Precedence:\n`options.trustedProxyHops ?? options.auth?.trustedProxyHops ?? 0`.\n`requestContext.ip` is now always set for an HTTP request (falls back to\n\"unknown\" instead of being left undefined), so `rateLimit: { per: \"ip\" }`\nhandlers reached via `r.httpRoute`/`extraRoutes` systemQuery are never\nsilently unthrottled. `rate-limit/middleware.ts`'s `globalIpRateLimit`/\n`authEndpointRateLimit` and `auth-routes.ts`'s `createAuthRoutes` gained a\n`trustedProxyHops`/`clientIpResolver` option and now share the server's\none resolver instance instead of each building their own.",
|
|
7
|
+
"migration": "Apps deployed behind a reverse-proxy/ingress that appends to\nX-Forwarded-For must set `trustedProxyHops` (buildServer/runProdApp/\nrunDevApp top-level option) to the number of trusted hops — usually `1`\nfor a single ingress. Without it, every client collapses into one shared\nrate-limit bucket (safe default, but likely too strict for real traffic).\nApps not behind a proxy need no change; `trustedProxyHops` defaults to 0.\n\nIntegration tests that call an `ip`/`ip+handler`-rate-limited handler\nrepeatedly through `stack.http.raw`/`stack.app.request` (no real socket)\nnow share one \"unknown\" bucket instead of skipping the bucket entirely —\na previously-passing test suite can start seeing 429s. Fix by flushing\nthe rate-limit namespace between test cases (`stack.redis.flushNamespace()`\nin `beforeEach`), or by setting `setupTestStack({ trustedProxyHops: 1 })`\nand sending a distinct `X-Forwarded-For` per test case. Also check any\ntest that nulls `context.redis` via `extraContext` to exercise a\nhandler's own redis-down guard: `setupTestStack` now keeps the L3\nrate-limit resolver alive from its own always-real Redis in that case\n(an explicit `context.rateLimit` still wins), so a handler-declared\n`rateLimit` is enforced instead of throwing `InternalError` for a missing\nresolver."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.320.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "Upload requests are now size-checked before the body is buffered",
|
|
13
|
+
"detail": "`POST /api/files` previously ran `parseBody()` on the full multipart request before any size check, so the body was parsed in full before the configured `maxUploadSize` ever ruled it out. It now rejects requests over the configured limit (checked upfront via `Content-Length`, and while streaming for chunked bodies) with a 413 before parsing. `runProdApp` and the dev server now also derive `Bun.serve`'s request-body cap from that same `maxUploadSize`/field `maxSize` configuration instead of Bun's own 128 MiB default; `buildBunServeOptions` keeps a lower fixed fallback for direct callers, overridable via `runProdApp`'s new `maxRequestBodySize` option."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.320.0",
|
|
17
|
+
"type": "fix",
|
|
18
|
+
"title": "Dev-boot backfills nullable columns a persistent dev DB is missing",
|
|
19
|
+
"detail": "`pushEntityProjectionTables` (packages/framework/src/stack) now diffs an\nexisting implicit-projection table's live columns against the entity's\ncurrent column set via `columnNamesOf`, backfilling anything nullable or\ndefaulted through the newly extracted `addMissingColumns` helper (shared\nwith `unsafePushTables`'s existing column-diff loop). A missing required\ncolumn with no default throws instead, naming the column and the\npersistent dev DB (`KUMIKO_DEV_DB_NAME`) to drop so the next boot\nrecreates it. Covers implicit `r.entity()`-derived projection tables,\nwhich is what the reporting app's read-model table used. `setupTestStack`'s\nown explicit `r.projection()`/`r.storeTable()` push loop (test-stack.ts)\nhas the same skip-existing behavior and is not yet patched."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.320.0",
|
|
23
|
+
"type": "fix",
|
|
24
|
+
"title": "Boot validator checks a metric's cross-screen navigate tab",
|
|
25
|
+
"detail": "A projectionDetail metric whose `navigate` sets `screen` or `entity` plus\n`tab` now fails boot when the target is not a projectionDetail with\nlayout.mode \"tabs\" or has no section with that id. Previously only a\nsame-screen `tab` was checked."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.320.0",
|
|
29
|
+
"type": "breaking",
|
|
30
|
+
"title": "Personal Access Tokens are now scoped to the dispatcher routes only",
|
|
31
|
+
"migration": "A caller using a PAT for SSE, file upload/download, /api/auth/*, a feature\nr.httpRoute, or an entry:\"user\" extraRoute now gets a 403 and needs a\nsession-cookie or JWT credential for those calls instead. The dispatcher\nroutes (/api/write, /api/batch, /api/query, /api/command, /api/stream) are\nunaffected."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "0.320.0",
|
|
35
|
+
"type": "fix",
|
|
36
|
+
"title": "No user-reference-name warning for owner fields referenced via personal.of",
|
|
37
|
+
"detail": "The `PII_USER_REFERENCE_NAME_HINTS` boot warning checked each field in isolation, so an owner field such as `authorId` kept warning even when a content field on the same entity already declared `personal: { of: \"authorId\" }`, the exact silencing condition the warning itself names. The validator now collects the owner fields referenced via `personal.of` per entity first and skips the warning for them. Fields with a user-reference-typical name that no `personal.of` references still warn."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"version": "0.320.0",
|
|
41
|
+
"type": "improvement",
|
|
42
|
+
"title": "New GET /api/schema route",
|
|
43
|
+
"detail": "Behind the existing `/api/*` auth guard. A signed-in, non-anonymous user\ngets the built `AppSchema` as JSON with `Cache-Control: private, no-cache`\nand a strong `ETag`; a matching `If-None-Match` gets a bodyless 304 (still\nbehind the same auth check, so an anonymous request with a stolen/guessed\nETag still 401s instead of getting a 304). Anonymous or missing auth gets\nthe same 401 `unauthenticated` response shape as every other non-public\nroute."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"version": "0.320.0",
|
|
47
|
+
"type": "improvement",
|
|
48
|
+
"title": "GET /api/schema now returns a per-role projection instead of the full AppSchema to every authenticated user",
|
|
49
|
+
"detail": "`buildAppSchema`'s output previously shipped every screen, nav, workspace and content-collection to any signed-in caller, regardless of role — a screen's own `access` rule only ever hid it in the UI, never removed it from the payload a curious client could still read. `projectAppSchemaForRoles` now strips every screen/nav/workspace/content-collection reference the caller's roles can't see (screens, nav entries, row/toolbar/related-list actions, entityEdit redirects, dashboard panels and metric navigation targets, tree actions, workspace nav membership) before the route serializes a response, closing empty parent nav sections and workspaces left with no surviving members along the way. Entities and translations are still shipped in full — the projection is a UI-visibility concern, not an entity-authorization concern; the dispatcher's `hasAccess` default-deny check is unchanged.\nThe route now builds the full schema lazily once per process and caches the projected JSON/ETag per canonical (deduplicated, sorted) role set — tenant is deliberately not part of the cache key, since the projection only depends on roles.\n`isUiAccessGranted` is the new shared default-visible UI predicate in `@cosmicdrift/kumiko-types`, re-exported through `framework/ui-types`. The renderer's `screenAccessAllows` is now an alias of it, and headless nav resolution and renderer-web's workspace filter call it directly instead of carrying their own copies.\nA deep link to a screen the caller's roles no longer receive now shows the \"screen not found\" banner instead of the access-denied banner, because the screen is no longer part of that caller's schema."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"version": "0.320.0",
|
|
53
|
+
"type": "fix",
|
|
54
|
+
"title": "Sequential-job lock key scoped by queueNamePrefix; contract clarified as mutual exclusion, not FIFO",
|
|
55
|
+
"detail": "The per-name Redis SETNX lock behind `concurrency: \"sequential\"` used a fixed `kumiko:lock:seq:<lane>:` key, unlike the queues themselves, which are scoped by `queueNamePrefix`. Two runners sharing a Redis but isolated by distinct prefixes (e.g. per-test-run prefixes) could collide on the same lock key even though their queues never saw each other's jobs. The key is now `kumiko:lock:seq:<queueNamePrefix>:<lane>:`. Separately, `JobDefinition.concurrency`'s doc comment and the integration test now state the contract precisely: \"sequential\" guarantees same-name dispatches never run concurrently, but does not guarantee they run in dispatch order. A lock loser is re-enqueued to the back of its queue, so a later dispatch can still complete before an earlier one. See fw#3265 for the local repro evidence backing this."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"version": "0.320.0",
|
|
59
|
+
"type": "improvement",
|
|
60
|
+
"title": "Real-provider helpers under the import-free subpath testing/real-providers",
|
|
61
|
+
"detail": "`@cosmicdrift/kumiko-framework/testing/real-providers` exports `requireRealProviders`, `isRealProviderRun` and `REAL_PROVIDERS_ENV` without pulling in the `./testing` barrel and its Bun-only dependencies, so code that loads under Node can use them."
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"version": "0.320.0",
|
|
65
|
+
"type": "improvement",
|
|
66
|
+
"title": "Boot validator checks the target tab of toolbar navigate actions",
|
|
67
|
+
"detail": "A ToolbarAction with kind \"navigate\" and `tab` fails boot when its target\nis not a projectionDetail with layout.mode \"tabs\" or has no section with\nthat id. The check covers entityList, projectionList and relatedList\nsection toolbars."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"version": "0.320.0",
|
|
71
|
+
"type": "fix",
|
|
72
|
+
"title": "HttpRouteHandlerDeps carries the resolved clientIp",
|
|
73
|
+
"detail": "`r.httpRoute` handlers get a new `clientIp: string` dep, resolved once per\nrequest via `buildServer`'s existing shared `clientIpResolver` (the same\ninstance `requestIdMiddleware` and the L1/L2 rate limits use), instead of\neach handler parsing `X-Forwarded-For`/`X-Real-IP` itself with no\nknowledge of the deployment's actual `trustedProxyHops`. `UNKNOWN_CLIENT_IP`\nis now exported from `@cosmicdrift/kumiko-framework/api` so callers can\ndetect the resolver's no-value sentinel without hardcoding the string."
|
|
74
|
+
},
|
|
2
75
|
{
|
|
3
76
|
"version": "0.318.0",
|
|
4
77
|
"type": "improvement",
|
|
@@ -365,6 +365,53 @@ describe("validateBoot — PII annotations", () => {
|
|
|
365
365
|
expect(matchingWarn).toBeUndefined();
|
|
366
366
|
});
|
|
367
367
|
|
|
368
|
+
const userReferenceHintWarningFor = (fieldName: string) =>
|
|
369
|
+
warnSpy.mock.calls.find((args: unknown[]) => {
|
|
370
|
+
const message = String(args[0]);
|
|
371
|
+
return (
|
|
372
|
+
message.includes(`Field "${fieldName}"`) && message.includes("user-reference-typical name")
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
function defineCommentFeatureWithBodyOwnedBy(ownerFieldName: "authorId" | "ownerId") {
|
|
377
|
+
return defineFeature("test", (r) => {
|
|
378
|
+
r.entity(
|
|
379
|
+
"user",
|
|
380
|
+
createEntity({
|
|
381
|
+
fields: {
|
|
382
|
+
email: createTextField({ personal: "self", find: "none" }),
|
|
383
|
+
},
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
stubListHandler(r, "user");
|
|
387
|
+
r.entity(
|
|
388
|
+
"comment",
|
|
389
|
+
createEntity({
|
|
390
|
+
fields: {
|
|
391
|
+
body: createLongTextField({ personal: { of: ownerFieldName }, find: "none" }),
|
|
392
|
+
authorId: { type: "reference", entity: "user" },
|
|
393
|
+
ownerId: { type: "reference", entity: "user" },
|
|
394
|
+
},
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
test("owner field referenced by another field's personal.of does not trigger the user-reference-name warning", () => {
|
|
401
|
+
validateBoot([defineCommentFeatureWithBodyOwnedBy("authorId")]);
|
|
402
|
+
expect(userReferenceHintWarningFor("authorId")).toBeUndefined();
|
|
403
|
+
const anyAuthorIdWarning = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
404
|
+
String(args[0]).includes('Field "authorId"'),
|
|
405
|
+
);
|
|
406
|
+
expect(anyAuthorIdWarning).toBeUndefined();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("user-reference-named field not referenced by any personal.of still warns", () => {
|
|
410
|
+
validateBoot([defineCommentFeatureWithBodyOwnedBy("ownerId")]);
|
|
411
|
+
expect(userReferenceHintWarningFor("authorId")).toBeDefined();
|
|
412
|
+
expect(userReferenceHintWarningFor("ownerId")).toBeUndefined();
|
|
413
|
+
});
|
|
414
|
+
|
|
368
415
|
test("allowPlaintext marker silences PII-name heuristic warning", () => {
|
|
369
416
|
const feature = defineFeature("test", (r) => {
|
|
370
417
|
r.entity(
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { createEntity, createTextField } from "../factories";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("validateBoot — MetricNavigate.tab (cross-screen)", () => {
|
|
13
|
+
test("metric navigate.screen + valid tab boots cleanly", () => {
|
|
14
|
+
const feature = defineFeature("app", (r) => {
|
|
15
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
16
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
17
|
+
});
|
|
18
|
+
r.screen({
|
|
19
|
+
id: "vehicle-detail",
|
|
20
|
+
type: "projectionDetail",
|
|
21
|
+
query: "app:query:vehicle:detail",
|
|
22
|
+
layout: {
|
|
23
|
+
mode: "tabs",
|
|
24
|
+
sections: [
|
|
25
|
+
{ id: "overview", title: "Overview", fields: ["name"] },
|
|
26
|
+
{ id: "channels", title: "Channels", fields: ["name"] },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
r.queryHandler("fleet:summary", z.object({}), async () => ({ name: "x" }), {
|
|
31
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
32
|
+
});
|
|
33
|
+
r.screen({
|
|
34
|
+
id: "fleet-summary",
|
|
35
|
+
type: "projectionDetail",
|
|
36
|
+
query: "app:query:fleet:summary",
|
|
37
|
+
layout: { sections: [{ title: "Summary", fields: ["name"] }] },
|
|
38
|
+
metrics: [
|
|
39
|
+
{
|
|
40
|
+
field: "name",
|
|
41
|
+
label: "app.metrics.name",
|
|
42
|
+
navigate: { screen: "vehicle-detail", tab: "channels" },
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("metric navigate.screen + unknown tab throws with the metric field name", () => {
|
|
51
|
+
const feature = defineFeature("app", (r) => {
|
|
52
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
53
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
54
|
+
});
|
|
55
|
+
r.screen({
|
|
56
|
+
id: "vehicle-detail",
|
|
57
|
+
type: "projectionDetail",
|
|
58
|
+
query: "app:query:vehicle:detail",
|
|
59
|
+
layout: {
|
|
60
|
+
mode: "tabs",
|
|
61
|
+
sections: [
|
|
62
|
+
{ id: "overview", title: "Overview", fields: ["name"] },
|
|
63
|
+
{ id: "channels", title: "Channels", fields: ["name"] },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
r.queryHandler("fleet:summary", z.object({}), async () => ({ name: "x" }), {
|
|
68
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
69
|
+
});
|
|
70
|
+
r.screen({
|
|
71
|
+
id: "fleet-summary",
|
|
72
|
+
type: "projectionDetail",
|
|
73
|
+
query: "app:query:fleet:summary",
|
|
74
|
+
layout: { sections: [{ title: "Summary", fields: ["name"] }] },
|
|
75
|
+
metrics: [
|
|
76
|
+
{
|
|
77
|
+
field: "name",
|
|
78
|
+
label: "app.metrics.name",
|
|
79
|
+
navigate: { screen: "vehicle-detail", tab: "unknown" },
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
85
|
+
/metric "name" navigates to tab "unknown", which is not a section id on target screen/,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("metric navigate.entity + entityId + unknown tab throws", () => {
|
|
90
|
+
const feature = defineFeature("app", (r) => {
|
|
91
|
+
r.entity(
|
|
92
|
+
"vehicle",
|
|
93
|
+
createEntity({
|
|
94
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
98
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
99
|
+
});
|
|
100
|
+
r.screen({
|
|
101
|
+
id: "vehicle-detail",
|
|
102
|
+
type: "projectionDetail",
|
|
103
|
+
detailFor: "vehicle",
|
|
104
|
+
query: "app:query:vehicle:detail",
|
|
105
|
+
layout: {
|
|
106
|
+
mode: "tabs",
|
|
107
|
+
sections: [
|
|
108
|
+
{ id: "overview", title: "Overview", fields: ["name"] },
|
|
109
|
+
{ id: "channels", title: "Channels", fields: ["name"] },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
r.queryHandler("fleet:summary", z.object({}), async () => ({ name: "x" }), {
|
|
114
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
115
|
+
});
|
|
116
|
+
r.screen({
|
|
117
|
+
id: "fleet-summary",
|
|
118
|
+
type: "projectionDetail",
|
|
119
|
+
query: "app:query:fleet:summary",
|
|
120
|
+
layout: { sections: [{ title: "Summary", fields: ["name"] }] },
|
|
121
|
+
metrics: [
|
|
122
|
+
{
|
|
123
|
+
field: "name",
|
|
124
|
+
label: "app.metrics.name",
|
|
125
|
+
navigate: { entity: "vehicle", entityId: "vehicleId", tab: "unknown" },
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
131
|
+
/metric "name" navigates to tab "unknown", which is not a section id on target screen/,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("metric navigate.screen targeting a non-tabs projectionDetail throws", () => {
|
|
136
|
+
const feature = defineFeature("app", (r) => {
|
|
137
|
+
r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
|
|
138
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
139
|
+
});
|
|
140
|
+
r.screen({
|
|
141
|
+
id: "vehicle-detail",
|
|
142
|
+
type: "projectionDetail",
|
|
143
|
+
query: "app:query:vehicle:detail",
|
|
144
|
+
layout: { sections: [{ title: "Overview", fields: ["name"] }] },
|
|
145
|
+
});
|
|
146
|
+
r.queryHandler("fleet:summary", z.object({}), async () => ({ name: "x" }), {
|
|
147
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
148
|
+
});
|
|
149
|
+
r.screen({
|
|
150
|
+
id: "fleet-summary",
|
|
151
|
+
type: "projectionDetail",
|
|
152
|
+
query: "app:query:fleet:summary",
|
|
153
|
+
layout: { sections: [{ title: "Summary", fields: ["name"] }] },
|
|
154
|
+
metrics: [
|
|
155
|
+
{
|
|
156
|
+
field: "name",
|
|
157
|
+
label: "app.metrics.name",
|
|
158
|
+
navigate: { screen: "vehicle-detail", tab: "overview" },
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
164
|
+
/is not a projectionDetail with layout.mode "tabs"/,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|