@cosmicdrift/kumiko-framework 0.193.1 → 0.194.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 +3 -3
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +71 -0
- package/src/api/server.ts +1 -1
- package/src/db/__tests__/migrate-generator.test.ts +17 -0
- package/src/db/__tests__/money.test.ts +41 -5
- package/src/db/event-store-executor-read.ts +1 -1
- package/src/db/index.ts +1 -1
- package/src/db/migrate-generator.ts +13 -3
- package/src/db/money.ts +34 -4
- package/src/derivatives/__tests__/variant-key.test.ts +2 -2
- package/src/derivatives/__tests__/variant-route.integration.test.ts +28 -0
- package/src/derivatives/variant-key.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +140 -0
- package/src/engine/__tests__/embedded-derived.test.ts +35 -0
- package/src/engine/__tests__/engine.test.ts +20 -0
- package/src/engine/__tests__/schema-builder.test.ts +93 -0
- package/src/engine/boot-validator/entity-handler.ts +64 -5
- package/src/engine/boot-validator/screens.ts +57 -36
- package/src/engine/embedded-derived.ts +9 -1
- package/src/engine/schema-builder.ts +33 -23
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +37 -6
- package/src/errors/zod-bridge.ts +4 -9
- package/src/event-store/__tests__/perf.integration.test.ts +2 -11
- package/src/files/file-routes.ts +20 -6
- package/src/files/storage-tracking.ts +2 -1
- package/src/jobs/job-runner.ts +18 -9
- package/src/logging/__tests__/fallback-logger.test.ts +43 -0
- package/src/logging/utils.ts +14 -1
- package/src/observability/__tests__/metrics-handle.test.ts +30 -0
- package/src/observability/metrics-handle.ts +24 -12
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +5 -5
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.194.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
"./package.json": "./package.json"
|
|
187
187
|
},
|
|
188
188
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
189
|
+
"@cosmicdrift/kumiko-types": "0.194.0",
|
|
190
190
|
"bullmq": "^5.76.7",
|
|
191
191
|
"bun-types": "^1.3.13",
|
|
192
192
|
"hono": "^4.13.1",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"zod": "^4.4.3"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.194.0",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// kumiko-framework#1977: r.httpRoute's systemQuery used to always run with
|
|
2
|
+
// requestContext.get()?.ip === undefined, so a `rateLimit: {per: "ip"}`
|
|
3
|
+
// handler invoked through it silently never bucketed — see server.ts's
|
|
4
|
+
// systemQuery wiring. Proves the fix: repeated calls through the same
|
|
5
|
+
// httpRoute, same client IP, DO hit the limit.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
10
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
11
|
+
import { RateLimitError } from "../../errors";
|
|
12
|
+
import { setupTestStack, type TestStack } from "../../stack";
|
|
13
|
+
|
|
14
|
+
const SYSTEM_TENANT_ID = "00000000-0000-4000-8000-000000000000" as TenantId;
|
|
15
|
+
|
|
16
|
+
const ipLimitedFeature = defineFeature("rl-http", (r) => {
|
|
17
|
+
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
18
|
+
r.queryHandler("ping", z.object({}), async () => ({ ok: true }), {
|
|
19
|
+
access: { roles: ["anonymous"] },
|
|
20
|
+
rateLimit: { per: "ip", limit: 2, windowSeconds: 60 },
|
|
21
|
+
});
|
|
22
|
+
r.httpRoute({
|
|
23
|
+
method: "GET",
|
|
24
|
+
path: "/ping",
|
|
25
|
+
anonymous: true,
|
|
26
|
+
handler: async (c, deps) => {
|
|
27
|
+
try {
|
|
28
|
+
await deps.systemQuery("rl-http:query:ping", {}, SYSTEM_TENANT_ID);
|
|
29
|
+
return c.json({ ok: true });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err instanceof RateLimitError) return c.json({ ok: false }, 429);
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
stack = await setupTestStack({ features: [ipLimitedFeature] });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await stack.cleanup();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
await stack.redis.flushNamespace();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("r.httpRoute → systemQuery propagates requestContext for per-ip rate limiting", () => {
|
|
53
|
+
test("2 calls allowed, 3rd from the same IP is rate-limited", async () => {
|
|
54
|
+
const call = () => stack.app.request("/ping", { headers: { "x-forwarded-for": "9.9.9.1" } });
|
|
55
|
+
|
|
56
|
+
expect((await call()).status).toBe(200);
|
|
57
|
+
expect((await call()).status).toBe(200);
|
|
58
|
+
expect((await call()).status).toBe(429);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a different client IP gets its own bucket", async () => {
|
|
62
|
+
const callAs = (ip: string) =>
|
|
63
|
+
stack.app.request("/ping", { headers: { "x-forwarded-for": ip } });
|
|
64
|
+
|
|
65
|
+
expect((await callAs("9.9.9.2")).status).toBe(200);
|
|
66
|
+
expect((await callAs("9.9.9.2")).status).toBe(200);
|
|
67
|
+
expect((await callAs("9.9.9.2")).status).toBe(429);
|
|
68
|
+
|
|
69
|
+
expect((await callAs("9.9.9.3")).status).toBe(200);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/api/server.ts
CHANGED
|
@@ -778,7 +778,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
778
778
|
// systemQuery is silent dead-code: enforceRateLimit reads
|
|
779
779
|
// requestContext.get()?.ip, which is undefined here, so
|
|
780
780
|
// buildBucketKey always returns {kind: "skip"}.
|
|
781
|
-
requestContext.run(buildRequestContextData(c), () =>
|
|
781
|
+
requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
|
|
782
782
|
dispatcher.query(type, payload, createAnonymousUser(tenantId)),
|
|
783
783
|
),
|
|
784
784
|
});
|
|
@@ -165,6 +165,23 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
|
|
|
165
165
|
expect(sql).not.toContain("WARN: column-type-change");
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
test("unmanaged: date → timestamptz type change emits the symmetric UTC-anchored USING clause", () => {
|
|
169
|
+
const prev = snapshotFromMetas([
|
|
170
|
+
meta("store_invoices", { name: "period_from", pgType: "date", notNull: true }),
|
|
171
|
+
]);
|
|
172
|
+
const next = snapshotFromMetas([
|
|
173
|
+
meta("store_invoices", { name: "period_from", pgType: "timestamptz", notNull: true }),
|
|
174
|
+
]);
|
|
175
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
176
|
+
name: "invoice-date-widen",
|
|
177
|
+
sequenceNumber: 8,
|
|
178
|
+
});
|
|
179
|
+
expect(sql).toContain(
|
|
180
|
+
'ALTER TABLE "store_invoices" ALTER COLUMN "period_from" TYPE timestamptz USING ("period_from"::timestamp AT TIME ZONE \'UTC\');',
|
|
181
|
+
);
|
|
182
|
+
expect(sql).not.toContain("WARN: column-type-change");
|
|
183
|
+
});
|
|
184
|
+
|
|
168
185
|
test("managed: multiple recreate reasons at once → all named in the warning", () => {
|
|
169
186
|
const prev = snapshotFromMetas([
|
|
170
187
|
meta("read_b", { name: "old_col", pgType: "text", notNull: false }, "managed"),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { createEntity, createMoneyField, createTextField } from "../../engine";
|
|
9
9
|
import type { EntityDefinition } from "../../engine/types";
|
|
10
|
-
import { flattenMoney, rehydrateMoney } from "../money";
|
|
10
|
+
import { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "../money";
|
|
11
11
|
|
|
12
12
|
const orderEntity: EntityDefinition = createEntity({
|
|
13
13
|
defaultCurrency: "EUR",
|
|
@@ -115,17 +115,16 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
115
115
|
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
test("amountMinor bleibt exakter Integer
|
|
118
|
+
test("amountMinor bleibt exakter Integer über mehrere Additionen (fw#1830)", () => {
|
|
119
119
|
const rows = [10, 20, 30].map(
|
|
120
120
|
(minor) =>
|
|
121
121
|
rehydrateMoney({ buyingPrice: minor, buyingPriceCurrency: "EUR" }, orderEntity)[
|
|
122
122
|
"buyingPrice"
|
|
123
|
-
] as
|
|
123
|
+
] as MoneyRead,
|
|
124
124
|
);
|
|
125
125
|
const [a, b, c] = rows;
|
|
126
126
|
|
|
127
127
|
expect(a!.amountMinor + b!.amountMinor).toBe(c!.amountMinor);
|
|
128
|
-
expect(a!.amount + b!.amount).not.toBe(c!.amount);
|
|
129
128
|
});
|
|
130
129
|
|
|
131
130
|
test("null/undefined amount → Field wird aus Output entfernt", () => {
|
|
@@ -178,7 +177,19 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
178
177
|
test("korrupte string-amount (kein number) → loud throw, kein silent drop", () => {
|
|
179
178
|
expect(() =>
|
|
180
179
|
rehydrateMoney({ buyingPrice: "not-a-number", buyingPriceCurrency: "EUR" }, orderEntity),
|
|
181
|
-
).toThrow(/not a
|
|
180
|
+
).toThrow(/not a safe integer — DB corruption/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("fractional string amount (fw#1833) → loud throw statt amountMinor mit Nachkommastelle", () => {
|
|
184
|
+
expect(() =>
|
|
185
|
+
rehydrateMoney({ buyingPrice: "45000.7", buyingPriceCurrency: "EUR" }, orderEntity),
|
|
186
|
+
).toThrow(/not a safe integer — DB corruption/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("string amount jenseits von Number.MAX_SAFE_INTEGER → loud throw statt Präzisionsverlust", () => {
|
|
190
|
+
expect(() =>
|
|
191
|
+
rehydrateMoney({ buyingPrice: "9007199254740993", buyingPriceCurrency: "EUR" }, orderEntity),
|
|
192
|
+
).toThrow(/not a safe integer — DB corruption/);
|
|
182
193
|
});
|
|
183
194
|
|
|
184
195
|
test("unerwarteter amount-Typ (boolean) → loud throw", () => {
|
|
@@ -222,3 +233,28 @@ describe("flattenMoney — Strict-Mode Throw", () => {
|
|
|
222
233
|
);
|
|
223
234
|
});
|
|
224
235
|
});
|
|
236
|
+
|
|
237
|
+
// kumiko-framework#1972: the write-handler-facing counterpart a custom
|
|
238
|
+
// totals check needs when reconciling a top-level money field's payload
|
|
239
|
+
// (major units, `{amount, currency}` or bare number) against an
|
|
240
|
+
// embedded-list row sum (minor-unit integers by convention).
|
|
241
|
+
describe("moneyPayloadToMinorUnits", () => {
|
|
242
|
+
test("{ amount, currency } object, crooked amount, rounds to the exact cent", () => {
|
|
243
|
+
expect(moneyPayloadToMinorUnits({ amount: 1234.56, currency: "EUR" })).toBe(123456);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("legacy bare-number payload (also major units, per flattenMoney's permissive-insert contract)", () => {
|
|
247
|
+
expect(moneyPayloadToMinorUnits(1234.56)).toBe(123456);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("a round amount does not mask a factor-of-100 regression: 30 major -> 3000 minor, not 30", () => {
|
|
251
|
+
expect(moneyPayloadToMinorUnits({ amount: 30, currency: "EUR" })).toBe(3000);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("undefined for a payload without a numeric amount (nothing to compare)", () => {
|
|
255
|
+
expect(moneyPayloadToMinorUnits({ currency: "EUR" })).toBeUndefined();
|
|
256
|
+
expect(moneyPayloadToMinorUnits("100")).toBeUndefined();
|
|
257
|
+
expect(moneyPayloadToMinorUnits(null)).toBeUndefined();
|
|
258
|
+
expect(moneyPayloadToMinorUnits(undefined)).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -166,7 +166,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
166
166
|
const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
167
167
|
|
|
168
168
|
const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
|
|
169
|
-
//
|
|
169
|
+
// Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
|
|
170
170
|
// Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
|
|
171
171
|
// column names, while compound-type lookups (rehydrateMoney et al.) and the
|
|
172
172
|
// encrypted/pii field lists are all camelCase — running either first on a
|
package/src/db/index.ts
CHANGED
|
@@ -100,7 +100,7 @@ export {
|
|
|
100
100
|
runMigrationsFromDir,
|
|
101
101
|
splitSqlStatements,
|
|
102
102
|
} from "./migrate-runner";
|
|
103
|
-
export { flattenMoney, rehydrateMoney } from "./money";
|
|
103
|
+
export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money";
|
|
104
104
|
export {
|
|
105
105
|
constraintOf,
|
|
106
106
|
extractPgError,
|
|
@@ -284,10 +284,20 @@ function renderColumnChange(tableName: string, change: ColumnChange): readonly s
|
|
|
284
284
|
out.push(
|
|
285
285
|
`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE date USING (${col} AT TIME ZONE 'UTC')::date;`,
|
|
286
286
|
);
|
|
287
|
+
} else if (from === "date" && (to === "timestamptz" || to === "timestamptz(3)")) {
|
|
288
|
+
// Same non-determinism, reversed: a bare `date → timestamptz` cast
|
|
289
|
+
// interprets the calendar date at session-TimeZone midnight. Anchor
|
|
290
|
+
// explicitly at UTC midnight instead.
|
|
291
|
+
out.push(
|
|
292
|
+
`-- date → ${to}: explicit UTC anchor — a bare cast would use the session TimeZone (non-deterministic).`,
|
|
293
|
+
);
|
|
294
|
+
out.push(
|
|
295
|
+
`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to} USING (${col}::timestamp AT TIME ZONE 'UTC');`,
|
|
296
|
+
);
|
|
287
297
|
} else {
|
|
288
|
-
// pg ALTER TYPE
|
|
289
|
-
// type
|
|
290
|
-
//
|
|
298
|
+
// pg ALTER TYPE often needs a USING clause for non-implicitly-castable
|
|
299
|
+
// type changes. Emitted as a reviewer comment + raw cast — the app
|
|
300
|
+
// author has to confirm it's intended.
|
|
291
301
|
out.push(`-- WARN: column-type-change ${from} → ${to}. Review USING-clause if needed.`);
|
|
292
302
|
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to};`);
|
|
293
303
|
}
|
package/src/db/money.ts
CHANGED
|
@@ -44,6 +44,27 @@ function toMajorUnits(amountMinor: number): number {
|
|
|
44
44
|
return amountMinor / MINOR_UNIT_SCALE;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// One money field's write payload — `{ amount, currency }` or a bare number
|
|
48
|
+
// (both MAJOR units, see file header) — reduced to minor units. This is the
|
|
49
|
+
// counterpart a write-handler needs when comparing a top-level money field
|
|
50
|
+
// against an `embeddedSubFieldToZod` list-row sum: rows are minor-unit
|
|
51
|
+
// integers by convention (currency lives on the head aggregate, not the
|
|
52
|
+
// row), so the two can only be compared once the sibling amount has been
|
|
53
|
+
// scaled the same way. `applyTotalsMatchRefinements` (schema-builder.ts)
|
|
54
|
+
// uses this internally for `EmbeddedFieldDef.totalsMatch`; exported so a
|
|
55
|
+
// custom write-handler doing its own total check doesn't have to re-derive
|
|
56
|
+
// this unwrap-and-scale step by hand (kumiko-framework#1972 — a hand-rolled
|
|
57
|
+
// comparison of a raw `{amount}` against a raw minor-unit row sum is exactly
|
|
58
|
+
// what silently fails 100x off).
|
|
59
|
+
export function moneyPayloadToMinorUnits(raw: unknown): number | undefined {
|
|
60
|
+
if (typeof raw === "number") return toMinorUnits(raw);
|
|
61
|
+
if (typeof raw === "object" && raw !== null && "amount" in raw) {
|
|
62
|
+
const amount = (raw as { amount: unknown }).amount;
|
|
63
|
+
if (typeof amount === "number") return toMinorUnits(amount);
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
/**
|
|
48
69
|
* API → DB: money-Felder zu zwei flachen Spalten flatten.
|
|
49
70
|
*
|
|
@@ -101,6 +122,15 @@ export function flattenMoney(
|
|
|
101
122
|
return result;
|
|
102
123
|
}
|
|
103
124
|
|
|
125
|
+
/** Shape of a single rehydrated money field — {amount major, currency,
|
|
126
|
+
* amountMinor exact integer cents}. Exported so consumers type their own
|
|
127
|
+
* copy against this instead of re-declaring the shape by hand. */
|
|
128
|
+
export type MoneyRead = {
|
|
129
|
+
readonly amount: number;
|
|
130
|
+
readonly currency: string;
|
|
131
|
+
readonly amountMinor: number;
|
|
132
|
+
};
|
|
133
|
+
|
|
104
134
|
/**
|
|
105
135
|
* DB → API: zwei flache Spalten zu combined { amount, currency } rehydraten.
|
|
106
136
|
*
|
|
@@ -134,15 +164,15 @@ export function rehydrateMoney(
|
|
|
134
164
|
amountMinor = amountRaw;
|
|
135
165
|
} else if (typeof amountRaw === "bigint") {
|
|
136
166
|
amountMinor = Number(amountRaw);
|
|
137
|
-
if (Number.
|
|
138
|
-
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a
|
|
167
|
+
if (!Number.isSafeInteger(amountMinor)) {
|
|
168
|
+
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
|
|
139
169
|
}
|
|
140
170
|
} else if (typeof amountRaw === "string" && amountRaw !== "") {
|
|
141
171
|
// PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
142
172
|
amountMinor = Number(amountRaw);
|
|
143
|
-
if (Number.
|
|
173
|
+
if (!Number.isSafeInteger(amountMinor)) {
|
|
144
174
|
throw new Error(
|
|
145
|
-
`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a
|
|
175
|
+
`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`,
|
|
146
176
|
);
|
|
147
177
|
}
|
|
148
178
|
} else {
|
|
@@ -33,8 +33,8 @@ describe("variantSuffix", () => {
|
|
|
33
33
|
expect(variantSuffix("thumb", spec)).not.toBe(variantSuffix("card", spec));
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
test("shape is `<name>-<
|
|
37
|
-
expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{
|
|
36
|
+
test("shape is `<name>-<16 hex chars>`", () => {
|
|
37
|
+
expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{16}$/);
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
test("a path-traversal name is rejected — it would escape the tenant prefix in the derived key", () => {
|
|
@@ -94,6 +94,7 @@ describe("GET /api/files/:id/variant/:name", () => {
|
|
|
94
94
|
|
|
95
95
|
expect(res.status).toBe(200);
|
|
96
96
|
expect(res.headers.get("Content-Type")).toBe("image/webp");
|
|
97
|
+
expect(res.headers.get("Cache-Control")).toBe("private, max-age=31536000, immutable");
|
|
97
98
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
98
99
|
});
|
|
99
100
|
|
|
@@ -166,4 +167,31 @@ describe("GET /api/files/:id/variant/:name", () => {
|
|
|
166
167
|
|
|
167
168
|
expect(res.status).toBe(401);
|
|
168
169
|
});
|
|
170
|
+
|
|
171
|
+
// createImageField has no `accept` restriction here, so validateFile
|
|
172
|
+
// never rejects a non-image upload against this field — the source
|
|
173
|
+
// mimeType a renderer has to handle is client-controlled, not just a
|
|
174
|
+
// deployment-time mount decision.
|
|
175
|
+
test("a source mimeType with no matching renderer (client uploaded a non-image) returns 415, not a 500", async () => {
|
|
176
|
+
const token = await stack.jwt.sign(user);
|
|
177
|
+
const fd = new FormData();
|
|
178
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }));
|
|
179
|
+
fd.append("entityType", "photo");
|
|
180
|
+
fd.append("fieldName", "avatar");
|
|
181
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
182
|
+
const uploadRes = await stack.app.request("/api/files", {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
185
|
+
body,
|
|
186
|
+
});
|
|
187
|
+
expect(uploadRes.status).toBe(201);
|
|
188
|
+
const { id: fileId } = (await uploadRes.json()) as { id: string };
|
|
189
|
+
|
|
190
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
191
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(res.status).toBe(415);
|
|
195
|
+
expect(await res.json()).toEqual({ error: "unsupported_media_type" });
|
|
196
|
+
});
|
|
169
197
|
});
|
|
@@ -22,7 +22,7 @@ export function canonicalJson(value: unknown): string {
|
|
|
22
22
|
// forever after a spec change; hashing the spec into the key means a
|
|
23
23
|
// changed spec is automatically a new URL.
|
|
24
24
|
export function specHash(spec: VariantSpec): string {
|
|
25
|
-
return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0,
|
|
25
|
+
return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 16);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;
|
|
@@ -1487,6 +1487,146 @@ describe("boot-validator", () => {
|
|
|
1487
1487
|
];
|
|
1488
1488
|
expect(() => validateBoot(features)).toThrow(/must match/);
|
|
1489
1489
|
});
|
|
1490
|
+
|
|
1491
|
+
test("rejects size beyond the max variant edge", () => {
|
|
1492
|
+
const features = [
|
|
1493
|
+
defineFeature("profile", (r) => {
|
|
1494
|
+
r.entity(
|
|
1495
|
+
"person",
|
|
1496
|
+
createEntity({
|
|
1497
|
+
fields: {
|
|
1498
|
+
avatar: createImageField({
|
|
1499
|
+
variants: { huge: { size: { width: 100000, height: 100000 } } },
|
|
1500
|
+
}),
|
|
1501
|
+
},
|
|
1502
|
+
}),
|
|
1503
|
+
);
|
|
1504
|
+
}),
|
|
1505
|
+
];
|
|
1506
|
+
expect(() => validateBoot(features)).toThrow(/size/);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
test("rejects maxEdge beyond the max variant edge", () => {
|
|
1510
|
+
const features = [
|
|
1511
|
+
defineFeature("profile", (r) => {
|
|
1512
|
+
r.entity(
|
|
1513
|
+
"person",
|
|
1514
|
+
createEntity({
|
|
1515
|
+
fields: {
|
|
1516
|
+
avatar: createImageField({ variants: { huge: { maxEdge: 100000 } } }),
|
|
1517
|
+
},
|
|
1518
|
+
}),
|
|
1519
|
+
);
|
|
1520
|
+
}),
|
|
1521
|
+
];
|
|
1522
|
+
expect(() => validateBoot(features)).toThrow(/maxEdge/);
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
test("rejects a negative blur", () => {
|
|
1526
|
+
const features = [
|
|
1527
|
+
defineFeature("profile", (r) => {
|
|
1528
|
+
r.entity(
|
|
1529
|
+
"person",
|
|
1530
|
+
createEntity({
|
|
1531
|
+
fields: {
|
|
1532
|
+
avatar: createImageField({ variants: { thumb: { maxEdge: 200, blur: -5 } } }),
|
|
1533
|
+
},
|
|
1534
|
+
}),
|
|
1535
|
+
);
|
|
1536
|
+
}),
|
|
1537
|
+
];
|
|
1538
|
+
expect(() => validateBoot(features)).toThrow(/blur/);
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
test("rejects a blur far beyond the renderer's supported sigma", () => {
|
|
1542
|
+
const features = [
|
|
1543
|
+
defineFeature("profile", (r) => {
|
|
1544
|
+
r.entity(
|
|
1545
|
+
"person",
|
|
1546
|
+
createEntity({
|
|
1547
|
+
fields: {
|
|
1548
|
+
avatar: createImageField({
|
|
1549
|
+
variants: { thumb: { maxEdge: 200, blur: 100000 } },
|
|
1550
|
+
}),
|
|
1551
|
+
},
|
|
1552
|
+
}),
|
|
1553
|
+
);
|
|
1554
|
+
}),
|
|
1555
|
+
];
|
|
1556
|
+
expect(() => validateBoot(features)).toThrow(/blur/);
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
test("accepts a valid blur", () => {
|
|
1560
|
+
process.env["FILE_STORAGE_PROVIDER"] = "local";
|
|
1561
|
+
try {
|
|
1562
|
+
const features = [
|
|
1563
|
+
defineFeature("profile", (r) => {
|
|
1564
|
+
r.entity(
|
|
1565
|
+
"person",
|
|
1566
|
+
createEntity({
|
|
1567
|
+
fields: {
|
|
1568
|
+
avatar: createImageField({ variants: { thumb: { maxEdge: 200, blur: 8 } } }),
|
|
1569
|
+
},
|
|
1570
|
+
}),
|
|
1571
|
+
);
|
|
1572
|
+
}),
|
|
1573
|
+
];
|
|
1574
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1575
|
+
} finally {
|
|
1576
|
+
delete process.env["FILE_STORAGE_PROVIDER"];
|
|
1577
|
+
}
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
test("rejects a blurRegion that overflows the unit square", () => {
|
|
1581
|
+
const features = [
|
|
1582
|
+
defineFeature("profile", (r) => {
|
|
1583
|
+
r.entity(
|
|
1584
|
+
"person",
|
|
1585
|
+
createEntity({
|
|
1586
|
+
fields: {
|
|
1587
|
+
avatar: createImageField({
|
|
1588
|
+
variants: {
|
|
1589
|
+
thumb: {
|
|
1590
|
+
maxEdge: 200,
|
|
1591
|
+
blurRegions: [{ x: -0.3, y: 0, width: 0.99, height: 0 }],
|
|
1592
|
+
},
|
|
1593
|
+
},
|
|
1594
|
+
}),
|
|
1595
|
+
},
|
|
1596
|
+
}),
|
|
1597
|
+
);
|
|
1598
|
+
}),
|
|
1599
|
+
];
|
|
1600
|
+
expect(() => validateBoot(features)).toThrow(/blurRegions/);
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
test("accepts a valid blurRegion", () => {
|
|
1604
|
+
process.env["FILE_STORAGE_PROVIDER"] = "local";
|
|
1605
|
+
try {
|
|
1606
|
+
const features = [
|
|
1607
|
+
defineFeature("profile", (r) => {
|
|
1608
|
+
r.entity(
|
|
1609
|
+
"person",
|
|
1610
|
+
createEntity({
|
|
1611
|
+
fields: {
|
|
1612
|
+
avatar: createImageField({
|
|
1613
|
+
variants: {
|
|
1614
|
+
thumb: {
|
|
1615
|
+
maxEdge: 200,
|
|
1616
|
+
blurRegions: [{ x: 0.1, y: 0.1, width: 0.2, height: 0.2 }],
|
|
1617
|
+
},
|
|
1618
|
+
},
|
|
1619
|
+
}),
|
|
1620
|
+
},
|
|
1621
|
+
}),
|
|
1622
|
+
);
|
|
1623
|
+
}),
|
|
1624
|
+
];
|
|
1625
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1626
|
+
} finally {
|
|
1627
|
+
delete process.env["FILE_STORAGE_PROVIDER"];
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1490
1630
|
});
|
|
1491
1631
|
|
|
1492
1632
|
// --- entityList column-renderer form-check ---
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { roundDerivedCellValue } from "../embedded-derived";
|
|
3
|
+
|
|
4
|
+
describe("roundDerivedCellValue — float-noise vs. genuine near-half values", () => {
|
|
5
|
+
test("money: float-multiplication noise just below a half-step still rounds up (deliberate)", () => {
|
|
6
|
+
// 1.005 * 100 === 100.49999999999999 in IEEE754 — this is the case the
|
|
7
|
+
// toPrecision(15) normalisation exists for: it must round to 101 minor
|
|
8
|
+
// units (100.5 → away-from-zero), not 100.
|
|
9
|
+
expect(roundDerivedCellValue(100.49999999999999, { type: "money" })).toBe(101);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("money: a value within ~1e-16 of a half-step also snaps up, even though it is not float noise", () => {
|
|
13
|
+
// Pinned trade-off (see the `ponytail:` comment in embedded-derived.ts):
|
|
14
|
+
// toPrecision(15) cannot distinguish "float noise from a real
|
|
15
|
+
// multiplication" from "a value that happens to sit just below .5" —
|
|
16
|
+
// both normalise to the same rounded string and both round up here.
|
|
17
|
+
expect(roundDerivedCellValue(0.49999999999999994, { type: "money" })).toBe(1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("money: a value clearly below the half-step still rounds down", () => {
|
|
21
|
+
expect(roundDerivedCellValue(0.49, { type: "money" })).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("decimal: respects the target scale", () => {
|
|
25
|
+
expect(roundDerivedCellValue(1.2345, { type: "decimal", scale: 2 })).toBe(1.23);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("non-money/decimal targets pass through unchanged", () => {
|
|
29
|
+
expect(roundDerivedCellValue(1.23456, { type: "number" })).toBe(1.23456);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("decimal with no scale: passes the value through unrounded instead of truncating to 0 decimals", () => {
|
|
33
|
+
expect(roundDerivedCellValue(1.2345, { type: "decimal", scale: undefined })).toBe(1.2345);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -1023,6 +1023,26 @@ describe("createApp", () => {
|
|
|
1023
1023
|
);
|
|
1024
1024
|
});
|
|
1025
1025
|
|
|
1026
|
+
test("rejects embedded-list required:true combined with minItems:0", () => {
|
|
1027
|
+
const feature = defineFeature("test", (r) => {
|
|
1028
|
+
r.entity(
|
|
1029
|
+
"doc",
|
|
1030
|
+
createEntity({
|
|
1031
|
+
table: "Docs",
|
|
1032
|
+
fields: {
|
|
1033
|
+
lines: createEmbeddedListField(
|
|
1034
|
+
{ accountId: { type: "text" } },
|
|
1035
|
+
{ required: true, minItems: 0 },
|
|
1036
|
+
),
|
|
1037
|
+
},
|
|
1038
|
+
}),
|
|
1039
|
+
);
|
|
1040
|
+
});
|
|
1041
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1042
|
+
"required:true and minItems:0",
|
|
1043
|
+
);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1026
1046
|
test("rejects derived cell referencing an unknown sub-field", () => {
|
|
1027
1047
|
const feature = defineFeature("test", (r) => {
|
|
1028
1048
|
r.entity(
|