@malloy-publisher/server 0.0.224 → 0.0.226
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/api-doc.yaml +76 -13
- package/dist/app/assets/{EnvironmentPage-DYTeXDll.js → EnvironmentPage-DvOJ7L_b.js} +1 -1
- package/dist/app/assets/{HomePage-pDK2BPJY.js → HomePage-CXguJsXS.js} +1 -1
- package/dist/app/assets/{LightMode-C2bwGPY1.js → LightMode-ZsshUznu.js} +1 -1
- package/dist/app/assets/{MainPage-WtBulMH_.js → MainPage-BIe0VwBa.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-hMgOtflG.js → MaterializationsPage-BuZ6UJVx.js} +1 -1
- package/dist/app/assets/{ModelPage-B2N5kYII.js → ModelPage-DsPf-s8B.js} +1 -1
- package/dist/app/assets/{PackagePage-CEN90nQG.js → PackagePage-CEVNAKZa.js} +1 -1
- package/dist/app/assets/{RouteError-BG2c5Zf0.js → RouteError-Chn7lL96.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DNfeUwEZ.js → ThemeEditorPage-DWC_FdNU.js} +1 -1
- package/dist/app/assets/{WorkbookPage-NKI1BhFS.js → WorkbookPage-CGrsFz8p.js} +1 -1
- package/dist/app/assets/{core-C6anj5c0.es-DDLHqpzt.js → core-vVgoO8IR.es-BD_THWs_.js} +1 -1
- package/dist/app/assets/{index-DMQtnaf4.js → index-BioohWQj.js} +1 -1
- package/dist/app/assets/{index-CzNqKMVl.js → index-D6YtyiJ0.js} +1 -1
- package/dist/app/assets/{index-C6gZ6sSY.js → index-DNUZpnaa.js} +4 -4
- package/dist/app/assets/{index-JXgvyZna.js → index-gEWxu09x.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +71 -15
- package/dist/package_load_worker.mjs +83 -16
- package/dist/server.mjs +249 -46
- package/dist/sshcrypto-8m50vnmb.node +0 -0
- package/package.json +1 -1
- package/src/controller/materialization.controller.spec.ts +72 -0
- package/src/controller/materialization.controller.ts +75 -11
- package/src/controller/package.controller.spec.ts +17 -17
- package/src/controller/package.controller.ts +7 -6
- package/src/logger.spec.ts +193 -0
- package/src/logger.ts +115 -18
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/package_load/package_load_pool.ts +5 -1
- package/src/package_load/package_load_worker.ts +7 -0
- package/src/package_load/protocol.ts +5 -1
- package/src/service/build_plan.spec.ts +110 -9
- package/src/service/build_plan.ts +126 -7
- package/src/service/environment.ts +4 -0
- package/src/service/materialization_service.spec.ts +42 -0
- package/src/service/materialization_service.ts +40 -2
- package/src/service/materialization_test_fixtures.ts +46 -15
- package/src/service/package.ts +116 -32
- package/src/service/package_manifest.spec.ts +22 -1
- package/src/service/package_manifest.ts +29 -0
- package/src/service/persistence_policy.spec.ts +234 -0
- package/src/storage/DatabaseInterface.ts +1 -0
- package/tests/fixtures/persist-multi-level/data/orders.csv +5 -0
- package/tests/fixtures/persist-multi-level/multi_level.malloy +18 -0
- package/tests/fixtures/persist-multi-level/publisher.json +5 -0
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +251 -0
- package/src/service/materialization_cron_gate.spec.ts +0 -142
|
Binary file
|
package/package.json
CHANGED
|
@@ -85,6 +85,78 @@ describe("MaterializationController.createMaterialization validation", () => {
|
|
|
85
85
|
expect(await parse({ buildInstructions: null })).toEqual({});
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
it("parses referenceManifest and strictUpstreams alongside sources", async () => {
|
|
89
|
+
const parsed = await parse({
|
|
90
|
+
buildInstructions: {
|
|
91
|
+
sources: [
|
|
92
|
+
{
|
|
93
|
+
sourceEntityId: "b2",
|
|
94
|
+
materializedTableId: "mt-2",
|
|
95
|
+
physicalTableName: "downstream_v1",
|
|
96
|
+
realization: "COPY",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
referenceManifest: [
|
|
100
|
+
{ sourceEntityId: "b1", physicalTableName: "upstream_table" },
|
|
101
|
+
],
|
|
102
|
+
strictUpstreams: true,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
expect(parsed).toEqual({
|
|
106
|
+
buildInstructions: [
|
|
107
|
+
{
|
|
108
|
+
sourceEntityId: "b2",
|
|
109
|
+
sourceID: undefined,
|
|
110
|
+
materializedTableId: "mt-2",
|
|
111
|
+
physicalTableName: "downstream_v1",
|
|
112
|
+
realization: "COPY",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
referenceManifest: [
|
|
116
|
+
{ sourceEntityId: "b1", physicalTableName: "upstream_table" },
|
|
117
|
+
],
|
|
118
|
+
strictUpstreams: true,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("rejects a referenceManifest entry missing a required field", async () => {
|
|
123
|
+
const { controller } = build();
|
|
124
|
+
await expect(
|
|
125
|
+
controller.createMaterialization("env", "pkg", {
|
|
126
|
+
buildInstructions: {
|
|
127
|
+
sources: [
|
|
128
|
+
{
|
|
129
|
+
sourceEntityId: "b2",
|
|
130
|
+
materializedTableId: "mt-2",
|
|
131
|
+
physicalTableName: "downstream_v1",
|
|
132
|
+
realization: "COPY",
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
referenceManifest: [{ sourceEntityId: "b1" }],
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
).rejects.toThrow(BadRequestError);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects a non-boolean strictUpstreams", async () => {
|
|
142
|
+
const { controller } = build();
|
|
143
|
+
await expect(
|
|
144
|
+
controller.createMaterialization("env", "pkg", {
|
|
145
|
+
buildInstructions: {
|
|
146
|
+
sources: [
|
|
147
|
+
{
|
|
148
|
+
sourceEntityId: "b2",
|
|
149
|
+
materializedTableId: "mt-2",
|
|
150
|
+
physicalTableName: "downstream_v1",
|
|
151
|
+
realization: "COPY",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
strictUpstreams: "yes",
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
).rejects.toThrow(BadRequestError);
|
|
158
|
+
});
|
|
159
|
+
|
|
88
160
|
it("rejects buildInstructions without a non-empty sources array", async () => {
|
|
89
161
|
const { controller } = build();
|
|
90
162
|
await expect(
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { BadRequestError } from "../errors";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BuildInstruction,
|
|
4
|
+
ManifestReference,
|
|
5
|
+
} from "../storage/DatabaseInterface";
|
|
3
6
|
import { MaterializationService } from "../service/materialization_service";
|
|
4
7
|
|
|
5
8
|
export class MaterializationController {
|
|
@@ -21,19 +24,28 @@ export class MaterializationController {
|
|
|
21
24
|
forceRefresh?: boolean;
|
|
22
25
|
sourceNames?: string[];
|
|
23
26
|
buildInstructions?: BuildInstruction[];
|
|
27
|
+
referenceManifest?: ManifestReference[];
|
|
28
|
+
strictUpstreams?: boolean;
|
|
24
29
|
} {
|
|
25
30
|
const result: {
|
|
26
31
|
forceRefresh?: boolean;
|
|
27
32
|
sourceNames?: string[];
|
|
28
33
|
buildInstructions?: BuildInstruction[];
|
|
34
|
+
referenceManifest?: ManifestReference[];
|
|
35
|
+
strictUpstreams?: boolean;
|
|
29
36
|
} = {};
|
|
30
37
|
if (
|
|
31
38
|
body.buildInstructions !== undefined &&
|
|
32
39
|
body.buildInstructions !== null
|
|
33
40
|
) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
41
|
+
const parsed = this.validateBuildInstructions(body.buildInstructions);
|
|
42
|
+
result.buildInstructions = parsed.sources;
|
|
43
|
+
if (parsed.referenceManifest !== undefined) {
|
|
44
|
+
result.referenceManifest = parsed.referenceManifest;
|
|
45
|
+
}
|
|
46
|
+
if (parsed.strictUpstreams !== undefined) {
|
|
47
|
+
result.strictUpstreams = parsed.strictUpstreams;
|
|
48
|
+
}
|
|
37
49
|
}
|
|
38
50
|
if (body.forceRefresh !== undefined) {
|
|
39
51
|
if (typeof body.forceRefresh !== "boolean") {
|
|
@@ -57,22 +69,74 @@ export class MaterializationController {
|
|
|
57
69
|
|
|
58
70
|
/**
|
|
59
71
|
* Validate the orchestrated `buildInstructions` payload (BuildInstructions:
|
|
60
|
-
* `{ sources: BuildInstruction[] }`)
|
|
61
|
-
* the service consumes
|
|
72
|
+
* `{ sources: BuildInstruction[], referenceManifest?, strictUpstreams? }`)
|
|
73
|
+
* into the parts the service consumes: the flattened instruction list, the
|
|
74
|
+
* optional upstream reference manifest, and the strict flag.
|
|
62
75
|
*/
|
|
63
|
-
private validateBuildInstructions(raw: unknown):
|
|
76
|
+
private validateBuildInstructions(raw: unknown): {
|
|
77
|
+
sources: BuildInstruction[];
|
|
78
|
+
referenceManifest?: ManifestReference[];
|
|
79
|
+
strictUpstreams?: boolean;
|
|
80
|
+
} {
|
|
64
81
|
if (typeof raw !== "object" || raw === null) {
|
|
65
82
|
throw new BadRequestError("buildInstructions must be an object");
|
|
66
83
|
}
|
|
67
|
-
const
|
|
84
|
+
const obj = raw as Record<string, unknown>;
|
|
85
|
+
const sources = obj.sources;
|
|
68
86
|
if (!Array.isArray(sources) || sources.length === 0) {
|
|
69
87
|
throw new BadRequestError(
|
|
70
88
|
"buildInstructions requires a non-empty 'sources' array of BuildInstruction",
|
|
71
89
|
);
|
|
72
90
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
const result: {
|
|
92
|
+
sources: BuildInstruction[];
|
|
93
|
+
referenceManifest?: ManifestReference[];
|
|
94
|
+
strictUpstreams?: boolean;
|
|
95
|
+
} = {
|
|
96
|
+
sources: sources.map((instruction) =>
|
|
97
|
+
this.validateInstruction(instruction),
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
if (
|
|
101
|
+
obj.referenceManifest !== undefined &&
|
|
102
|
+
obj.referenceManifest !== null
|
|
103
|
+
) {
|
|
104
|
+
if (!Array.isArray(obj.referenceManifest)) {
|
|
105
|
+
throw new BadRequestError(
|
|
106
|
+
"buildInstructions.referenceManifest must be an array of ManifestReference",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
result.referenceManifest = obj.referenceManifest.map((ref) =>
|
|
110
|
+
this.validateManifestReference(ref),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (obj.strictUpstreams !== undefined) {
|
|
114
|
+
if (typeof obj.strictUpstreams !== "boolean") {
|
|
115
|
+
throw new BadRequestError(
|
|
116
|
+
"buildInstructions.strictUpstreams must be a boolean",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
result.strictUpstreams = obj.strictUpstreams;
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private validateManifestReference(raw: unknown): ManifestReference {
|
|
125
|
+
if (typeof raw !== "object" || raw === null) {
|
|
126
|
+
throw new BadRequestError("Each manifest reference must be an object");
|
|
127
|
+
}
|
|
128
|
+
const ref = raw as Record<string, unknown>;
|
|
129
|
+
for (const field of ["sourceEntityId", "physicalTableName"] as const) {
|
|
130
|
+
if (typeof ref[field] !== "string") {
|
|
131
|
+
throw new BadRequestError(
|
|
132
|
+
`Manifest reference is missing required string field '${field}'`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
sourceEntityId: ref.sourceEntityId as string,
|
|
138
|
+
physicalTableName: ref.physicalTableName as string,
|
|
139
|
+
};
|
|
76
140
|
}
|
|
77
141
|
|
|
78
142
|
private validateInstruction(raw: unknown): BuildInstruction {
|
|
@@ -18,7 +18,7 @@ describe("PackageController.addPackage explores validation", () => {
|
|
|
18
18
|
"Invalid explores entry 'missing.malloy' in publisher.json: file not found";
|
|
19
19
|
const mockPackage = {
|
|
20
20
|
formatInvalidExplores: () => invalidMsg,
|
|
21
|
-
|
|
21
|
+
formatInvalidPersistencePolicy: () => "",
|
|
22
22
|
};
|
|
23
23
|
const unloadPackage = sinon.stub().resolves(undefined);
|
|
24
24
|
const deletePackage = sinon.stub().resolves(undefined);
|
|
@@ -55,7 +55,7 @@ describe("PackageController.addPackage explores validation", () => {
|
|
|
55
55
|
"Invalid explores entry 'missing.malloy' in publisher.json: file not found";
|
|
56
56
|
const mockPackage = {
|
|
57
57
|
formatInvalidExplores: () => invalidMsg,
|
|
58
|
-
|
|
58
|
+
formatInvalidPersistencePolicy: () => "",
|
|
59
59
|
};
|
|
60
60
|
// installPackage mimics the real contract: invoke the validator and, if it
|
|
61
61
|
// returns a message, throw BadRequestError (after its internal rollback).
|
|
@@ -104,7 +104,7 @@ describe("PackageController.addPackage explores validation", () => {
|
|
|
104
104
|
it("persists when explores are valid (no-location)", async () => {
|
|
105
105
|
const mockPackage = {
|
|
106
106
|
formatInvalidExplores: () => "",
|
|
107
|
-
|
|
107
|
+
formatInvalidPersistencePolicy: () => "",
|
|
108
108
|
};
|
|
109
109
|
const addPackage = sinon.stub().resolves(mockPackage);
|
|
110
110
|
const getEnvironment = sinon.stub().resolves({ addPackage });
|
|
@@ -127,22 +127,22 @@ describe("PackageController.addPackage explores validation", () => {
|
|
|
127
127
|
});
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
describe("PackageController.addPackage
|
|
130
|
+
describe("PackageController.addPackage persistence policy validation", () => {
|
|
131
131
|
afterEach(() => {
|
|
132
132
|
sinon.restore();
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
it("rejects a publish whose
|
|
136
|
-
// Valid explores but an invalid
|
|
137
|
-
// still 400 (strict-at-publish,
|
|
138
|
-
// warns) and roll back via
|
|
135
|
+
it("rejects a publish whose persistence policy is invalid (no-location path)", async () => {
|
|
136
|
+
// Valid explores but an invalid persistence policy (e.g. a schedule on a
|
|
137
|
+
// package-scoped package): the publish must still 400 (strict-at-publish,
|
|
138
|
+
// same split as explores — load merely warns) and roll back via
|
|
139
|
+
// unloadPackage.
|
|
139
140
|
const cronMsg =
|
|
140
|
-
|
|
141
|
-
"
|
|
142
|
-
"'orders' resolves to unset.";
|
|
141
|
+
'materialization.schedule (cron) in publisher.json requires "scope": ' +
|
|
142
|
+
'"version".';
|
|
143
143
|
const mockPackage = {
|
|
144
144
|
formatInvalidExplores: () => "",
|
|
145
|
-
|
|
145
|
+
formatInvalidPersistencePolicy: () => cronMsg,
|
|
146
146
|
};
|
|
147
147
|
const unloadPackage = sinon.stub().resolves(undefined);
|
|
148
148
|
const addPackage = sinon.stub().resolves(mockPackage);
|
|
@@ -165,13 +165,13 @@ describe("PackageController.addPackage cron schedule validation", () => {
|
|
|
165
165
|
expect(addPackageToDatabase.called).toBe(false);
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
it("location path: the
|
|
168
|
+
it("location path: the persistence-policy gate runs inside installPackage's rollback window", async () => {
|
|
169
169
|
const cronMsg =
|
|
170
|
-
|
|
171
|
-
"
|
|
170
|
+
'materialization.schedule (cron) in publisher.json requires "scope": ' +
|
|
171
|
+
'"version".';
|
|
172
172
|
const mockPackage = {
|
|
173
173
|
formatInvalidExplores: () => "",
|
|
174
|
-
|
|
174
|
+
formatInvalidPersistencePolicy: () => cronMsg,
|
|
175
175
|
};
|
|
176
176
|
const installPackage = sinon
|
|
177
177
|
.stub()
|
|
@@ -225,7 +225,7 @@ describe("PackageController.updatePackage explores validation", () => {
|
|
|
225
225
|
const mockPackage = {
|
|
226
226
|
formatInvalidExplores: (override?: string[]) =>
|
|
227
227
|
override?.includes("nope.malloy") ? invalidMsg : "",
|
|
228
|
-
|
|
228
|
+
formatInvalidPersistencePolicy: () => "",
|
|
229
229
|
};
|
|
230
230
|
const installPackage = sinon
|
|
231
231
|
.stub()
|
|
@@ -8,21 +8,22 @@ type ApiPackage = components["schemas"]["Package"];
|
|
|
8
8
|
/**
|
|
9
9
|
* Everything that is strict-at-publish, joined into one 400 message (or
|
|
10
10
|
* undefined when the package is publishable): invalid explores entries plus
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* the Malloy Persistence policy gate (scope is package-level; a
|
|
12
|
+
* `materialization.schedule` is package-root-only + version-scope-only and
|
|
13
|
+
* mutually exclusive with freshness; per-source `sharing`/`schedule` are
|
|
14
|
+
* retired — see Package.persistencePolicyWarnings). At startup/reload both are
|
|
15
|
+
* warn-only instead (fail-safe; see Package.loadViaWorker).
|
|
15
16
|
*/
|
|
16
17
|
function formatPublishRejections(
|
|
17
18
|
pkg: {
|
|
18
19
|
formatInvalidExplores(exploresOverride?: string[]): string;
|
|
19
|
-
|
|
20
|
+
formatInvalidPersistencePolicy(): string;
|
|
20
21
|
},
|
|
21
22
|
exploresOverride?: string[],
|
|
22
23
|
): string | undefined {
|
|
23
24
|
const message = [
|
|
24
25
|
pkg.formatInvalidExplores(exploresOverride),
|
|
25
|
-
pkg.
|
|
26
|
+
pkg.formatInvalidPersistencePolicy(),
|
|
26
27
|
]
|
|
27
28
|
.filter(Boolean)
|
|
28
29
|
.join("\n");
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { AxiosError } from "axios";
|
|
3
|
+
import { buildAxiosErrorLog, redactSensitive } from "./logger";
|
|
4
|
+
|
|
5
|
+
describe("redactSensitive", () => {
|
|
6
|
+
it("masks every known credential field at the top level", () => {
|
|
7
|
+
const input = {
|
|
8
|
+
password: "hunter2",
|
|
9
|
+
connectionString: "postgresql://u:p@h/db",
|
|
10
|
+
serviceAccountKeyJson: '{"private_key":"-----BEGIN..."}',
|
|
11
|
+
privateKey: "-----BEGIN PRIVATE KEY-----",
|
|
12
|
+
privateKeyPass: "passphrase",
|
|
13
|
+
secret: "gcs-hmac-secret",
|
|
14
|
+
secretAccessKey: "aws-secret",
|
|
15
|
+
sessionToken: "aws-session",
|
|
16
|
+
sasUrl: "https://a.blob.core.windows.net/c?sig=abc",
|
|
17
|
+
clientSecret: "azure-client-secret",
|
|
18
|
+
oauthClientSecret: "databricks-oauth-secret",
|
|
19
|
+
peakaKey: "peaka-api-key",
|
|
20
|
+
token: "databricks-pat",
|
|
21
|
+
accessToken: "motherduck-token",
|
|
22
|
+
};
|
|
23
|
+
const out = redactSensitive(input) as Record<string, unknown>;
|
|
24
|
+
for (const key of Object.keys(input)) {
|
|
25
|
+
expect(out[key]).toBe("[REDACTED]");
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps non-secret fields intact", () => {
|
|
30
|
+
const input = {
|
|
31
|
+
name: "bigquery",
|
|
32
|
+
type: "bigquery",
|
|
33
|
+
defaultProjectId: "my-project",
|
|
34
|
+
host: "db.example.com",
|
|
35
|
+
port: 5432,
|
|
36
|
+
};
|
|
37
|
+
expect(redactSensitive(input)).toEqual(input);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("redacts secrets nested inside the environment PATCH payload shape", () => {
|
|
41
|
+
const input = {
|
|
42
|
+
name: "demo___malloy-samples",
|
|
43
|
+
connections: [
|
|
44
|
+
{
|
|
45
|
+
name: "bigquery",
|
|
46
|
+
type: "bigquery",
|
|
47
|
+
bigqueryConnection: {
|
|
48
|
+
defaultProjectId: "vscode-demo",
|
|
49
|
+
serviceAccountKeyJson: '{"private_key":"-----BEGIN..."}',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "warehouse",
|
|
54
|
+
type: "snowflake",
|
|
55
|
+
snowflakeConnection: {
|
|
56
|
+
account: "acct",
|
|
57
|
+
username: "svc",
|
|
58
|
+
password: "s3cret",
|
|
59
|
+
privateKey: "-----BEGIN PRIVATE KEY-----",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
expect(redactSensitive(input)).toEqual({
|
|
65
|
+
name: "demo___malloy-samples",
|
|
66
|
+
connections: [
|
|
67
|
+
{
|
|
68
|
+
name: "bigquery",
|
|
69
|
+
type: "bigquery",
|
|
70
|
+
bigqueryConnection: {
|
|
71
|
+
defaultProjectId: "vscode-demo",
|
|
72
|
+
serviceAccountKeyJson: "[REDACTED]",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "warehouse",
|
|
77
|
+
type: "snowflake",
|
|
78
|
+
snowflakeConnection: {
|
|
79
|
+
account: "acct",
|
|
80
|
+
username: "svc",
|
|
81
|
+
password: "[REDACTED]",
|
|
82
|
+
privateKey: "[REDACTED]",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("recurses through arrays of secrets", () => {
|
|
90
|
+
const out = redactSensitive([{ password: "a" }, { password: "b" }]);
|
|
91
|
+
expect(out).toEqual([
|
|
92
|
+
{ password: "[REDACTED]" },
|
|
93
|
+
{ password: "[REDACTED]" },
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("passes primitives through unchanged", () => {
|
|
98
|
+
expect(redactSensitive("plain")).toBe("plain");
|
|
99
|
+
expect(redactSensitive(42)).toBe(42);
|
|
100
|
+
expect(redactSensitive(null)).toBeNull();
|
|
101
|
+
expect(redactSensitive(undefined)).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("preserves Date values instead of destructuring them", () => {
|
|
105
|
+
const d = new Date("2026-07-08T00:00:00.000Z");
|
|
106
|
+
const out = redactSensitive({ createdAt: d }) as { createdAt: Date };
|
|
107
|
+
expect(out.createdAt).toBeInstanceOf(Date);
|
|
108
|
+
expect(out.createdAt.getTime()).toBe(d.getTime());
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not mutate the input object", () => {
|
|
112
|
+
const input = { password: "hunter2", nested: { token: "abc" } };
|
|
113
|
+
redactSensitive(input);
|
|
114
|
+
expect(input.password).toBe("hunter2");
|
|
115
|
+
expect(input.nested.token).toBe("abc");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("tolerates circular references", () => {
|
|
119
|
+
const input: Record<string, unknown> = { password: "hunter2" };
|
|
120
|
+
input.self = input;
|
|
121
|
+
const out = redactSensitive(input) as Record<string, unknown>;
|
|
122
|
+
expect(out.password).toBe("[REDACTED]");
|
|
123
|
+
expect(out.self).toBe("[Circular]");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("matches credential keys case-insensitively", () => {
|
|
127
|
+
const out = redactSensitive({
|
|
128
|
+
Password: "hunter2",
|
|
129
|
+
Authorization: "Bearer abc",
|
|
130
|
+
}) as Record<string, unknown>;
|
|
131
|
+
expect(out.Password).toBe("[REDACTED]");
|
|
132
|
+
expect(out.Authorization).toBe("[REDACTED]");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("masks credential HTTP headers", () => {
|
|
136
|
+
const out = redactSensitive({
|
|
137
|
+
"content-type": "application/json",
|
|
138
|
+
authorization: "Bearer secret-token",
|
|
139
|
+
cookie: "session=abc",
|
|
140
|
+
}) as Record<string, unknown>;
|
|
141
|
+
expect(out["content-type"]).toBe("application/json");
|
|
142
|
+
expect(out.authorization).toBe("[REDACTED]");
|
|
143
|
+
expect(out.cookie).toBe("[REDACTED]");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("buildAxiosErrorLog", () => {
|
|
148
|
+
it("redacts response data and headers on a server-side error", () => {
|
|
149
|
+
const error = {
|
|
150
|
+
response: {
|
|
151
|
+
config: { url: "http://worker/api" },
|
|
152
|
+
status: 500,
|
|
153
|
+
headers: { authorization: "Bearer abc", "content-type": "json" },
|
|
154
|
+
data: { connection: { serviceAccountKeyJson: "PRIVATE_KEY" } },
|
|
155
|
+
},
|
|
156
|
+
} as unknown as AxiosError;
|
|
157
|
+
const { message, meta } = buildAxiosErrorLog(error);
|
|
158
|
+
expect(message).toBe("Axios server-side error");
|
|
159
|
+
expect(meta).toEqual({
|
|
160
|
+
url: "http://worker/api",
|
|
161
|
+
status: 500,
|
|
162
|
+
headers: { authorization: "[REDACTED]", "content-type": "json" },
|
|
163
|
+
data: { connection: { serviceAccountKeyJson: "[REDACTED]" } },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("never logs the raw request object on a client-side error", () => {
|
|
168
|
+
const error = {
|
|
169
|
+
request: { _header: "GET / HTTP/1.1\r\nAuthorization: Bearer abc" },
|
|
170
|
+
config: { method: "get", url: "http://worker/api" },
|
|
171
|
+
code: "ECONNABORTED",
|
|
172
|
+
} as unknown as AxiosError;
|
|
173
|
+
const { message, meta } = buildAxiosErrorLog(error);
|
|
174
|
+
expect(message).toBe("Axios client-side error");
|
|
175
|
+
expect(meta).toEqual({
|
|
176
|
+
method: "get",
|
|
177
|
+
url: "http://worker/api",
|
|
178
|
+
code: "ECONNABORTED",
|
|
179
|
+
});
|
|
180
|
+
expect(JSON.stringify(meta)).not.toContain("Bearer abc");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("logs only the message on a setup error", () => {
|
|
184
|
+
const error = {
|
|
185
|
+
message: "getaddrinfo ENOTFOUND",
|
|
186
|
+
config: { headers: { Authorization: "Bearer abc" } },
|
|
187
|
+
} as unknown as AxiosError;
|
|
188
|
+
const { message, meta } = buildAxiosErrorLog(error);
|
|
189
|
+
expect(message).toBe("Axios unknown error");
|
|
190
|
+
expect(meta).toEqual({ message: "getaddrinfo ENOTFOUND" });
|
|
191
|
+
expect(JSON.stringify(meta)).not.toContain("Bearer abc");
|
|
192
|
+
});
|
|
193
|
+
});
|