@cosmicdrift/kumiko-bundled-features 0.208.3 → 0.209.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 +8 -8
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +10 -2
- package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +50 -0
- package/src/compliance-profiles/changes.json +8 -1
- package/src/compliance-profiles/constants.ts +5 -0
- package/src/compliance-profiles/feature.ts +28 -3
- package/src/compliance-profiles/i18n.ts +25 -0
- package/src/compliance-profiles/web/client-plugin.tsx +7 -4
- package/src/compliance-profiles/web/compliance-profile-catalog.tsx +55 -0
- package/src/compliance-profiles/web/i18n.ts +1 -7
- package/src/compliance-profiles/web/index.ts +1 -1
- package/src/delivery/__tests__/delivery-log-query.integration.test.ts +132 -0
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +5 -5
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -2
- package/src/delivery/__tests__/delivery.integration.test.ts +1 -1
- package/src/delivery/changes.json +6 -0
- package/src/delivery/constants.ts +1 -0
- package/src/delivery/feature.ts +28 -3
- package/src/delivery/handlers/log.query.ts +115 -18
- package/src/delivery/i18n.ts +4 -0
- package/src/delivery/public-names.ts +5 -0
- package/src/delivery/web/client-plugin.tsx +6 -7
- package/src/delivery/web/delivery-status-cell.tsx +26 -0
- package/src/delivery/web/index.ts +1 -1
- package/src/jobs/__tests__/jobs-events.integration.test.ts +65 -81
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +21 -18
- package/src/jobs/__tests__/jobs-retention.integration.test.ts +124 -0
- package/src/jobs/db/queries/retention.ts +44 -0
- package/src/jobs/events.ts +5 -8
- package/src/jobs/feature.ts +26 -146
- package/src/jobs/handlers/retention-cleanup.job.ts +21 -0
- package/src/jobs/index.ts +1 -1
- package/src/jobs/job-run-logger.ts +112 -59
- package/src/jobs/job-run-table.ts +36 -28
- package/src/sessions/__tests__/session-last-seen.integration.test.ts +165 -0
- package/src/sessions/changes.json +6 -0
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +17 -1
- package/src/sessions/schema/user-session.ts +5 -0
- package/src/sessions/session-callbacks.ts +20 -1
- package/src/sessions/session-checker-fail-open.test.ts +38 -0
- package/src/user-data-rights/feature.ts +4 -4
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +1 -1
- package/src/compliance-profiles/web/compliance-profile-screen.tsx +0 -152
- package/src/delivery/web/delivery-log-screen.tsx +0 -103
- package/src/delivery/web/i18n.ts +0 -15
|
@@ -1,14 +1,77 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
1
|
+
import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
definePagedQueryHandler,
|
|
6
|
+
MAX_LIST_LIMIT,
|
|
7
|
+
type NotifyPriority,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
9
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
|
+
import { Temporal } from "temporal-polyfill";
|
|
4
11
|
import { z } from "zod";
|
|
5
12
|
import { decryptStoredPii } from "../../shared";
|
|
13
|
+
import type { DeliveryStatusValue } from "../constants";
|
|
6
14
|
import { deliveryAttemptsTable } from "../tables";
|
|
7
15
|
|
|
8
|
-
|
|
16
|
+
type DeliveryLogRow = {
|
|
17
|
+
id: string;
|
|
18
|
+
tenantId: string;
|
|
19
|
+
notificationType: string;
|
|
20
|
+
channel: string;
|
|
21
|
+
recipientId: string | null;
|
|
22
|
+
recipientAddress: string | null;
|
|
23
|
+
status: DeliveryStatusValue;
|
|
24
|
+
error: string | null;
|
|
25
|
+
priority: NotifyPriority;
|
|
26
|
+
createdAt: Temporal.Instant;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Sort whitelist keyed by the DISPLAY field the client actually sends: the
|
|
30
|
+
// declarative projectionList renderer marks every listed column uniformly
|
|
31
|
+
// sortable and a header click sends `sort=<screen.columns[].field>`, not the
|
|
32
|
+
// DB column name (see projection-list-shim.ts synthesizeProjectionEntity).
|
|
33
|
+
// "recipient" is deliberately absent — it's PII decrypted per-row in
|
|
34
|
+
// application code, so there is no column to ORDER BY in SQL for it.
|
|
35
|
+
const DELIVERY_LOG_SORT_COLUMNS = {
|
|
36
|
+
createdAt: "createdAt",
|
|
37
|
+
type: "notificationType",
|
|
38
|
+
channel: "channel",
|
|
39
|
+
status: "status",
|
|
40
|
+
} as const;
|
|
41
|
+
type DeliveryLogSortField = keyof typeof DELIVERY_LOG_SORT_COLUMNS;
|
|
42
|
+
|
|
43
|
+
function isDeliveryLogSortField(value: string): value is DeliveryLogSortField {
|
|
44
|
+
return value in DELIVERY_LOG_SORT_COLUMNS;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type DeliveryLogSortColumn = (typeof DELIVERY_LOG_SORT_COLUMNS)[DeliveryLogSortField];
|
|
48
|
+
|
|
49
|
+
// createdAt round-trips through Temporal.Instant (matches how the WHERE
|
|
50
|
+
// builder coerces timestamptz values — see bun-db/query.ts prepareValue);
|
|
51
|
+
// every other whitelisted column is a plain text column. Keyed by the
|
|
52
|
+
// PHYSICAL column, not the display alias — row only has the DB field names
|
|
53
|
+
// (e.g. notificationType), never the alias (e.g. "type").
|
|
54
|
+
function encodeSortCursor(column: DeliveryLogSortColumn, row: DeliveryLogRow): string {
|
|
55
|
+
if (column === "createdAt") return encodeCursor(row.createdAt.toString());
|
|
56
|
+
return encodeCursor(String(row[column]));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decodeSortCursor(
|
|
60
|
+
column: DeliveryLogSortColumn,
|
|
61
|
+
cursor: string,
|
|
62
|
+
): Temporal.Instant | string {
|
|
63
|
+
const decoded = decodeCursor(cursor);
|
|
64
|
+
if (column === "createdAt") return Temporal.Instant.from(decoded);
|
|
65
|
+
return decoded;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const logQuery = definePagedQueryHandler({
|
|
9
69
|
name: "log",
|
|
10
70
|
schema: z.object({
|
|
11
|
-
|
|
71
|
+
cursor: z.string().optional(),
|
|
72
|
+
limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(50),
|
|
73
|
+
sort: z.string().optional(),
|
|
74
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
12
75
|
}),
|
|
13
76
|
access: { roles: access.admin },
|
|
14
77
|
handler: async (query, ctx) => {
|
|
@@ -20,27 +83,61 @@ export const logQuery = defineQueryHandler({
|
|
|
20
83
|
// tenantId, not a query filter — it returns that same unfiltered db, so
|
|
21
84
|
// the explicit `where` below still does the actual tenant scoping.
|
|
22
85
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
86
|
+
|
|
87
|
+
const requestedSort = query.payload.sort;
|
|
88
|
+
const sortField: DeliveryLogSortField =
|
|
89
|
+
requestedSort !== undefined && isDeliveryLogSortField(requestedSort)
|
|
90
|
+
? requestedSort
|
|
91
|
+
: "createdAt";
|
|
92
|
+
const sortDirection = query.payload.sortDirection ?? "desc";
|
|
93
|
+
const sortColumn = DELIVERY_LOG_SORT_COLUMNS[sortField];
|
|
94
|
+
|
|
95
|
+
const where: WhereObject = { tenantId: query.user.tenantId };
|
|
96
|
+
if (query.payload.cursor) {
|
|
97
|
+
where[sortColumn] = {
|
|
98
|
+
[sortDirection === "asc" ? "gt" : "lt"]: decodeSortCursor(sortColumn, query.payload.cursor),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const rows = await selectMany<DeliveryLogRow>(db, deliveryAttemptsTable, where, {
|
|
103
|
+
// Tie-breaker on id keeps ordering stable across pages when the sort
|
|
104
|
+
// column has duplicate values (e.g. many rows with the same status).
|
|
105
|
+
orderBy: [
|
|
106
|
+
{ col: sortColumn, direction: sortDirection },
|
|
107
|
+
{ col: "id", direction: "asc" },
|
|
108
|
+
],
|
|
109
|
+
limit: query.payload.limit,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const lastRow = rows[rows.length - 1];
|
|
113
|
+
const nextCursor =
|
|
114
|
+
rows.length === query.payload.limit && lastRow ? encodeSortCursor(sortColumn, lastRow) : null;
|
|
115
|
+
|
|
32
116
|
// recipientAddress is stored encrypted under the recipient's DEK (#799)
|
|
33
117
|
// — decrypt for the admin log view; forgotten subjects show [[erased]].
|
|
118
|
+
// The notificationType/recipientAddress → type/recipient rename happens
|
|
119
|
+
// here, not in the client: a projectionList screen has no entity to
|
|
120
|
+
// derive a field-mapping from, so the row shape the query returns is the
|
|
121
|
+
// shape the declarative columns read directly.
|
|
34
122
|
return {
|
|
35
123
|
rows: await Promise.all(
|
|
36
124
|
rows.map(async (row) => ({
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
125
|
+
id: row.id,
|
|
126
|
+
tenantId: row.tenantId,
|
|
127
|
+
recipientId: row.recipientId,
|
|
128
|
+
type: row.notificationType,
|
|
129
|
+
channel: row.channel,
|
|
130
|
+
recipient:
|
|
131
|
+
row.recipientAddress !== null
|
|
132
|
+
? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
|
|
133
|
+
: row.recipientAddress,
|
|
134
|
+
status: row.status,
|
|
135
|
+
error: row.error,
|
|
136
|
+
priority: row.priority,
|
|
137
|
+
createdAt: row.createdAt,
|
|
42
138
|
})),
|
|
43
139
|
),
|
|
140
|
+
nextCursor,
|
|
44
141
|
};
|
|
45
142
|
},
|
|
46
143
|
});
|
package/src/delivery/i18n.ts
CHANGED
|
@@ -3,4 +3,8 @@ type LocalizedString = { readonly en: string };
|
|
|
3
3
|
export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
4
4
|
"screen:delivery-log.title": { en: "Delivery log" },
|
|
5
5
|
"delivery:nav.deliveryLog": { en: "Delivery" },
|
|
6
|
+
"delivery.log.col.type": { en: "Type" },
|
|
7
|
+
"delivery.log.col.channel": { en: "Channel" },
|
|
8
|
+
"delivery.log.col.recipient": { en: "Recipient" },
|
|
9
|
+
"delivery.log.col.status": { en: "Status" },
|
|
6
10
|
};
|
|
@@ -14,6 +14,11 @@ export const DeliveryQueries = {
|
|
|
14
14
|
|
|
15
15
|
export const DELIVERY_LOG_SCREEN_ID = "delivery-log" as const;
|
|
16
16
|
|
|
17
|
+
// Client column-renderer registered for the delivery-log screen's status
|
|
18
|
+
// column — see `screen.columns[].renderer.react.__component` in feature.ts
|
|
19
|
+
// and `deliveryClient()`'s `columnRenderers` map.
|
|
20
|
+
export const DELIVERY_STATUS_CELL_COMPONENT = "DeliveryStatusCell" as const;
|
|
21
|
+
|
|
17
22
|
export const DeliveryErrors = {
|
|
18
23
|
noRecipient: "delivery_no_recipient",
|
|
19
24
|
channelFailed: "delivery_channel_failed",
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
import {
|
|
2
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import { DELIVERY_FEATURE,
|
|
5
|
-
import {
|
|
6
|
-
import { defaultTranslations } from "./i18n";
|
|
4
|
+
import { DELIVERY_FEATURE, DELIVERY_STATUS_CELL_COMPONENT } from "../public-names";
|
|
5
|
+
import { DeliveryStatusCell } from "./delivery-status-cell";
|
|
7
6
|
|
|
8
7
|
export type DeliveryClientOptions = {
|
|
9
8
|
readonly translations?: TranslationsByLocale;
|
|
@@ -12,9 +11,9 @@ export type DeliveryClientOptions = {
|
|
|
12
11
|
export function deliveryClient(options?: DeliveryClientOptions): ClientFeatureDefinition {
|
|
13
12
|
return {
|
|
14
13
|
name: DELIVERY_FEATURE,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
[DELIVERY_LOG_SCREEN_ID]: DeliveryLogScreen,
|
|
14
|
+
columnRenderers: {
|
|
15
|
+
[DELIVERY_STATUS_CELL_COMPONENT]: DeliveryStatusCell,
|
|
18
16
|
},
|
|
17
|
+
...(options?.translations !== undefined && { translations: options.translations }),
|
|
19
18
|
};
|
|
20
19
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Column-renderer for the delivery-log screen's status column, registered
|
|
3
|
+
// via `deliveryClient()`'s `columnRenderers` map (screen.columns[].renderer
|
|
4
|
+
// in feature.ts). The list frame itself is the generic declarative
|
|
5
|
+
// projectionList renderer — only this one cell stays TSX.
|
|
6
|
+
|
|
7
|
+
import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer-web";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { DeliveryStatus } from "../public-names";
|
|
11
|
+
|
|
12
|
+
const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
|
|
13
|
+
[DeliveryStatus.sent]: "ok",
|
|
14
|
+
[DeliveryStatus.failed]: "bad",
|
|
15
|
+
[DeliveryStatus.queued]: "muted",
|
|
16
|
+
[DeliveryStatus.skipped]: "muted",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function DeliveryStatusCell({ row }: ColumnRendererProps): ReactNode {
|
|
20
|
+
const status = typeof row["status"] === "string" ? row["status"] : "";
|
|
21
|
+
return (
|
|
22
|
+
<StatusBadge tone={STATUS_TONE[status] ?? "muted"} testId="delivery-status-cell">
|
|
23
|
+
{status}
|
|
24
|
+
</StatusBadge>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
1
|
+
// Direct-write shape contract for job runs (#2243). Pins onJobStart/
|
|
2
|
+
// -Complete/-Failed against the tables they write — jobRunsTable status
|
|
3
|
+
// transitions and jobRunLogsTable batched log rows. A silent shape drift
|
|
4
|
+
// (missing field, wrong status string) fails here instead of breaking the
|
|
5
|
+
// operator UI silently.
|
|
6
6
|
//
|
|
7
7
|
// The jobs integration test (jobs-feature.integration.ts) covers the
|
|
8
|
-
// projection side (list + detail queries). This file covers the
|
|
9
|
-
// side — complementary coverage, minimal overlap.
|
|
8
|
+
// projection side (list + detail queries). This file covers the
|
|
9
|
+
// write side — complementary coverage, minimal overlap.
|
|
10
10
|
|
|
11
11
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
12
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
-
import { createRegistry
|
|
13
|
+
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
14
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
15
|
import {
|
|
16
16
|
createTestDb,
|
|
@@ -20,14 +20,8 @@ import {
|
|
|
20
20
|
unsafePushTables,
|
|
21
21
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
22
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
|
-
import { runCompletedSchema, runFailedSchema, runStartedSchema } from "../events";
|
|
24
23
|
import { createJobsFeature } from "../feature";
|
|
25
|
-
import {
|
|
26
|
-
createJobRunLogger,
|
|
27
|
-
JOB_RUN_COMPLETED_EVENT,
|
|
28
|
-
JOB_RUN_FAILED_EVENT,
|
|
29
|
-
JOB_RUN_STARTED_EVENT,
|
|
30
|
-
} from "../job-run-logger";
|
|
24
|
+
import { createJobRunLogger } from "../job-run-logger";
|
|
31
25
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
32
26
|
|
|
33
27
|
let testDb: TestDb;
|
|
@@ -40,6 +34,8 @@ beforeAll(async () => {
|
|
|
40
34
|
testRedis = await createTestRedis();
|
|
41
35
|
registry = createRegistry([createJobsFeature()]);
|
|
42
36
|
await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
|
|
37
|
+
// Kept only for the negative assertion below (no jobs:event:run-* rows) —
|
|
38
|
+
// the write path itself no longer touches the event store.
|
|
43
39
|
await createEventsTable(testDb.db);
|
|
44
40
|
logger = createJobRunLogger({ db: testDb.db, registry });
|
|
45
41
|
});
|
|
@@ -53,114 +49,102 @@ beforeEach(async () => {
|
|
|
53
49
|
await resetTestTables(testDb.db, [eventsTable, jobRunsTable, jobRunLogsTable]);
|
|
54
50
|
});
|
|
55
51
|
|
|
56
|
-
describe("jobRun
|
|
57
|
-
test("
|
|
58
|
-
// Guard against silent rename. Tests that subscribe via string-match
|
|
59
|
-
// (MSPs written in userland, audit export tools) break without this.
|
|
60
|
-
expect(JOB_RUN_STARTED_EVENT).toBe("jobs:event:run-started");
|
|
61
|
-
expect(JOB_RUN_COMPLETED_EVENT).toBe("jobs:event:run-completed");
|
|
62
|
-
expect(JOB_RUN_FAILED_EVENT).toBe("jobs:event:run-failed");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("onJobStart writes a run-started event on the jobRun aggregate", async () => {
|
|
52
|
+
describe("jobRun direct writes", () => {
|
|
53
|
+
test("onJobStart inserts a row into jobRunsTable", async () => {
|
|
66
54
|
await logger.onJobStart?.("example:job:import", "bull-42", {
|
|
67
55
|
triggeredById: "u-99",
|
|
68
56
|
payload: JSON.stringify({ foo: 1 }),
|
|
69
57
|
attempt: 1,
|
|
70
58
|
});
|
|
71
59
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
expect(
|
|
75
|
-
const
|
|
76
|
-
expect(
|
|
77
|
-
expect(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect(() => runStartedSchema.parse(e?.payload)).not.toThrow();
|
|
81
|
-
const p = runStartedSchema.parse(e?.payload);
|
|
82
|
-
expect(p.jobName).toBe("example:job:import");
|
|
83
|
-
expect(p.bullJobId).toBe("bull-42");
|
|
84
|
-
expect(p.triggeredById).toBe("u-99");
|
|
85
|
-
expect(p.attempt).toBe(1);
|
|
60
|
+
const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-42" });
|
|
61
|
+
|
|
62
|
+
expect(runs.length).toBe(1);
|
|
63
|
+
const run = runs[0];
|
|
64
|
+
expect(run?.jobName).toBe("example:job:import");
|
|
65
|
+
expect(run?.status).toBe("running");
|
|
66
|
+
expect(run?.triggeredById).toBe("u-99");
|
|
67
|
+
expect(run?.attempt).toBe(1);
|
|
86
68
|
});
|
|
87
69
|
|
|
88
|
-
test("onJobComplete
|
|
70
|
+
test("onJobComplete updates the row to completed with batched logs", async () => {
|
|
89
71
|
await logger.onJobStart?.("example:job:export", "bull-1", {});
|
|
90
72
|
await logger.onJobComplete?.("example:job:export", "bull-1", 123, [
|
|
91
73
|
{ level: "info", message: "started", timestamp: Temporal.Now.instant() },
|
|
92
74
|
{ level: "info", message: "done", timestamp: Temporal.Now.instant() },
|
|
93
75
|
]);
|
|
94
76
|
|
|
95
|
-
const
|
|
77
|
+
const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-1" });
|
|
78
|
+
expect(runs.length).toBe(1);
|
|
79
|
+
expect(runs[0]?.status).toBe("completed");
|
|
80
|
+
expect(runs[0]?.duration).toBe(123);
|
|
96
81
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
expect(
|
|
100
|
-
expect(p.logs).toHaveLength(2);
|
|
101
|
-
expect(p.logs[0]?.level).toBe("info");
|
|
82
|
+
const logs = await selectMany(testDb.db, jobRunLogsTable, { runId: runs[0]?.id as string });
|
|
83
|
+
expect(logs).toHaveLength(2);
|
|
84
|
+
expect(logs[0]?.level).toBe("info");
|
|
102
85
|
});
|
|
103
86
|
|
|
104
|
-
test("onJobFailed
|
|
87
|
+
test("onJobFailed updates the row to failed with error + logs", async () => {
|
|
105
88
|
await logger.onJobStart?.("example:job:fragile", "bull-9", {});
|
|
106
89
|
await logger.onJobFailed?.("example:job:fragile", "bull-9", "boom", [
|
|
107
90
|
{ level: "error", message: "kaboom", timestamp: Temporal.Now.instant() },
|
|
108
91
|
]);
|
|
109
92
|
|
|
110
|
-
const
|
|
93
|
+
const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-9" });
|
|
94
|
+
expect(runs.length).toBe(1);
|
|
95
|
+
expect(runs[0]?.status).toBe("failed");
|
|
96
|
+
expect(runs[0]?.error).toBe("boom");
|
|
111
97
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
expect(p.error).toBe("boom");
|
|
115
|
-
expect(p.logs).toHaveLength(1);
|
|
98
|
+
const logs = await selectMany(testDb.db, jobRunLogsTable, { runId: runs[0]?.id as string });
|
|
99
|
+
expect(logs).toHaveLength(1);
|
|
116
100
|
});
|
|
117
101
|
|
|
118
|
-
test("start + complete both
|
|
102
|
+
test("start + complete both act on the SAME row", async () => {
|
|
119
103
|
await logger.onJobStart?.("example:job:stream", "bull-99", {});
|
|
120
104
|
await logger.onJobComplete?.("example:job:stream", "bull-99", 10, []);
|
|
121
105
|
|
|
122
|
-
//
|
|
123
|
-
// the
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
expect(events.length).toBe(2);
|
|
128
|
-
const ids = new Set(events.map((e) => e.aggregateId));
|
|
129
|
-
expect(ids.size).toBe(1);
|
|
106
|
+
// Exactly one row for this bullJobId — the complete-callback updated
|
|
107
|
+
// the row onJobStart created, it did not insert a second one.
|
|
108
|
+
const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-99" });
|
|
109
|
+
expect(runs.length).toBe(1);
|
|
110
|
+
expect(runs[0]?.status).toBe("completed");
|
|
130
111
|
});
|
|
131
112
|
|
|
132
|
-
test("complete/fail without a prior start skips — does not forge a
|
|
133
|
-
// State-loss path: worker restart with empty cache AND no
|
|
134
|
-
//
|
|
135
|
-
//
|
|
113
|
+
test("complete/fail without a prior start skips — does not forge a run row", async () => {
|
|
114
|
+
// State-loss path: worker restart with empty cache AND no row for this
|
|
115
|
+
// bullJobId. Dropping the terminal write is intentional — forging a
|
|
116
|
+
// row from scratch would invent a run that never started.
|
|
136
117
|
await logger.onJobComplete?.("example:job:orphan", "bull-orphan-complete", 50, []);
|
|
137
118
|
await logger.onJobFailed?.("example:job:orphan", "bull-orphan-failed", "boom", []);
|
|
138
119
|
|
|
139
|
-
const completed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_COMPLETED_EVENT });
|
|
140
|
-
const failed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_FAILED_EVENT });
|
|
141
120
|
const runs = await selectMany(testDb.db, jobRunsTable);
|
|
142
|
-
|
|
143
|
-
expect(completed).toHaveLength(0);
|
|
144
|
-
expect(failed).toHaveLength(0);
|
|
145
121
|
expect(runs).toHaveLength(0);
|
|
146
122
|
});
|
|
147
123
|
|
|
148
|
-
test("complete after cache loss recovers runId from
|
|
149
|
-
// Simulates worker process restart: in-memory bullJobId→runId cache is
|
|
150
|
-
// but the run-started
|
|
151
|
-
// must DB-lookup and
|
|
124
|
+
test("complete after cache loss recovers runId from jobRunsTable (same row)", async () => {
|
|
125
|
+
// Simulates worker process restart: in-memory bullJobId→runId cache is
|
|
126
|
+
// gone, but the run-started row still has bull_job_id. A fresh logger
|
|
127
|
+
// must DB-lookup and update the original row — not insert a second one.
|
|
152
128
|
await logger.onJobStart?.("example:job:restart", "bull-restart-1", {});
|
|
153
|
-
const started = await selectMany(testDb.db,
|
|
129
|
+
const started = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-restart-1" });
|
|
154
130
|
expect(started).toHaveLength(1);
|
|
155
|
-
const
|
|
156
|
-
expect(
|
|
131
|
+
const originalId = started[0]?.id;
|
|
132
|
+
expect(originalId).toBeTruthy();
|
|
157
133
|
|
|
158
134
|
const coldLogger = createJobRunLogger({ db: testDb.db, registry });
|
|
159
135
|
await coldLogger.onJobComplete?.("example:job:restart", "bull-restart-1", 42, []);
|
|
160
136
|
|
|
161
|
-
const all = await selectMany(testDb.db,
|
|
162
|
-
expect(all).toHaveLength(
|
|
163
|
-
expect(
|
|
164
|
-
expect(all
|
|
137
|
+
const all = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-restart-1" });
|
|
138
|
+
expect(all).toHaveLength(1);
|
|
139
|
+
expect(all[0]?.id).toBe(originalId);
|
|
140
|
+
expect(all[0]?.status).toBe("completed");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("no jobs:event:run-* rows ever land in the event store", async () => {
|
|
144
|
+
await logger.onJobStart?.("example:job:no-events", "bull-no-events", {});
|
|
145
|
+
await logger.onJobComplete?.("example:job:no-events", "bull-no-events", 5, []);
|
|
146
|
+
|
|
147
|
+
const events = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
|
|
148
|
+
expect(events).toHaveLength(0);
|
|
165
149
|
});
|
|
166
150
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
// arbitrary user data and is written
|
|
3
|
-
//
|
|
4
|
-
// With a KMS active the stored
|
|
5
|
-
// ciphertext under the triggering user's DEK; erasing that key
|
|
6
|
-
// payload unreadable ([[erased]]) without touching the
|
|
1
|
+
// PII on the jobs run-logger, direct-write path (#2243, formerly #799):
|
|
2
|
+
// run-started payload can carry arbitrary user data and is written straight
|
|
3
|
+
// into jobRunsTable (no event store, no event-PII catalog — #2243 removed
|
|
4
|
+
// the jobRun r.defineEvent registrations). With a KMS active the stored row
|
|
5
|
+
// carries ciphertext under the triggering user's DEK; erasing that key
|
|
6
|
+
// makes the payload unreadable ([[erased]]) without touching the row.
|
|
7
7
|
|
|
8
8
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
26
|
import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
27
|
import { createJobsFeature } from "../feature";
|
|
28
|
-
import { createJobRunLogger
|
|
28
|
+
import { createJobRunLogger } from "../job-run-logger";
|
|
29
29
|
import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
|
|
30
30
|
|
|
31
31
|
let testDb: TestDb;
|
|
@@ -39,10 +39,10 @@ const SECRET_PAYLOAD = JSON.stringify({ iban: "DE89370400440532013000" });
|
|
|
39
39
|
beforeAll(async () => {
|
|
40
40
|
testDb = await createTestDb();
|
|
41
41
|
testRedis = await createTestRedis();
|
|
42
|
-
// createRegistry publishes the event-PII catalog as a module singleton —
|
|
43
|
-
// the logger's low-level append() picks it up without further wiring.
|
|
44
42
|
const registry = createRegistry([createJobsFeature()]);
|
|
45
43
|
await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
|
|
44
|
+
// Kept only so the "no event store involved" assertion below has a table
|
|
45
|
+
// to assert against — the write path itself never touches it.
|
|
46
46
|
await createEventsTable(testDb.db);
|
|
47
47
|
logger = createJobRunLogger({ db: testDb.db, registry });
|
|
48
48
|
});
|
|
@@ -63,25 +63,28 @@ afterEach(() => {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
describe("jobs run-started payload under KMS", () => {
|
|
66
|
-
test("stored
|
|
66
|
+
test("stored row carries ciphertext payload, plaintext triggeredById — no event-store write", async () => {
|
|
67
67
|
await logger.onJobStart?.("app:job:export", "bull-1", {
|
|
68
68
|
triggeredById: USER_ID,
|
|
69
69
|
payload: SECRET_PAYLOAD,
|
|
70
70
|
attempt: 1,
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
const
|
|
74
|
-
expect(
|
|
75
|
-
|
|
76
|
-
expect(
|
|
77
|
-
expect(String(payload["payload"])).toContain(`user:${USER_ID}`);
|
|
78
|
-
expect(payload["triggeredById"]).toBe(USER_ID);
|
|
73
|
+
const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-1" });
|
|
74
|
+
expect(isPiiCiphertext(row?.["payload"])).toBe(true);
|
|
75
|
+
expect(String(row?.["payload"])).toContain(`user:${USER_ID}`);
|
|
76
|
+
expect(row?.["triggeredById"]).toBe(USER_ID);
|
|
79
77
|
|
|
80
|
-
const back = await decryptPiiFieldValues(payload, ["payload"], kms, {
|
|
78
|
+
const back = await decryptPiiFieldValues({ payload: row?.["payload"] }, ["payload"], kms, {
|
|
79
|
+
requestId: "t",
|
|
80
|
+
});
|
|
81
81
|
expect(back["payload"]).toBe(SECRET_PAYLOAD);
|
|
82
|
+
|
|
83
|
+
const events = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
|
|
84
|
+
expect(events).toHaveLength(0);
|
|
82
85
|
});
|
|
83
86
|
|
|
84
|
-
test("
|
|
87
|
+
test("erase subject key → row payload decrypts to [[erased]]", async () => {
|
|
85
88
|
await logger.onJobStart?.("app:job:export", "bull-2", {
|
|
86
89
|
triggeredById: USER_ID,
|
|
87
90
|
payload: SECRET_PAYLOAD,
|