@blamejs/blamejs-shop 0.3.69 → 0.3.71
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/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/lib/admin.js +254 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/vendor/MANIFEST.json +95 -83
- package/lib/vendor/blamejs/.github/workflows/actions-lint.yml +3 -3
- package/lib/vendor/blamejs/.github/workflows/cflite_batch.yml +1 -1
- package/lib/vendor/blamejs/.github/workflows/cflite_pr.yml +1 -1
- package/lib/vendor/blamejs/.github/workflows/ci.yml +10 -10
- package/lib/vendor/blamejs/.github/workflows/codeql.yml +3 -3
- package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +2 -2
- package/lib/vendor/blamejs/.github/workflows/release-container.yml +4 -4
- package/lib/vendor/blamejs/.github/workflows/scorecard.yml +2 -2
- package/lib/vendor/blamejs/.github/workflows/sha-to-tag-verify.yml +1 -1
- package/lib/vendor/blamejs/CHANGELOG.md +4 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/SECURITY.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +108 -4
- package/lib/vendor/blamejs/lib/auth/oauth.js +736 -1
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +124 -5
- package/lib/vendor/blamejs/lib/auth/oid4vp.js +14 -4
- package/lib/vendor/blamejs/lib/auth/sd-jwt-vc-holder.js +46 -1
- package/lib/vendor/blamejs/lib/break-glass.js +1 -2
- package/lib/vendor/blamejs/lib/config.js +28 -31
- package/lib/vendor/blamejs/lib/crypto-field.js +274 -17
- package/lib/vendor/blamejs/lib/dora.js +8 -5
- package/lib/vendor/blamejs/lib/dsr.js +2 -2
- package/lib/vendor/blamejs/lib/flag-evaluation-context.js +7 -0
- package/lib/vendor/blamejs/lib/guard-html-wcag-aria.js +4 -2
- package/lib/vendor/blamejs/lib/guard-html-wcag-forms.js +4 -2
- package/lib/vendor/blamejs/lib/guard-html-wcag-tables.js +4 -2
- package/lib/vendor/blamejs/lib/guard-html-wcag-tagwalk.js +20 -0
- package/lib/vendor/blamejs/lib/guard-html-wcag.js +1 -1
- package/lib/vendor/blamejs/lib/honeytoken.js +27 -20
- package/lib/vendor/blamejs/lib/mail-auth.js +333 -0
- package/lib/vendor/blamejs/lib/mail-deploy.js +1 -1
- package/lib/vendor/blamejs/lib/mail-send-deliver.js +13 -4
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +140 -13
- package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +3 -0
- package/lib/vendor/blamejs/lib/middleware/csp-report.js +13 -9
- package/lib/vendor/blamejs/lib/middleware/fetch-metadata.js +115 -14
- package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +3 -0
- package/lib/vendor/blamejs/lib/middleware/scim-server.js +297 -19
- package/lib/vendor/blamejs/lib/middleware/security-headers.js +47 -0
- package/lib/vendor/blamejs/lib/middleware/security-txt.js +1 -2
- package/lib/vendor/blamejs/lib/middleware/trace-log-correlation.js +1 -2
- package/lib/vendor/blamejs/lib/network-smtp-policy.js +4 -4
- package/lib/vendor/blamejs/lib/object-store/sigv4-bucket-ops.js +11 -2
- package/lib/vendor/blamejs/lib/observability-tracer.js +1 -1
- package/lib/vendor/blamejs/lib/observability.js +39 -1
- package/lib/vendor/blamejs/lib/problem-details.js +56 -11
- package/lib/vendor/blamejs/lib/pubsub-cluster.js +16 -3
- package/lib/vendor/blamejs/lib/queue-sqs.js +20 -2
- package/lib/vendor/blamejs/lib/redis-client.js +32 -4
- package/lib/vendor/blamejs/lib/safe-redirect.js +16 -2
- package/lib/vendor/blamejs/memory/specs/node-26-map-getorinsert-migration.md +3 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.20.json +73 -0
- package/lib/vendor/blamejs/release-notes/v0.14.21.json +98 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +339 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +37 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/break-glass.test.js +22 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +315 -5
- package/lib/vendor/blamejs/test/layer-0-primitives/config.test.js +46 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-unseal-rate-cap.test.js +176 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +86 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dora.test.js +38 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dsr.test.js +29 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +236 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/fetch-metadata.test.js +190 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/flag.test.js +23 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-html-wcag.test.js +59 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/honeytoken.test.js +26 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-deploy-tlsrpt.test.js +16 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +108 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +269 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/observability-tracing.test.js +28 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/observability.test.js +39 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +37 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/problem-details.test.js +79 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/pubsub.test.js +49 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/queue-sqs.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +60 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-redirect.test.js +118 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +259 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sd-jwt-vc.test.js +46 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/security-headers.test.js +113 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/security-txt.test.js +111 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sigv4-bucket-ops.test.js +62 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/smtp-policy.test.js +39 -0
- package/package.json +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.middleware.fetchMetadata — Sec-Fetch-* resource-isolation gate.
|
|
4
|
+
*
|
|
5
|
+
* Covers the destination-vocabulary surface added for FedCM /
|
|
6
|
+
* Storage Access API traffic (Fetch Metadata Request Headers spec):
|
|
7
|
+
*
|
|
8
|
+
* - default behavior unchanged when the new opts are unset;
|
|
9
|
+
* - deniedDest refuses a "webidentity" (FedCM) Sec-Fetch-Dest on a
|
|
10
|
+
* non-identity route, regardless of Sec-Fetch-Site;
|
|
11
|
+
* - allowStorageAccess:false refuses the cross-site
|
|
12
|
+
* Sec-Fetch-Storage-Access: active|inactive escalation, and "none"
|
|
13
|
+
* passes through;
|
|
14
|
+
* - strictDest throws at config time on an unknown destination value;
|
|
15
|
+
* - membership tests are exact (no substring / prototype-pollution
|
|
16
|
+
* bypass).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
var helpers = require("../helpers");
|
|
20
|
+
var b = helpers.b;
|
|
21
|
+
var check = helpers.check;
|
|
22
|
+
var _bodyReq = helpers._bodyReq;
|
|
23
|
+
var _bodyRes = helpers._bodyRes;
|
|
24
|
+
|
|
25
|
+
function _run(mw, req, res) {
|
|
26
|
+
return new Promise(function (resolve) {
|
|
27
|
+
var settled = false;
|
|
28
|
+
function done(via) {
|
|
29
|
+
if (settled) return;
|
|
30
|
+
settled = true;
|
|
31
|
+
resolve({ next: via === "next", status: res._endedStatus, captured: res._captured });
|
|
32
|
+
}
|
|
33
|
+
// Register the finish listener BEFORE invoking the middleware: the
|
|
34
|
+
// refusal path is fully synchronous (writeHead + end → emit "finish")
|
|
35
|
+
// so a listener attached after mw() would miss the event entirely.
|
|
36
|
+
res.on("finish", function () { done("finish"); });
|
|
37
|
+
mw(req, res, function () { done("next"); });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _post(headers) {
|
|
42
|
+
return _bodyReq("POST", headers || {}, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _reason(captured) {
|
|
46
|
+
try { return JSON.parse(captured || "{}").error || ""; }
|
|
47
|
+
catch (_e) { return ""; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Default surface: opts unset → prior behavior is preserved. A FedCM
|
|
51
|
+
// webidentity Sec-Fetch-Dest cross-site is still refused by the existing
|
|
52
|
+
// cross-site rule (not by the new deniedDest path), and a same-origin
|
|
53
|
+
// webidentity passes through.
|
|
54
|
+
async function testDefaultUnchanged() {
|
|
55
|
+
check("middleware.fetchMetadata exposed",
|
|
56
|
+
typeof b.middleware.fetchMetadata === "function");
|
|
57
|
+
|
|
58
|
+
var mw = b.middleware.fetchMetadata({});
|
|
59
|
+
|
|
60
|
+
var sameOrigin = await _run(mw, _post({
|
|
61
|
+
"sec-fetch-site": "same-origin", "sec-fetch-dest": "webidentity",
|
|
62
|
+
}), _bodyRes());
|
|
63
|
+
check("same-origin webidentity passes through by default",
|
|
64
|
+
sameOrigin.next === true);
|
|
65
|
+
|
|
66
|
+
var cross = await _run(mw, _post({
|
|
67
|
+
"sec-fetch-site": "cross-site", "sec-fetch-dest": "webidentity",
|
|
68
|
+
}), _bodyRes());
|
|
69
|
+
check("cross-site request still refused by default (unchanged)",
|
|
70
|
+
cross.next === false && cross.status === 403);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// deniedDest gates webidentity first-class — refused even when the
|
|
74
|
+
// request is same-origin (a route that is not a FedCM endpoint should
|
|
75
|
+
// never see a webidentity destination).
|
|
76
|
+
async function testDeniedDestWebIdentity() {
|
|
77
|
+
var mw = b.middleware.fetchMetadata({ deniedDest: ["webidentity"] });
|
|
78
|
+
|
|
79
|
+
var denied = await _run(mw, _post({
|
|
80
|
+
"sec-fetch-site": "same-origin", "sec-fetch-dest": "webidentity",
|
|
81
|
+
}), _bodyRes());
|
|
82
|
+
check("deniedDest webidentity → 403 even same-origin",
|
|
83
|
+
denied.next === false && denied.status === 403);
|
|
84
|
+
check("deniedDest refusal carries the dest-not-allowed message",
|
|
85
|
+
/destination not allowed/i.test(_reason(denied.captured)));
|
|
86
|
+
|
|
87
|
+
// A non-denied destination on the same gate still passes through.
|
|
88
|
+
var allowed = await _run(mw, _post({
|
|
89
|
+
"sec-fetch-site": "same-origin", "sec-fetch-dest": "document",
|
|
90
|
+
}), _bodyRes());
|
|
91
|
+
check("non-denied destination passes through with deniedDest set",
|
|
92
|
+
allowed.next === true);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Storage Access API escalation: active|inactive refused when
|
|
96
|
+
// allowStorageAccess:false; none passes through; default (unset) lets it
|
|
97
|
+
// through.
|
|
98
|
+
async function testStorageAccessGate() {
|
|
99
|
+
var strict = b.middleware.fetchMetadata({ allowStorageAccess: false });
|
|
100
|
+
|
|
101
|
+
var active = await _run(strict, _post({
|
|
102
|
+
"sec-fetch-site": "cross-site", "sec-fetch-storage-access": "active",
|
|
103
|
+
}), _bodyRes());
|
|
104
|
+
check("allowStorageAccess:false refuses cross-site active escalation",
|
|
105
|
+
active.next === false && active.status === 403 &&
|
|
106
|
+
/storage access/i.test(_reason(active.captured)));
|
|
107
|
+
|
|
108
|
+
var inactive = await _run(strict, _post({
|
|
109
|
+
"sec-fetch-site": "cross-site", "sec-fetch-storage-access": "inactive",
|
|
110
|
+
}), _bodyRes());
|
|
111
|
+
check("allowStorageAccess:false refuses cross-site inactive escalation",
|
|
112
|
+
inactive.next === false && inactive.status === 403);
|
|
113
|
+
|
|
114
|
+
var none = await _run(strict, _post({
|
|
115
|
+
"sec-fetch-site": "same-origin", "sec-fetch-storage-access": "none",
|
|
116
|
+
}), _bodyRes());
|
|
117
|
+
check("storage-access none on same-origin passes through",
|
|
118
|
+
none.next === true);
|
|
119
|
+
|
|
120
|
+
// Default (opt unset) does not refuse the escalation.
|
|
121
|
+
var dflt = b.middleware.fetchMetadata({ allowCrossSite: true });
|
|
122
|
+
var permitted = await _run(dflt, _post({
|
|
123
|
+
"sec-fetch-site": "cross-site", "sec-fetch-storage-access": "active",
|
|
124
|
+
}), _bodyRes());
|
|
125
|
+
check("storage-access escalation permitted by default",
|
|
126
|
+
permitted.next === true);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// strictDest is a config-time tier opt — an unknown Sec-Fetch-Dest value
|
|
130
|
+
// throws at construction so an operator typo surfaces at boot.
|
|
131
|
+
function testStrictDestThrows() {
|
|
132
|
+
var threw = null;
|
|
133
|
+
try {
|
|
134
|
+
b.middleware.fetchMetadata({ strictDest: true, deniedDest: ["web-identity"] });
|
|
135
|
+
} catch (e) { threw = e; }
|
|
136
|
+
check("strictDest rejects an unknown Sec-Fetch-Dest value at config time",
|
|
137
|
+
threw && /not a known Sec-Fetch-Dest value/.test(threw.message || ""));
|
|
138
|
+
|
|
139
|
+
// A known value under strictDest constructs cleanly.
|
|
140
|
+
var ok = true;
|
|
141
|
+
try {
|
|
142
|
+
b.middleware.fetchMetadata({ strictDest: true, deniedDest: ["webidentity"], allowedDest: ["empty"] });
|
|
143
|
+
} catch (_e) { ok = false; }
|
|
144
|
+
check("strictDest accepts known destination values", ok === true);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Exact membership — a header value that is a substring of, or a
|
|
148
|
+
// prototype name relative to, a denied value must NOT match.
|
|
149
|
+
async function testExactMembershipNoBypass() {
|
|
150
|
+
var mw = b.middleware.fetchMetadata({ deniedDest: ["webidentity"] });
|
|
151
|
+
|
|
152
|
+
// Substring of the denied value — must not be treated as denied.
|
|
153
|
+
var substr = await _run(mw, _post({
|
|
154
|
+
"sec-fetch-site": "same-origin", "sec-fetch-dest": "web",
|
|
155
|
+
}), _bodyRes());
|
|
156
|
+
check("substring of a denied dest is not refused (exact match only)",
|
|
157
|
+
substr.next === true);
|
|
158
|
+
|
|
159
|
+
// Prototype-name header value must not satisfy the membership map.
|
|
160
|
+
var proto = await _run(mw, _post({
|
|
161
|
+
"sec-fetch-site": "same-origin", "sec-fetch-dest": "__proto__",
|
|
162
|
+
}), _bodyRes());
|
|
163
|
+
check("__proto__ as Sec-Fetch-Dest does not match the deny map",
|
|
164
|
+
proto.next === true);
|
|
165
|
+
|
|
166
|
+
// deniedDest rejects a prototype-name value at config time (non-empty
|
|
167
|
+
// string array validation passes it; it simply never matches a real
|
|
168
|
+
// header). Construction with such a value must not pollute.
|
|
169
|
+
var safe = b.middleware.fetchMetadata({ deniedDest: ["constructor"] });
|
|
170
|
+
var plain = await _run(safe, _post({
|
|
171
|
+
"sec-fetch-site": "same-origin", "sec-fetch-dest": "image",
|
|
172
|
+
}), _bodyRes());
|
|
173
|
+
check("deniedDest with a reserved-name entry does not pollute the map",
|
|
174
|
+
plain.next === true);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function run() {
|
|
178
|
+
await testDefaultUnchanged();
|
|
179
|
+
await testDeniedDestWebIdentity();
|
|
180
|
+
await testStorageAccessGate();
|
|
181
|
+
testStrictDestThrows();
|
|
182
|
+
await testExactMembershipNoBypass();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { run: run };
|
|
186
|
+
|
|
187
|
+
if (require.main === module) {
|
|
188
|
+
run().then(function () { console.log("OK"); })
|
|
189
|
+
.catch(function (e) { process.exitCode = 1; throw e; });
|
|
190
|
+
}
|
|
@@ -145,6 +145,29 @@ function run() {
|
|
|
145
145
|
var ctxAnon = b.flag.context.fromRequest({ headers: { "x-forwarded-for": "1.2.3.4", "user-agent": "ua" } });
|
|
146
146
|
check("fromRequest: anon targetingKey", ctxAnon.targetingKey.indexOf("anon:") === 0);
|
|
147
147
|
|
|
148
|
+
// explicit tenantKey supplies the tenant id (gateway-resolved tenancy)
|
|
149
|
+
var ctxTenant = b.flag.context.fromRequest(
|
|
150
|
+
{ user: { id: "u-1" }, headers: {} },
|
|
151
|
+
{ tenantKey: "tenant-explicit" });
|
|
152
|
+
check("fromRequest: tenantKey sets tenantId", ctxTenant.tenantId === "tenant-explicit");
|
|
153
|
+
|
|
154
|
+
// tenantKey overrides the tenant id derived from req.user.tenantId
|
|
155
|
+
var ctxTenantOverride = b.flag.context.fromRequest(
|
|
156
|
+
{ user: { id: "u-2", tenantId: "from-user" }, headers: {} },
|
|
157
|
+
{ tenantKey: "from-opts" });
|
|
158
|
+
check("fromRequest: tenantKey overrides req.user.tenantId", ctxTenantOverride.tenantId === "from-opts");
|
|
159
|
+
|
|
160
|
+
// default unchanged: no tenantKey → tenantId still derived from req.user
|
|
161
|
+
var ctxTenantDefault = b.flag.context.fromRequest(
|
|
162
|
+
{ user: { id: "u-3", tenantId: "user-tenant" }, headers: {} });
|
|
163
|
+
check("fromRequest: no tenantKey keeps req.user.tenantId", ctxTenantDefault.tenantId === "user-tenant");
|
|
164
|
+
|
|
165
|
+
// empty-string tenantKey is ignored (falls back to req.user.tenantId)
|
|
166
|
+
var ctxTenantEmpty = b.flag.context.fromRequest(
|
|
167
|
+
{ user: { id: "u-4", tenantId: "user-tenant" }, headers: {} },
|
|
168
|
+
{ tenantKey: "" });
|
|
169
|
+
check("fromRequest: empty tenantKey ignored", ctxTenantEmpty.tenantId === "user-tenant");
|
|
170
|
+
|
|
148
171
|
// ---- targeting evaluation ----
|
|
149
172
|
var t = b.flag.targeting;
|
|
150
173
|
check("targeting.VALID_OPS", Array.isArray(t.VALID_OPS) && t.VALID_OPS.length > 10);
|
|
@@ -331,6 +331,62 @@ function testFindingFields() {
|
|
|
331
331
|
imgFinding && imgFinding.level === "A");
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
// ---- de-advertised opts ----
|
|
335
|
+
|
|
336
|
+
function testAuditRejectsCheckAll() {
|
|
337
|
+
// checkAll was an accepted-but-never-read knob; it is no longer in the
|
|
338
|
+
// validateOpts allowlist, so passing it is now a config-time error.
|
|
339
|
+
var threw = false;
|
|
340
|
+
try { wcag.audit("<html lang=\"en\"><head><title>Real page title</title></head><body></body></html>",
|
|
341
|
+
{ checkAll: true }); }
|
|
342
|
+
catch (_e) { threw = true; }
|
|
343
|
+
check("audit: unknown checkAll opt throws", threw);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---- scopeUrl stamped onto sub-scanner findings ----
|
|
347
|
+
|
|
348
|
+
function testSubScannerScopeUrlStamped() {
|
|
349
|
+
var url = "https://example.com/page";
|
|
350
|
+
|
|
351
|
+
var ariaFindings = wcag.aria.audit('<div role="invalidrole"></div>', { scopeUrl: url });
|
|
352
|
+
check("aria sub-scanner: scopeUrl stamped on finding",
|
|
353
|
+
ariaFindings.length >= 1 &&
|
|
354
|
+
ariaFindings.every(function (f) { return f.scopeUrl === url; }));
|
|
355
|
+
|
|
356
|
+
var tableFindings = wcag.tables.audit('<table><tbody><tr><td>1</td></tr></tbody></table>', { scopeUrl: url });
|
|
357
|
+
check("tables sub-scanner: scopeUrl stamped on finding",
|
|
358
|
+
tableFindings.length >= 1 &&
|
|
359
|
+
tableFindings.every(function (f) { return f.scopeUrl === url; }));
|
|
360
|
+
|
|
361
|
+
var formFindings = wcag.forms.audit('<textarea></textarea>', { scopeUrl: url });
|
|
362
|
+
check("forms sub-scanner: scopeUrl stamped on finding",
|
|
363
|
+
formFindings.length >= 1 &&
|
|
364
|
+
formFindings.every(function (f) { return f.scopeUrl === url; }));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function testSubScannerScopeUrlDefaultUnchanged() {
|
|
368
|
+
// No scopeUrl → findings carry no scopeUrl field (default behavior).
|
|
369
|
+
var ariaFindings = wcag.aria.audit('<div role="invalidrole"></div>');
|
|
370
|
+
check("aria sub-scanner: no scopeUrl field by default",
|
|
371
|
+
ariaFindings.length >= 1 &&
|
|
372
|
+
ariaFindings.every(function (f) { return f.scopeUrl === undefined; }));
|
|
373
|
+
|
|
374
|
+
var tableFindings = wcag.tables.audit('<table><tbody><tr><td>1</td></tr></tbody></table>');
|
|
375
|
+
check("tables sub-scanner: no scopeUrl field by default",
|
|
376
|
+
tableFindings.length >= 1 &&
|
|
377
|
+
tableFindings.every(function (f) { return f.scopeUrl === undefined; }));
|
|
378
|
+
|
|
379
|
+
var formFindings = wcag.forms.audit('<textarea></textarea>');
|
|
380
|
+
check("forms sub-scanner: no scopeUrl field by default",
|
|
381
|
+
formFindings.length >= 1 &&
|
|
382
|
+
formFindings.every(function (f) { return f.scopeUrl === undefined; }));
|
|
383
|
+
|
|
384
|
+
// Empty-string scopeUrl is treated as absent (no stamp).
|
|
385
|
+
var emptyScope = wcag.aria.audit('<div role="invalidrole"></div>', { scopeUrl: "" });
|
|
386
|
+
check("aria sub-scanner: empty scopeUrl not stamped",
|
|
387
|
+
emptyScope.every(function (f) { return f.scopeUrl === undefined; }));
|
|
388
|
+
}
|
|
389
|
+
|
|
334
390
|
// ---- SC registry ----
|
|
335
391
|
|
|
336
392
|
function testRegistryShape() {
|
|
@@ -820,6 +876,9 @@ function testFormsStandalone() {
|
|
|
820
876
|
testScoreFullClean();
|
|
821
877
|
testScoreManyErrors();
|
|
822
878
|
testAuditValidation();
|
|
879
|
+
testAuditRejectsCheckAll();
|
|
880
|
+
testSubScannerScopeUrlStamped();
|
|
881
|
+
testSubScannerScopeUrlDefaultUnchanged();
|
|
823
882
|
testReportShape();
|
|
824
883
|
testFindingFields();
|
|
825
884
|
testRegistryShape();
|
|
@@ -38,6 +38,32 @@ async function run() {
|
|
|
38
38
|
|
|
39
39
|
check("honeytoken.HoneytokenError class registered",
|
|
40
40
|
typeof b.honeytoken.HoneytokenError === "function");
|
|
41
|
+
|
|
42
|
+
// ---- injected audit sink (opts.audit) ----
|
|
43
|
+
// The documented `audit: b.audit` sink must receive issued + tripped
|
|
44
|
+
// rows when supplied, instead of the module's default audit log.
|
|
45
|
+
var captured = [];
|
|
46
|
+
var sink = { safeEmit: function (rec) { captured.push(rec); } };
|
|
47
|
+
var honeyInj = b.honeytoken.create({ audit: sink });
|
|
48
|
+
var injIssued = honeyInj.issue({ kind: "url", metadata: { plantedAt: "admin-listing" } });
|
|
49
|
+
check("honeytoken.audit: injected sink received issued row",
|
|
50
|
+
captured.length === 1 && captured[0].action === "honeytoken.issued" &&
|
|
51
|
+
captured[0].metadata.id === injIssued.id && captured[0].metadata.kind === "url");
|
|
52
|
+
honeyInj.lookup(injIssued.value, { ip: "198.51.100.7" });
|
|
53
|
+
check("honeytoken.audit: injected sink received tripped row",
|
|
54
|
+
captured.length === 2 && captured[1].action === "honeytoken.tripped" &&
|
|
55
|
+
captured[1].outcome === "failure" && captured[1].metadata.id === injIssued.id &&
|
|
56
|
+
captured[1].metadata.observedActor && captured[1].metadata.observedActor.ip === "198.51.100.7");
|
|
57
|
+
check("honeytoken.audit: lookup miss emits nothing to the sink",
|
|
58
|
+
honeyInj.lookup("not-a-canary") === null && captured.length === 2);
|
|
59
|
+
|
|
60
|
+
// Default path (no sink) still emits to the module audit log without
|
|
61
|
+
// throwing — exercised by the surface tests above, asserted here via
|
|
62
|
+
// a fresh registry whose issue/lookup don't blow up.
|
|
63
|
+
var honeyDefault = b.honeytoken.create({});
|
|
64
|
+
var d = honeyDefault.issue({ kind: "rowId", metadata: null });
|
|
65
|
+
check("honeytoken.audit: default sink path issues without throwing",
|
|
66
|
+
typeof d.value === "string" && d.value.indexOf("ht_canary_") === 0);
|
|
41
67
|
}
|
|
42
68
|
|
|
43
69
|
module.exports = { run: run };
|
|
@@ -766,6 +766,178 @@ function testDmarcRuaBuildBadInput() {
|
|
|
766
766
|
}
|
|
767
767
|
}
|
|
768
768
|
|
|
769
|
+
// RFC 6591 §4.1 / RFC 7489 §7.3 — a DMARC forensic (RUF) failure
|
|
770
|
+
// report: multipart/report (report-type=feedback-report) whose
|
|
771
|
+
// message/feedback-report part carries Feedback-Type: auth-failure plus
|
|
772
|
+
// the forensic-specific fields (Auth-Failure, Delivery-Result,
|
|
773
|
+
// Identity-Alignment, DKIM-*/SPF-*), and a message/rfc822 part with the
|
|
774
|
+
// reported message.
|
|
775
|
+
var DMARC_RUF_SAMPLE =
|
|
776
|
+
"From: <postmaster@example.com>\r\n" +
|
|
777
|
+
"Date: Fri, 15 May 2026 12:00:00 -0400\r\n" +
|
|
778
|
+
"Subject: FW: DMARC failure report\r\n" +
|
|
779
|
+
"To: <ruf@sender.example>\r\n" +
|
|
780
|
+
"MIME-Version: 1.0\r\n" +
|
|
781
|
+
"Content-Type: multipart/report; report-type=feedback-report;\r\n" +
|
|
782
|
+
'\tboundary="ruf_boundary_abc"\r\n' +
|
|
783
|
+
"\r\n" +
|
|
784
|
+
"--ruf_boundary_abc\r\n" +
|
|
785
|
+
"Content-Type: text/plain; charset=\"US-ASCII\"\r\n" +
|
|
786
|
+
"\r\n" +
|
|
787
|
+
"This is a DMARC authentication-failure report.\r\n" +
|
|
788
|
+
"\r\n" +
|
|
789
|
+
"--ruf_boundary_abc\r\n" +
|
|
790
|
+
"Content-Type: message/feedback-report\r\n" +
|
|
791
|
+
"\r\n" +
|
|
792
|
+
"Feedback-Type: auth-failure\r\n" +
|
|
793
|
+
"User-Agent: ExampleReporter/1.0\r\n" +
|
|
794
|
+
"Version: 1\r\n" +
|
|
795
|
+
"Original-Mail-From: <bounce@sender.example>\r\n" +
|
|
796
|
+
"Original-Rcpt-To: <victim@example.com>\r\n" +
|
|
797
|
+
"Arrival-Date: Fri, 15 May 2026 11:59:00 -0400\r\n" +
|
|
798
|
+
"Source-IP: 203.0.113.7\r\n" +
|
|
799
|
+
"Reported-Domain: sender.example\r\n" +
|
|
800
|
+
"Authentication-Results: mx.example.com; dmarc=fail header.from=sender.example\r\n" +
|
|
801
|
+
"Auth-Failure: dmarc\r\n" +
|
|
802
|
+
"Delivery-Result: reject\r\n" +
|
|
803
|
+
"Identity-Alignment: none\r\n" +
|
|
804
|
+
"DKIM-Domain: sender.example\r\n" +
|
|
805
|
+
"DKIM-Identity: @sender.example\r\n" +
|
|
806
|
+
"DKIM-Selector: sel2026\r\n" +
|
|
807
|
+
"DKIM-Canonicalized-Header: from:Sender <noreply@sender.example>\r\n" +
|
|
808
|
+
"SPF-DNS: txt sender.example \"v=spf1 ip4:198.51.100.0/24 -all\"\r\n" +
|
|
809
|
+
"\r\n" +
|
|
810
|
+
"--ruf_boundary_abc\r\n" +
|
|
811
|
+
"Content-Type: message/rfc822\r\n" +
|
|
812
|
+
"\r\n" +
|
|
813
|
+
"From: Sender <noreply@sender.example>\r\n" +
|
|
814
|
+
"To: <victim@example.com>\r\n" +
|
|
815
|
+
"Subject: You have won\r\n" +
|
|
816
|
+
"Message-ID: <forged-123@sender.example>\r\n" +
|
|
817
|
+
"\r\n" +
|
|
818
|
+
"Body of the reported message.\r\n" +
|
|
819
|
+
"--ruf_boundary_abc--\r\n";
|
|
820
|
+
|
|
821
|
+
function testDmarcForensicSurface() {
|
|
822
|
+
check("dmarc.parseForensicReport is a function",
|
|
823
|
+
typeof b.mail.dmarc.parseForensicReport === "function");
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function testDmarcForensicParse() {
|
|
827
|
+
var rv = b.mail.dmarc.parseForensicReport(DMARC_RUF_SAMPLE);
|
|
828
|
+
check("dmarc.parseForensicReport: ok envelope on a valid report",
|
|
829
|
+
rv && rv.ok === true && rv.report && typeof rv.report === "object");
|
|
830
|
+
var rep = rv.report;
|
|
831
|
+
check("dmarc.parseForensicReport: feedbackType=auth-failure",
|
|
832
|
+
rep.feedbackType === "auth-failure");
|
|
833
|
+
check("dmarc.parseForensicReport: authFailure=dmarc (RFC 6591 §3.1)",
|
|
834
|
+
rep.authFailure === "dmarc");
|
|
835
|
+
check("dmarc.parseForensicReport: deliveryResult=reject",
|
|
836
|
+
rep.deliveryResult === "reject");
|
|
837
|
+
check("dmarc.parseForensicReport: identityAlignment=none (RFC 7489 §7.3)",
|
|
838
|
+
rep.identityAlignment === "none");
|
|
839
|
+
check("dmarc.parseForensicReport: DKIM-* fields shaped",
|
|
840
|
+
rep.dkim && rep.dkim.domain === "sender.example" &&
|
|
841
|
+
rep.dkim.selector === "sel2026" &&
|
|
842
|
+
rep.dkim.identity === "@sender.example" &&
|
|
843
|
+
/noreply@sender\.example/.test(rep.dkim.canonicalizedHeader || ""));
|
|
844
|
+
check("dmarc.parseForensicReport: spf.dns captured",
|
|
845
|
+
rep.spf && /v=spf1/.test(rep.spf.dns || ""));
|
|
846
|
+
check("dmarc.parseForensicReport: base ARF fields carried through",
|
|
847
|
+
rep.sourceIp === "203.0.113.7" &&
|
|
848
|
+
rep.reportedDomain === "sender.example" &&
|
|
849
|
+
/dmarc=fail/.test(rep.authenticationResults || ""));
|
|
850
|
+
check("dmarc.parseForensicReport: reported message headers parsed",
|
|
851
|
+
Array.isArray(rep.reportedHeaders) &&
|
|
852
|
+
rep.reportedHeaderMap["message-id"] === "<forged-123@sender.example>" &&
|
|
853
|
+
rep.reportedHeaderMap.subject === "You have won");
|
|
854
|
+
check("dmarc.parseForensicReport: reportedHeaderMap is null-prototype",
|
|
855
|
+
Object.getPrototypeOf(rep.reportedHeaderMap) === null);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function testDmarcForensicNotAuthFailure() {
|
|
859
|
+
// An RFC 5965 abuse report is a valid ARF report but NOT a DMARC
|
|
860
|
+
// forensic report — Feedback-Type must be auth-failure (RFC 7489 §7.3).
|
|
861
|
+
var abuseReport = DMARC_RUF_SAMPLE.replace(
|
|
862
|
+
"Feedback-Type: auth-failure", "Feedback-Type: abuse");
|
|
863
|
+
var rv = b.mail.dmarc.parseForensicReport(abuseReport);
|
|
864
|
+
check("dmarc.parseForensicReport: non-auth-failure Feedback-Type → typed error (not a throw)",
|
|
865
|
+
rv && rv.ok === false &&
|
|
866
|
+
/dmarc-ruf-not-auth-failure/.test(rv.error.code || ""));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function testDmarcForensicMissingAuthFailure() {
|
|
870
|
+
// RFC 6591 §3.1 — Auth-Failure is required in an auth-failure report.
|
|
871
|
+
var noAuthFailure = DMARC_RUF_SAMPLE.replace("Auth-Failure: dmarc\r\n", "");
|
|
872
|
+
var rv = b.mail.dmarc.parseForensicReport(noAuthFailure);
|
|
873
|
+
check("dmarc.parseForensicReport: missing Auth-Failure → typed error",
|
|
874
|
+
rv && rv.ok === false &&
|
|
875
|
+
/dmarc-ruf-missing-auth-failure/.test(rv.error.code || ""));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function testDmarcForensicHostileInputDoesNotThrow() {
|
|
879
|
+
// Defensive reader — hostile / malformed input MUST return a typed
|
|
880
|
+
// error, never throw in the hot path that ingested the report.
|
|
881
|
+
var cases = [null, 12345, "", "not-a-multipart-report",
|
|
882
|
+
Buffer.from("garbage"),
|
|
883
|
+
"Content-Type: text/plain\r\n\r\nnope"];
|
|
884
|
+
for (var i = 0; i < cases.length; i += 1) {
|
|
885
|
+
var threw = null;
|
|
886
|
+
var rv = null;
|
|
887
|
+
try { rv = b.mail.dmarc.parseForensicReport(cases[i]); }
|
|
888
|
+
catch (e) { threw = e; }
|
|
889
|
+
check("dmarc.parseForensicReport: hostile input #" + i + " returns typed error, no throw",
|
|
890
|
+
threw === null && rv && rv.ok === false &&
|
|
891
|
+
rv.error && typeof rv.error.code === "string");
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function testDmarcForensicReportedHeaderCap() {
|
|
896
|
+
// RFC 6591 §3.2 reported-header cap — a report whose reported message
|
|
897
|
+
// carries > the header cap clips the parsed list and flags it, while
|
|
898
|
+
// still surfacing the verbatim message. Build a reported message with
|
|
899
|
+
// many headers.
|
|
900
|
+
var lines = [];
|
|
901
|
+
for (var i = 0; i < 400; i += 1) lines.push("X-Pad-" + i + ": v" + i);
|
|
902
|
+
var bigReported = lines.join("\r\n") + "\r\n\r\nbody\r\n";
|
|
903
|
+
var sample = DMARC_RUF_SAMPLE.replace(
|
|
904
|
+
"From: Sender <noreply@sender.example>\r\n" +
|
|
905
|
+
"To: <victim@example.com>\r\n" +
|
|
906
|
+
"Subject: You have won\r\n" +
|
|
907
|
+
"Message-ID: <forged-123@sender.example>\r\n" +
|
|
908
|
+
"\r\n" +
|
|
909
|
+
"Body of the reported message.\r\n",
|
|
910
|
+
bigReported);
|
|
911
|
+
var rv = b.mail.dmarc.parseForensicReport(sample);
|
|
912
|
+
check("dmarc.parseForensicReport: over-cap reported headers are clipped + flagged",
|
|
913
|
+
rv && rv.ok === true &&
|
|
914
|
+
rv.report.reportedHeaders.length === 256 &&
|
|
915
|
+
rv.report.reportedHeadersTruncated === true);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function testDmarcForensicPrototypePollutionSafe() {
|
|
919
|
+
// A hostile report naming a feedback-report field or a reported-message
|
|
920
|
+
// header `__proto__` / `constructor` must not pollute Object.prototype.
|
|
921
|
+
var sample = DMARC_RUF_SAMPLE
|
|
922
|
+
// feedback-report field block — exercises the extraFields path.
|
|
923
|
+
.replace("Auth-Failure: dmarc\r\n",
|
|
924
|
+
"Auth-Failure: dmarc\r\n__proto__: fieldpoison\r\n")
|
|
925
|
+
// reported message header block — exercises the reportedHeaderMap path.
|
|
926
|
+
.replace("From: Sender <noreply@sender.example>\r\n",
|
|
927
|
+
"From: Sender <noreply@sender.example>\r\n__proto__: hdrpoison\r\n");
|
|
928
|
+
var rv = b.mail.dmarc.parseForensicReport(sample);
|
|
929
|
+
check("dmarc.parseForensicReport: __proto__ in feedback field → own data on null-prototype extraFields",
|
|
930
|
+
rv && rv.ok === true &&
|
|
931
|
+
Object.getPrototypeOf(rv.report.extraFields) === null &&
|
|
932
|
+
rv.report.extraFields["__proto__"] === "fieldpoison");
|
|
933
|
+
check("dmarc.parseForensicReport: __proto__ reported header → own data, no prototype pollution",
|
|
934
|
+
rv.report.reportedHeaderMap["__proto__"] === "hdrpoison" &&
|
|
935
|
+
({}).fieldpoison === undefined &&
|
|
936
|
+
({}).hdrpoison === undefined &&
|
|
937
|
+
Object.prototype.fieldpoison === undefined &&
|
|
938
|
+
Object.prototype.hdrpoison === undefined);
|
|
939
|
+
}
|
|
940
|
+
|
|
769
941
|
async function testIprevValidatesPtrShape() {
|
|
770
942
|
// MAIL-50 — PTR result MUST be a valid DNS-name shape (LDH labels,
|
|
771
943
|
// 1..63 octets, total 1..253 octets). Synthesize via a mocked
|
|
@@ -1072,6 +1244,13 @@ async function run() {
|
|
|
1072
1244
|
testDmarcRuaGunzipBombDistinguished();
|
|
1073
1245
|
testDmarcRuaBuildRoundTrip();
|
|
1074
1246
|
testDmarcRuaBuildBadInput();
|
|
1247
|
+
testDmarcForensicSurface();
|
|
1248
|
+
testDmarcForensicParse();
|
|
1249
|
+
testDmarcForensicNotAuthFailure();
|
|
1250
|
+
testDmarcForensicMissingAuthFailure();
|
|
1251
|
+
testDmarcForensicHostileInputDoesNotThrow();
|
|
1252
|
+
testDmarcForensicReportedHeaderCap();
|
|
1253
|
+
testDmarcForensicPrototypePollutionSafe();
|
|
1075
1254
|
await testIprevValidatesPtrShape();
|
|
1076
1255
|
await testArcHeaderSourceOrder();
|
|
1077
1256
|
}
|
|
@@ -331,6 +331,21 @@ function testHttpAuthenticateHookValidatesType() {
|
|
|
331
331
|
check("authenticate non-function refused at construct", threw);
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
function testHttpRejectsRemovedComplianceOpt() {
|
|
335
|
+
// `compliance` was an accepted-but-never-read allowlist key (it was not
|
|
336
|
+
// documented in @opts and nothing consumed it). It is removed from the
|
|
337
|
+
// validateOpts allowlist, so passing it is now a config-time error.
|
|
338
|
+
var threw = false;
|
|
339
|
+
try { b.mail.deploy.tlsRptIngestHttp({ compliance: "hipaa" }); }
|
|
340
|
+
catch (_e) { threw = true; }
|
|
341
|
+
check("tlsRptIngestHttp: removed `compliance` opt refused at construct", threw);
|
|
342
|
+
|
|
343
|
+
// Default construct still works (no opts).
|
|
344
|
+
var threwBare = false;
|
|
345
|
+
try { b.mail.deploy.tlsRptIngestHttp({}); } catch (_e) { threwBare = true; }
|
|
346
|
+
check("tlsRptIngestHttp: bare construct still works", !threwBare);
|
|
347
|
+
}
|
|
348
|
+
|
|
334
349
|
function testTlsRptParseErrorClassExported() {
|
|
335
350
|
check("b.mail.deploy.TlsRptParseError is a constructor",
|
|
336
351
|
typeof b.mail.deploy.TlsRptParseError === "function");
|
|
@@ -359,6 +374,7 @@ async function run() {
|
|
|
359
374
|
testRefusesNonFiniteSummary();
|
|
360
375
|
await testHttpAuthenticateHookSyncFalsy();
|
|
361
376
|
testHttpAuthenticateHookValidatesType();
|
|
377
|
+
testHttpRejectsRemovedComplianceOpt();
|
|
362
378
|
testTlsRptParseErrorClassExported();
|
|
363
379
|
}
|
|
364
380
|
|
|
@@ -72,6 +72,62 @@ function testFactoryRefusesBadOpts() {
|
|
|
72
72
|
});
|
|
73
73
|
check("port 0 → DeliverError (connect port must be >=1)",
|
|
74
74
|
e7 && e7.code === "deliver/bad-port");
|
|
75
|
+
|
|
76
|
+
// retry.maxAttempts / timeouts.mxLookupMs / timeouts.perHostMs are
|
|
77
|
+
// config-time entry-point opts: a typo must throw at create(), not be
|
|
78
|
+
// swallowed by a valid-or-default fallback. Absent keeps the default.
|
|
79
|
+
var e8 = threw(function () {
|
|
80
|
+
b.mail.send.deliver({ hostname: "m.example", retry: { maxAttempts: "5" } });
|
|
81
|
+
});
|
|
82
|
+
check("retry.maxAttempts as string → DeliverError",
|
|
83
|
+
e8 && e8.code === "deliver/bad-retry-maxAttempts");
|
|
84
|
+
|
|
85
|
+
var e9 = threw(function () {
|
|
86
|
+
b.mail.send.deliver({ hostname: "m.example", retry: { maxAttempts: -1 } });
|
|
87
|
+
});
|
|
88
|
+
check("retry.maxAttempts negative → DeliverError",
|
|
89
|
+
e9 && e9.code === "deliver/bad-retry-maxAttempts");
|
|
90
|
+
|
|
91
|
+
var e10 = threw(function () {
|
|
92
|
+
b.mail.send.deliver({ hostname: "m.example", retry: { maxAttempts: 0 } });
|
|
93
|
+
});
|
|
94
|
+
check("retry.maxAttempts 0 → DeliverError (must be >= 1)",
|
|
95
|
+
e10 && e10.code === "deliver/bad-retry-maxAttempts");
|
|
96
|
+
|
|
97
|
+
var e11 = threw(function () {
|
|
98
|
+
b.mail.send.deliver({ hostname: "m.example", timeouts: { mxLookupMs: -1 } });
|
|
99
|
+
});
|
|
100
|
+
check("timeouts.mxLookupMs negative → DeliverError",
|
|
101
|
+
e11 && e11.code === "deliver/bad-timeout-mxLookupMs");
|
|
102
|
+
|
|
103
|
+
var e12 = threw(function () {
|
|
104
|
+
b.mail.send.deliver({ hostname: "m.example", timeouts: { mxLookupMs: "10000" } });
|
|
105
|
+
});
|
|
106
|
+
check("timeouts.mxLookupMs as string → DeliverError",
|
|
107
|
+
e12 && e12.code === "deliver/bad-timeout-mxLookupMs");
|
|
108
|
+
|
|
109
|
+
var e13 = threw(function () {
|
|
110
|
+
b.mail.send.deliver({ hostname: "m.example", timeouts: { perHostMs: 0 } });
|
|
111
|
+
});
|
|
112
|
+
check("timeouts.perHostMs 0 → DeliverError (must be >= 1)",
|
|
113
|
+
e13 && e13.code === "deliver/bad-timeout-perHostMs");
|
|
114
|
+
|
|
115
|
+
// Absent retry / timeouts keys keep the defaults — create() succeeds.
|
|
116
|
+
var okDefault = threw(function () {
|
|
117
|
+
b.mail.send.deliver({ hostname: "m.example", retry: {}, timeouts: {}, audit: false });
|
|
118
|
+
});
|
|
119
|
+
check("absent retry/timeouts keys keep defaults (create succeeds)", okDefault === null);
|
|
120
|
+
|
|
121
|
+
// Valid integer values are accepted unchanged.
|
|
122
|
+
var okValid = threw(function () {
|
|
123
|
+
b.mail.send.deliver({
|
|
124
|
+
hostname: "m.example",
|
|
125
|
+
retry: { maxAttempts: 3 },
|
|
126
|
+
timeouts: { mxLookupMs: 2000, perHostMs: 30000 },
|
|
127
|
+
audit: false,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
check("valid retry/timeouts values accepted (create succeeds)", okValid === null);
|
|
75
131
|
}
|
|
76
132
|
|
|
77
133
|
// ---- Submission/smarthost port ----
|
|
@@ -288,6 +344,57 @@ async function testTransientDefersPermanentFails() {
|
|
|
288
344
|
dsnInvocations[0].dsnHasReport === true);
|
|
289
345
|
}
|
|
290
346
|
|
|
347
|
+
// ---- retry.maxAttempts value flows through to the retry budget ----
|
|
348
|
+
|
|
349
|
+
// A valid maxAttempts must reach the deferred-vs-failed routing, not just
|
|
350
|
+
// pass validation. With maxAttempts:1, the first transient failure exhausts
|
|
351
|
+
// the budget (attempts 1 >= 1) and converts transient → permanent → failed[]
|
|
352
|
+
// rather than landing in deferred[]. The default (5) keeps it deferred.
|
|
353
|
+
async function testMaxAttemptsFlowsThrough() {
|
|
354
|
+
var fakeResolver = {
|
|
355
|
+
queryMx: async function (domain) {
|
|
356
|
+
return [{ exchange: "mx1." + domain, priority: 10 }];
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
var transientTransport = function () {
|
|
360
|
+
return {
|
|
361
|
+
send: async function () {
|
|
362
|
+
var err = new Error("temporary failure");
|
|
363
|
+
err.smtpResponse = { code: 451 };
|
|
364
|
+
throw err;
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
var envelope = {
|
|
369
|
+
from: "ops@example.com",
|
|
370
|
+
to: ["transient@example.com"],
|
|
371
|
+
rfc822: Buffer.from("hi"),
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
var deliverBudget1 = b.mail.send.deliver({
|
|
375
|
+
hostname: "mta1.example.com",
|
|
376
|
+
resolver: fakeResolver,
|
|
377
|
+
policy: { mtaSts: "off", dane: "off" },
|
|
378
|
+
transportFactory: transientTransport,
|
|
379
|
+
retry: { maxAttempts: 1 },
|
|
380
|
+
audit: false,
|
|
381
|
+
});
|
|
382
|
+
var r1 = await deliverBudget1(envelope);
|
|
383
|
+
check("maxAttempts:1 exhausts budget on first transient → failed",
|
|
384
|
+
r1.failed.length === 1 && r1.deferred.length === 0);
|
|
385
|
+
|
|
386
|
+
var deliverDefault = b.mail.send.deliver({
|
|
387
|
+
hostname: "mta1.example.com",
|
|
388
|
+
resolver: fakeResolver,
|
|
389
|
+
policy: { mtaSts: "off", dane: "off" },
|
|
390
|
+
transportFactory: transientTransport,
|
|
391
|
+
audit: false,
|
|
392
|
+
});
|
|
393
|
+
var r2 = await deliverDefault(envelope);
|
|
394
|
+
check("default maxAttempts keeps a single transient deferred",
|
|
395
|
+
r2.deferred.length === 1 && r2.failed.length === 0);
|
|
396
|
+
}
|
|
397
|
+
|
|
291
398
|
// ---- No-MX (RFC 7505 null MX) ----
|
|
292
399
|
|
|
293
400
|
async function testNullMx() {
|
|
@@ -369,6 +476,7 @@ async function run() {
|
|
|
369
476
|
await testNullMx();
|
|
370
477
|
await testMxFailover();
|
|
371
478
|
await testPortReachesTransport();
|
|
479
|
+
await testMaxAttemptsFlowsThrough();
|
|
372
480
|
}
|
|
373
481
|
|
|
374
482
|
module.exports = { run: run };
|