@blamejs/blamejs-shop 0.5.12 → 0.5.14
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/lib/asset-manifest.json +1 -1
- package/lib/sales-tax-filings.js +143 -0
- package/lib/vendor/MANIFEST.json +632 -416
- package/lib/vendor/blamejs/.clusterfuzzlite/Dockerfile +21 -10
- package/lib/vendor/blamejs/.clusterfuzzlite/build.sh +30 -9
- package/lib/vendor/blamejs/.github/dependabot.yml +8 -0
- package/lib/vendor/blamejs/.github/workflows/actions-lint.yml +3 -3
- package/lib/vendor/blamejs/.github/workflows/cflite_batch.yml +7 -2
- package/lib/vendor/blamejs/.github/workflows/cflite_pr.yml +7 -2
- package/lib/vendor/blamejs/.github/workflows/ci.yml +21 -21
- package/lib/vendor/blamejs/.github/workflows/codeql.yml +3 -3
- package/lib/vendor/blamejs/.github/workflows/npm-publish.yml +4 -4
- package/lib/vendor/blamejs/.github/workflows/release-container.yml +7 -7
- package/lib/vendor/blamejs/.github/workflows/scorecard.yml +3 -3
- package/lib/vendor/blamejs/.github/workflows/sha-to-tag-verify.yml +1 -1
- package/lib/vendor/blamejs/.gitignore +2 -0
- package/lib/vendor/blamejs/CHANGELOG.md +122 -0
- package/lib/vendor/blamejs/NOTICE +26 -1
- package/lib/vendor/blamejs/SECURITY.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +207 -5
- package/lib/vendor/blamejs/docker/keycloak/realm-blamejs-test.json +29 -0
- package/lib/vendor/blamejs/examples/wiki/DEPLOY.md +1 -0
- package/lib/vendor/blamejs/examples/wiki/lib/build-app.js +31 -0
- package/lib/vendor/blamejs/examples/wiki/lib/source-comment-block-validator.js +109 -0
- package/lib/vendor/blamejs/examples/wiki/nginx.conf +106 -0
- package/lib/vendor/blamejs/examples/wiki/package-lock.json +1 -1
- package/lib/vendor/blamejs/examples/wiki/test/e2e.js +92 -0
- package/lib/vendor/blamejs/fuzz/asn1-der.fuzz.js +28 -0
- package/lib/vendor/blamejs/fuzz/cms-codec.fuzz.js +17 -0
- package/lib/vendor/blamejs/fuzz/link-header.fuzz.js +18 -0
- package/lib/vendor/blamejs/index.js +6 -0
- package/lib/vendor/blamejs/lib/a2a.js +4 -4
- package/lib/vendor/blamejs/lib/agent-idempotency.js +18 -2
- package/lib/vendor/blamejs/lib/agent-saga.js +36 -8
- package/lib/vendor/blamejs/lib/agent-snapshot.js +63 -6
- package/lib/vendor/blamejs/lib/app-shutdown.js +23 -20
- package/lib/vendor/blamejs/lib/archive-read.js +8 -0
- package/lib/vendor/blamejs/lib/archive-tar.js +12 -2
- package/lib/vendor/blamejs/lib/arg-parser.js +49 -5
- package/lib/vendor/blamejs/lib/asn1-der.js +12 -0
- package/lib/vendor/blamejs/lib/audit-sign.js +47 -1
- package/lib/vendor/blamejs/lib/audit.js +71 -8
- package/lib/vendor/blamejs/lib/auth/ciba.js +14 -2
- package/lib/vendor/blamejs/lib/auth/fido-mds3.js +58 -47
- package/lib/vendor/blamejs/lib/auth/jwt.js +34 -0
- package/lib/vendor/blamejs/lib/auth/oauth.js +90 -13
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +20 -17
- package/lib/vendor/blamejs/lib/auth/openid-federation.js +214 -23
- package/lib/vendor/blamejs/lib/auth/saml.js +38 -20
- package/lib/vendor/blamejs/lib/auth/sd-jwt-vc.js +10 -4
- package/lib/vendor/blamejs/lib/backup/bundle.js +13 -3
- package/lib/vendor/blamejs/lib/backup/index.js +16 -5
- package/lib/vendor/blamejs/lib/backup/manifest.js +18 -5
- package/lib/vendor/blamejs/lib/base32.js +21 -0
- package/lib/vendor/blamejs/lib/break-glass.js +19 -10
- package/lib/vendor/blamejs/lib/bundler.js +5 -1
- package/lib/vendor/blamejs/lib/cache-status.js +3 -7
- package/lib/vendor/blamejs/lib/calendar.js +21 -1
- package/lib/vendor/blamejs/lib/cbor.js +12 -1
- package/lib/vendor/blamejs/lib/cert.js +29 -11
- package/lib/vendor/blamejs/lib/cli.js +94 -24
- package/lib/vendor/blamejs/lib/cloud-events.js +2 -2
- package/lib/vendor/blamejs/lib/cluster-provider-db.js +13 -3
- package/lib/vendor/blamejs/lib/codepoint-class.js +85 -0
- package/lib/vendor/blamejs/lib/compliance-ai-act-prohibited.js +8 -4
- package/lib/vendor/blamejs/lib/compliance-ai-act-transparency.js +19 -4
- package/lib/vendor/blamejs/lib/compliance-ai-act.js +6 -8
- package/lib/vendor/blamejs/lib/content-credentials.js +1 -1
- package/lib/vendor/blamejs/lib/content-digest.js +2 -1
- package/lib/vendor/blamejs/lib/cookies.js +13 -3
- package/lib/vendor/blamejs/lib/cose.js +31 -16
- package/lib/vendor/blamejs/lib/crypto-field.js +32 -0
- package/lib/vendor/blamejs/lib/crypto-oprf.js +11 -2
- package/lib/vendor/blamejs/lib/crypto-xwing.js +22 -4
- package/lib/vendor/blamejs/lib/crypto.js +314 -19
- package/lib/vendor/blamejs/lib/csp.js +11 -3
- package/lib/vendor/blamejs/lib/daemon.js +270 -24
- package/lib/vendor/blamejs/lib/dark-patterns.js +5 -2
- package/lib/vendor/blamejs/lib/db-declare-view.js +2 -2
- package/lib/vendor/blamejs/lib/db-query.js +34 -7
- package/lib/vendor/blamejs/lib/db.js +18 -6
- package/lib/vendor/blamejs/lib/dbsc.js +17 -0
- package/lib/vendor/blamejs/lib/dev.js +21 -2
- package/lib/vendor/blamejs/lib/dsr.js +157 -102
- package/lib/vendor/blamejs/lib/external-db.js +41 -2
- package/lib/vendor/blamejs/lib/file-upload.js +25 -7
- package/lib/vendor/blamejs/lib/flag-providers.js +27 -15
- package/lib/vendor/blamejs/lib/forms.js +113 -27
- package/lib/vendor/blamejs/lib/gate-contract.js +23 -10
- package/lib/vendor/blamejs/lib/gdpr-ropa.js +8 -1
- package/lib/vendor/blamejs/lib/guard-agent-registry.js +1 -1
- package/lib/vendor/blamejs/lib/guard-archive.js +2 -2
- package/lib/vendor/blamejs/lib/guard-cidr.js +27 -1
- package/lib/vendor/blamejs/lib/guard-graphql.js +30 -11
- package/lib/vendor/blamejs/lib/guard-html.js +26 -53
- package/lib/vendor/blamejs/lib/guard-image.js +24 -5
- package/lib/vendor/blamejs/lib/guard-imap-command.js +10 -2
- package/lib/vendor/blamejs/lib/guard-jmap.js +19 -3
- package/lib/vendor/blamejs/lib/guard-jwt.js +18 -2
- package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +2 -2
- package/lib/vendor/blamejs/lib/guard-managesieve-command.js +10 -2
- package/lib/vendor/blamejs/lib/guard-markdown.js +11 -24
- package/lib/vendor/blamejs/lib/guard-pdf.js +18 -2
- package/lib/vendor/blamejs/lib/guard-pop3-command.js +10 -2
- package/lib/vendor/blamejs/lib/guard-regex.js +8 -5
- package/lib/vendor/blamejs/lib/guard-svg.js +31 -32
- package/lib/vendor/blamejs/lib/guard-time.js +1 -1
- package/lib/vendor/blamejs/lib/guard-uuid.js +2 -2
- package/lib/vendor/blamejs/lib/html-balance.js +3 -3
- package/lib/vendor/blamejs/lib/http-client-cache.js +82 -4
- package/lib/vendor/blamejs/lib/http-client-cookie-jar.js +16 -1
- package/lib/vendor/blamejs/lib/http-client.js +39 -7
- package/lib/vendor/blamejs/lib/http-message-signature.js +35 -1
- package/lib/vendor/blamejs/lib/i18n-messageformat.js +31 -6
- package/lib/vendor/blamejs/lib/i18n.js +35 -21
- package/lib/vendor/blamejs/lib/incident-report.js +8 -1
- package/lib/vendor/blamejs/lib/ip-utils.js +11 -1
- package/lib/vendor/blamejs/lib/json-path.js +23 -1
- package/lib/vendor/blamejs/lib/jwk.js +20 -3
- package/lib/vendor/blamejs/lib/link-header.js +2 -1
- package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +5 -1
- package/lib/vendor/blamejs/lib/log-stream-otlp.js +5 -1
- package/lib/vendor/blamejs/lib/log-stream.js +8 -3
- package/lib/vendor/blamejs/lib/mail-auth.js +123 -22
- package/lib/vendor/blamejs/lib/mail-bimi.js +1 -1
- package/lib/vendor/blamejs/lib/mail-bounce.js +8 -0
- package/lib/vendor/blamejs/lib/mail-crypto-pgp.js +46 -10
- package/lib/vendor/blamejs/lib/mail-dav.js +22 -3
- package/lib/vendor/blamejs/lib/mail-dkim.js +96 -30
- package/lib/vendor/blamejs/lib/mail-helo.js +6 -1
- package/lib/vendor/blamejs/lib/mail-scan.js +23 -16
- package/lib/vendor/blamejs/lib/mail-send-deliver.js +34 -8
- package/lib/vendor/blamejs/lib/mail-server-imap.js +3 -4
- package/lib/vendor/blamejs/lib/mail-server-managesieve.js +7 -11
- package/lib/vendor/blamejs/lib/mail-server-mx.js +93 -7
- package/lib/vendor/blamejs/lib/mail-server-registry.js +8 -2
- package/lib/vendor/blamejs/lib/mail.js +15 -5
- package/lib/vendor/blamejs/lib/metrics.js +268 -63
- package/lib/vendor/blamejs/lib/middleware/age-gate.js +7 -1
- package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/body-parser.js +19 -4
- package/lib/vendor/blamejs/lib/middleware/bot-guard.js +5 -9
- package/lib/vendor/blamejs/lib/middleware/csp-report.js +40 -8
- package/lib/vendor/blamejs/lib/middleware/csrf-protect.js +24 -10
- package/lib/vendor/blamejs/lib/middleware/dpop.js +10 -5
- package/lib/vendor/blamejs/lib/middleware/index.js +6 -1
- package/lib/vendor/blamejs/lib/middleware/nel.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/rate-limit.js +12 -1
- package/lib/vendor/blamejs/lib/middleware/require-bound-key.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/scim-server.js +1 -1
- package/lib/vendor/blamejs/lib/middleware/tus-upload.js +6 -2
- package/lib/vendor/blamejs/lib/mtls-ca.js +18 -2
- package/lib/vendor/blamejs/lib/network-dns-resolver.js +11 -1
- package/lib/vendor/blamejs/lib/network-dns.js +21 -2
- package/lib/vendor/blamejs/lib/network-tls.js +69 -7
- package/lib/vendor/blamejs/lib/nonce-store.js +9 -1
- package/lib/vendor/blamejs/lib/object-store/gcs.js +7 -0
- package/lib/vendor/blamejs/lib/object-store/sigv4.js +31 -0
- package/lib/vendor/blamejs/lib/observability-otlp-exporter.js +1 -1
- package/lib/vendor/blamejs/lib/outbox.js +1 -1
- package/lib/vendor/blamejs/lib/parsers/safe-ini.js +9 -1
- package/lib/vendor/blamejs/lib/parsers/safe-toml.js +10 -1
- package/lib/vendor/blamejs/lib/permissions.js +6 -3
- package/lib/vendor/blamejs/lib/pid-probe.js +55 -0
- package/lib/vendor/blamejs/lib/pqc-agent.js +8 -1
- package/lib/vendor/blamejs/lib/pqc-software.js +3 -3
- package/lib/vendor/blamejs/lib/pubsub.js +20 -9
- package/lib/vendor/blamejs/lib/queue-local.js +8 -1
- package/lib/vendor/blamejs/lib/queue-redis.js +5 -0
- package/lib/vendor/blamejs/lib/redact.js +54 -0
- package/lib/vendor/blamejs/lib/restore-bundle.js +10 -2
- package/lib/vendor/blamejs/lib/restore.js +19 -0
- package/lib/vendor/blamejs/lib/retention.js +12 -4
- package/lib/vendor/blamejs/lib/router.js +60 -6
- package/lib/vendor/blamejs/lib/safe-buffer.js +69 -0
- package/lib/vendor/blamejs/lib/safe-decompress.js +8 -2
- package/lib/vendor/blamejs/lib/safe-dns.js +25 -20
- package/lib/vendor/blamejs/lib/safe-ical.js +7 -3
- package/lib/vendor/blamejs/lib/safe-mime.js +12 -1
- package/lib/vendor/blamejs/lib/safe-object.js +80 -0
- package/lib/vendor/blamejs/lib/safe-path.js +52 -4
- package/lib/vendor/blamejs/lib/safe-redirect.js +10 -2
- package/lib/vendor/blamejs/lib/safe-schema.js +4 -1
- package/lib/vendor/blamejs/lib/safe-sieve.js +33 -1
- package/lib/vendor/blamejs/lib/safe-sql.js +88 -0
- package/lib/vendor/blamejs/lib/safe-vcard.js +7 -3
- package/lib/vendor/blamejs/lib/scheduler.js +8 -1
- package/lib/vendor/blamejs/lib/security-assert.js +6 -6
- package/lib/vendor/blamejs/lib/self-update-standalone-verifier.js +74 -27
- package/lib/vendor/blamejs/lib/self-update.js +517 -87
- package/lib/vendor/blamejs/lib/server-timing.js +2 -4
- package/lib/vendor/blamejs/lib/session.js +34 -12
- package/lib/vendor/blamejs/lib/sql.js +15 -1
- package/lib/vendor/blamejs/lib/ssrf-guard.js +52 -0
- package/lib/vendor/blamejs/lib/static.js +55 -13
- package/lib/vendor/blamejs/lib/storage.js +8 -2
- package/lib/vendor/blamejs/lib/subject.js +6 -1
- package/lib/vendor/blamejs/lib/tenant-quota.js +52 -13
- package/lib/vendor/blamejs/lib/tls-exporter.js +3 -3
- package/lib/vendor/blamejs/lib/tsa.js +34 -13
- package/lib/vendor/blamejs/lib/uri-template.js +8 -1
- package/lib/vendor/blamejs/lib/vault/index.js +13 -0
- package/lib/vendor/blamejs/lib/vc.js +44 -5
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +26 -26
- package/lib/vendor/blamejs/lib/vendor/noble-ciphers.cjs +792 -1
- package/lib/vendor/blamejs/lib/vendor/noble-curves.cjs +3443 -1
- package/lib/vendor/blamejs/lib/vendor/noble-post-quantum.cjs +2737 -1
- package/lib/vendor/blamejs/lib/vendor/pki.cjs +39550 -13
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.dat +55 -53
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.data.js +5551 -5554
- package/lib/vendor/blamejs/lib/vendor/simplewebauthn-server.cjs +22459 -32
- package/lib/vendor/blamejs/lib/watcher.js +89 -17
- package/lib/vendor/blamejs/lib/webhook-dispatcher.js +25 -3
- package/lib/vendor/blamejs/lib/webhook.js +12 -2
- package/lib/vendor/blamejs/lib/ws-client.js +32 -1
- package/lib/vendor/blamejs/lib/xml-c14n.js +29 -1
- package/lib/vendor/blamejs/oss-fuzz/projects/blamejs/Dockerfile +1 -1
- package/lib/vendor/blamejs/package-lock.json +2 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.16.x.json +1462 -0
- package/lib/vendor/blamejs/release-notes/v0.17.0.json +31 -0
- package/lib/vendor/blamejs/release-notes/v0.17.1.json +31 -0
- package/lib/vendor/blamejs/release-notes/v0.17.10.json +30 -0
- package/lib/vendor/blamejs/release-notes/v0.17.11.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.17.12.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.17.13.json +77 -0
- package/lib/vendor/blamejs/release-notes/v0.17.14.json +40 -0
- package/lib/vendor/blamejs/release-notes/v0.17.15.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.17.16.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.17.17.json +31 -0
- package/lib/vendor/blamejs/release-notes/v0.17.18.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.17.19.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.17.2.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.17.20.json +43 -0
- package/lib/vendor/blamejs/release-notes/v0.17.21.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.17.22.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.17.3.json +43 -0
- package/lib/vendor/blamejs/release-notes/v0.17.4.json +35 -0
- package/lib/vendor/blamejs/release-notes/v0.17.5.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.17.6.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.17.7.json +35 -0
- package/lib/vendor/blamejs/release-notes/v0.17.8.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.17.9.json +26 -0
- package/lib/vendor/blamejs/scripts/validate-source-comment-blocks.js +4 -0
- package/lib/vendor/blamejs/scripts/vendor-update.sh +293 -9
- package/lib/vendor/blamejs/socket.yml +94 -0
- package/lib/vendor/blamejs/test/00-primitives.js +605 -0
- package/lib/vendor/blamejs/test/helpers/drivers.js +12 -9
- package/lib/vendor/blamejs/test/integration/audit-stack-mysql.test.js +90 -1
- package/lib/vendor/blamejs/test/integration/audit-stack-postgres.test.js +122 -1
- package/lib/vendor/blamejs/test/integration/data-layer-mysql.test.js +30 -0
- package/lib/vendor/blamejs/test/integration/data-layer-pg.test.js +29 -0
- package/lib/vendor/blamejs/test/integration/dsr-cluster-mysql.test.js +479 -0
- package/lib/vendor/blamejs/test/integration/dsr-cluster-pg.test.js +487 -0
- package/lib/vendor/blamejs/test/integration/federation-auth.test.js +313 -5
- package/lib/vendor/blamejs/test/integration/mail-dkim.test.js +80 -0
- package/lib/vendor/blamejs/test/integration/openid-federation-chain.test.js +275 -0
- package/lib/vendor/blamejs/test/integration/webhook-dispatcher-pg.test.js +96 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/a2a-tasks.test.js +401 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/acme.test.js +1649 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/age-gate.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +0 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-saga.test.js +60 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-snapshot.test.js +524 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +891 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +128 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-adapters.test.js +271 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +628 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-tar-read.test.js +0 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +227 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/arg-parser.test.js +15 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/asn1-der.test.js +73 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file.test.js +114 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/audit-sign-anchor.test.js +141 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/audit-tools.test.js +624 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/audit.test.js +769 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-jwt-defenses.test.js +436 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-oauth.test.js +1697 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-oid4vp.test.js +495 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-password.test.js +500 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-saml.test.js +1809 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-status-list.test.js +243 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-index.test.js +2226 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-manifest-signature.test.js +183 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/base32.test.js +105 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/break-glass.test.js +693 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +603 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/calendar.test.js +557 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/case-insensitive-security-token-guard.test.js +214 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cbor.test.js +25 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +843 -6
- package/lib/vendor/blamejs/test/layer-0-primitives/ciba-authreqid-binding.test.js +77 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/ciba.test.js +866 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/clear-site-data.test.js +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cli-erase.test.js +19 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cli.test.js +2790 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-lease-renewal-ttl.test.js +114 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster.test.js +1032 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cms-codec.test.js +575 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +739 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/codepoint-class.test.js +8 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +194 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-lifecycle-proto-key.test.js +104 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-sanctions.test.js +254 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials.test.js +613 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/cookies.test.js +140 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cose.test.js +407 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-adversarial.test.js +352 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field.test.js +575 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-hash-files-parallel.test.js +10 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-hash-stream.test.js +24 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-oprf.test.js +17 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-spki-pin.test.js +183 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-xwing.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto.test.js +345 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +58 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csv.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/daemon.test.js +459 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/dark-patterns.test.js +19 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db-query.test.js +1019 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db-raw-residency-gate.test.js +77 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/db.test.js +1621 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ddl-change-control.test.js +462 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/dnssec.test.js +323 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dpop-middleware-verify.test.js +562 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dsr.test.js +901 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +249 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db-migrate.test.js +561 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db.test.js +1214 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/fal.test.js +21 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/fedcm-dbsc.test.js +46 -20
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +147 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3.test.js +907 -38
- package/lib/vendor/blamejs/test/layer-0-primitives/file-upload-content-safety-skip-audit.test.js +29 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/file-upload.test.js +1135 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/flag.test.js +30 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/forms.test.js +747 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/gate-contract.test.js +1855 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/gdpr-ropa.test.js +30 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-archive.test.js +86 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-auth.test.js +117 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-cidr.test.js +151 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-domain.test.js +422 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-filename.test.js +424 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/{guard-gate-disposition-coverage.test.js → guard-gate-disposition.test.js} +1 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-graphql.test.js +340 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-html.test.js +29 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-idempotency-key.test.js +28 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-image.test.js +519 -45
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-imap-command.test.js +0 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-jmap.test.js +51 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-jsonpath.test.js +66 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-jwt.test.js +436 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-managesieve-command.test.js +25 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-markdown.test.js +16 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-mime.test.js +86 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-oauth.test.js +140 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-pdf.test.js +80 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-pop3-command.test.js +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-regex.test.js +99 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-shell.test.js +76 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/{guard-sql-coverage.test.js → guard-sql.test.js} +3 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-svg.test.js +524 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-template.test.js +71 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-time.test.js +92 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-uuid.test.js +86 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/html-balance.test.js +38 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/http-client-cache-authorization.test.js +347 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/http-client-cache.test.js +908 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/http-client.test.js +2874 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/http-message-signature.test.js +643 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/i18n-messageformat.test.js +38 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/i18n.test.js +1153 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/idempotency-key.test.js +494 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ip-utils.test.js +30 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/jsdoc-example-execution.test.js +177 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-path.test.js +103 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-schema.test.js +214 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/jwk.test.js +41 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/jwt-external.test.js +276 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/keychain.test.js +0 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/log-stream.test.js +241 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/log.test.js +106 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-agent.test.js +441 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +2136 -4
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-bimi.test.js +759 -12
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-bounce.test.js +811 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-pgp-experimental.test.js +401 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-pgp.test.js +751 -8
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +864 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto.test.js +110 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-dav.test.js +830 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-deploy.test.js +635 -86
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim.test.js +818 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-helo.test.js +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-scan.test.js +199 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +686 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-imap.test.js +778 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-jmap.test.js +1438 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-managesieve.test.js +449 -6
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-mx.test.js +773 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-pop3.test.js +623 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-rate-limit.test.js +55 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-registry.test.js +13 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-server-submission.test.js +877 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-sieve.test.js +326 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-store-fts.test.js +55 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-store.test.js +242 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail.test.js +1596 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/mcp.test.js +440 -4
- package/lib/vendor/blamejs/test/layer-0-primitives/metrics-snapshot.test.js +89 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/metrics.test.js +1230 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-assetlinks.test.js +113 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-body-parser.test.js +1169 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-bot-disclose.test.js +123 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-cookies.test.js +108 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-gpc.test.js +121 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-headers.test.js +163 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-host-allowlist.test.js +154 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-require-content-type.test.js +127 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/{middleware-tus-upload-coverage.test.js → middleware-tus-upload.test.js} +45 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/middleware-web-app-manifest.test.js +132 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mtls-ca-crl-fingerprint-only.test.js +92 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-resolver.test.js +65 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-dns.test.js +1727 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/network-nts.test.js +699 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-smtp-policy.test.js +1181 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-tls.test.js +1876 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/network.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/nonce-store-enforce-replay-nonboolean.test.js +81 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ntp-check.test.js +122 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/observability-tracing.test.js +378 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/observability.test.js +77 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/oid4vci.test.js +1016 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/openid-federation.test.js +946 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/outbox.test.js +818 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/own-property-membership-guards.test.js +109 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/parsers-safe-toml.test.js +543 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/{parsers-safe-yaml-coverage.test.js → parsers-safe-yaml.test.js} +2 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/parsers-standalone.test.js +73 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/passkey.test.js +649 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/permissions.test.js +524 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/pqc-agent-curve.test.js +22 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/pubsub.test.js +295 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/queue-flow-repeat.test.js +36 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/queue.test.js +83 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/rate-limit-xff-spoofing.test.js +34 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redact.test.js +1031 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +94 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/require-bound-key.test.js +401 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/restore-empty-manifest-wipe.test.js +154 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/retention.test.js +600 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/router-host-path-injection.test.js +231 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/router-tls0rtt.test.js +21 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/router.test.js +1277 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-async.test.js +133 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-buffer-linear-scans.test.js +14 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-buffer.test.js +80 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-decompress.test.js +28 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-dns.test.js +245 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-ical.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-ini.test.js +376 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-json.test.js +175 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-mime.test.js +518 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-object.test.js +62 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-path.test.js +30 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-schema.test.js +104 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-sql.test.js +142 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-url.test.js +112 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/safe-vcard.test.js +26 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/scheduler.test.js +157 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +396 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sd-jwt-vc.test.js +930 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/security-assert.test.js +755 -82
- package/lib/vendor/blamejs/test/layer-0-primitives/self-update.test.js +1053 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding.test.js +200 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/session-strict-binding-missing-failclosed.test.js +110 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sigv4-bucket-ops.test.js +636 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sql-offset-without-limit.test.js +94 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +1190 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ssrf-guard.test.js +21 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +850 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/storage-presigned-url.test.js +146 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/storage.test.js +621 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/subject.test.js +47 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/template.test.js +582 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/tenant-quota.test.js +587 -25
- package/lib/vendor/blamejs/test/layer-0-primitives/time.test.js +11 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/tsa.test.js +524 -63
- package/lib/vendor/blamejs/test/layer-0-primitives/uri-template.test.js +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vault-rotate.test.js +866 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vault.test.js +74 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vc.test.js +215 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/watcher.test.js +550 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/webhook-dispatcher.test.js +388 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/webhook.test.js +262 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/websocket.test.js +1294 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +629 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/xml-c14n-attr-normalization.test.js +164 -0
- package/lib/vendor/blamejs/test/smoke.js +2 -0
- package/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.16.0.json +0 -44
- package/lib/vendor/blamejs/release-notes/v0.16.1.json +0 -36
- package/lib/vendor/blamejs/release-notes/v0.16.2.json +0 -71
- package/lib/vendor/blamejs/release-notes/v0.16.3.json +0 -71
- package/lib/vendor/blamejs/test/layer-0-primitives/acme-coverage.test.js +0 -441
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-oauth-coverage.test.js +0 -482
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-saml-coverage.test.js +0 -671
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-index-coverage.test.js +0 -690
- package/lib/vendor/blamejs/test/layer-0-primitives/cli-coverage.test.js +0 -238
- package/lib/vendor/blamejs/test/layer-0-primitives/content-credentials-coverage.test.js +0 -319
- package/lib/vendor/blamejs/test/layer-0-primitives/keychain-coverage.test.js +0 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth-coverage.test.js +0 -437
- package/lib/vendor/blamejs/test/layer-0-primitives/mcp-coverage.test.js +0 -443
- package/lib/vendor/blamejs/test/layer-0-primitives/metrics-coverage.test.js +0 -395
- package/lib/vendor/blamejs/test/layer-0-primitives/network-smtp-policy-coverage.test.js +0 -565
- package/lib/vendor/blamejs/test/layer-0-primitives/router-coverage.test.js +0 -592
- package/lib/vendor/blamejs/test/layer-0-primitives/sql-coverage.test.js +0 -422
- package/lib/vendor/blamejs/test/layer-0-primitives/test-coverage.test.js +0 -573
|
@@ -0,0 +1,2874 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* b.httpClient — error / defensive / adversarial branch coverage.
|
|
6
|
+
*
|
|
7
|
+
* Targets the gap left by http-client-stream / -cache / -throttle-transform:
|
|
8
|
+
* the validation rejections, the SSRF/allowedHosts egress gate, the cross-
|
|
9
|
+
* origin redirect machinery, the h1 + h2 error paths (idle timeout, abort,
|
|
10
|
+
* body-stream error, oversized response, non-2xx), configurePool validation,
|
|
11
|
+
* and the pinned-DNS lookup contract.
|
|
12
|
+
*
|
|
13
|
+
* No live network:
|
|
14
|
+
* - loopback http.Server on a random port (b.testing.listenOnRandomPort)
|
|
15
|
+
* - loopback cleartext-h2 (h2c) http2.Server for the HTTP/2 code path,
|
|
16
|
+
* reached via preferH2:true (no TLS, no ALPN — prior-knowledge h2c)
|
|
17
|
+
* - fault injection (pre-aborted signal, erroring body Readable, a port
|
|
18
|
+
* with no listener for ECONNREFUSED) for the defensive branches
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
var http = require("http");
|
|
22
|
+
var https = require("https");
|
|
23
|
+
var http2 = require("http2");
|
|
24
|
+
var nodeStream = require("stream");
|
|
25
|
+
var nodeCrypto = require("crypto");
|
|
26
|
+
var asn1 = require("../../lib/asn1-der");
|
|
27
|
+
|
|
28
|
+
var helpers = require("../helpers");
|
|
29
|
+
var b = helpers.b;
|
|
30
|
+
var check = helpers.check;
|
|
31
|
+
|
|
32
|
+
var ALLOW = b.safeUrl.ALLOW_HTTP_ALL;
|
|
33
|
+
|
|
34
|
+
// ---- shared fixtures ------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function _mkAuditCapture() {
|
|
37
|
+
var events = [];
|
|
38
|
+
return { events: events, safeEmit: function (e) { events.push(e); } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function _withServer(handler, fn) {
|
|
42
|
+
var server = http.createServer(handler);
|
|
43
|
+
var port = await b.testing.listenOnRandomPort(server, "127.0.0.1");
|
|
44
|
+
try {
|
|
45
|
+
return await fn("http://127.0.0.1:" + port, port);
|
|
46
|
+
} finally {
|
|
47
|
+
await new Promise(function (resolve) { server.close(function () { resolve(); }); });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function _withTwoServers(handlerA, handlerB, fn) {
|
|
52
|
+
var serverA = http.createServer(handlerA);
|
|
53
|
+
var serverB = http.createServer(handlerB);
|
|
54
|
+
var portA = await b.testing.listenOnRandomPort(serverA, "127.0.0.1");
|
|
55
|
+
var portB = await b.testing.listenOnRandomPort(serverB, "127.0.0.1");
|
|
56
|
+
try {
|
|
57
|
+
return await fn("http://127.0.0.1:" + portA, "http://127.0.0.1:" + portB);
|
|
58
|
+
} finally {
|
|
59
|
+
await new Promise(function (resolve) { serverA.close(function () { resolve(); }); });
|
|
60
|
+
await new Promise(function (resolve) { serverB.close(function () { resolve(); }); });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function _withH2cServer(onStream, fn) {
|
|
65
|
+
var server = http2.createServer();
|
|
66
|
+
server.on("stream", onStream);
|
|
67
|
+
var port = await b.testing.listenOnRandomPort(server, "127.0.0.1");
|
|
68
|
+
try {
|
|
69
|
+
return await fn("http://127.0.0.1:" + port);
|
|
70
|
+
} finally {
|
|
71
|
+
await new Promise(function (resolve) { server.close(function () { resolve(); }); });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A REAL, handshake-valid self-signed EC leaf (P-256, CN=localhost). Generated
|
|
76
|
+
// once and reused. Lets a localhost https.createServer complete a TLS handshake
|
|
77
|
+
// so the HTTPS transport-selection paths (_getTransport https branch,
|
|
78
|
+
// _connectHttpsWithAlpn, opts.agent-over-TLS) run against a live TLS endpoint —
|
|
79
|
+
// no external network, no fixture on disk. The framework's own transport refuses
|
|
80
|
+
// this cert (self-signed) — exactly the error arm we want to exercise; the
|
|
81
|
+
// caller-agent path opts into rejectUnauthorized:false on its OWN agent (a
|
|
82
|
+
// documented operator escape hatch), never weakening the framework default.
|
|
83
|
+
var _cachedSelfSigned = null;
|
|
84
|
+
function _selfSignedCert() {
|
|
85
|
+
if (_cachedSelfSigned) return _cachedSelfSigned;
|
|
86
|
+
var kp = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
87
|
+
var spkiDer = kp.publicKey.export({ type: "spki", format: "der" });
|
|
88
|
+
var sigAlgId = asn1.writeSequence([asn1.writeOid("1.2.840.10045.4.3.2")]); // ecdsa-with-SHA256
|
|
89
|
+
var cnrdn = asn1.writeSequence([asn1.writeOid("2.5.4.3"),
|
|
90
|
+
asn1.writeNode(0x0c, Buffer.from("localhost", "ascii"))]);
|
|
91
|
+
var name = asn1.writeSequence([asn1.writeNode(0x31, cnrdn)]);
|
|
92
|
+
var validity = asn1.writeSequence([
|
|
93
|
+
asn1.writeNode(0x17, Buffer.from("250101000000Z", "ascii")),
|
|
94
|
+
asn1.writeNode(0x17, Buffer.from("350101000000Z", "ascii")),
|
|
95
|
+
]);
|
|
96
|
+
var version = asn1.writeContextExplicit(0, asn1.writeInteger(Buffer.from([2])));
|
|
97
|
+
var tbs = asn1.writeSequence([version, asn1.writeInteger(Buffer.from([0x12, 0x34, 0x56, 0x78])),
|
|
98
|
+
sigAlgId, name, validity, name, spkiDer]);
|
|
99
|
+
var sig = nodeCrypto.sign("sha256", tbs, kp.privateKey);
|
|
100
|
+
var certDer = asn1.writeSequence([tbs, sigAlgId, asn1.writeBitString(sig)]);
|
|
101
|
+
_cachedSelfSigned = {
|
|
102
|
+
certPem: "-----BEGIN CERTIFICATE-----\n" +
|
|
103
|
+
certDer.toString("base64").replace(/(.{64})/g, "$1\n") +
|
|
104
|
+
"\n-----END CERTIFICATE-----\n",
|
|
105
|
+
keyPem: kp.privateKey.export({ type: "pkcs8", format: "pem" }),
|
|
106
|
+
};
|
|
107
|
+
return _cachedSelfSigned;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function _withHttpsServer(handler, serverOpts, fn) {
|
|
111
|
+
var m = _selfSignedCert();
|
|
112
|
+
var server = https.createServer(Object.assign({ key: m.keyPem, cert: m.certPem }, serverOpts || {}), handler);
|
|
113
|
+
server.on("tlsClientError", function () { /* self-signed refusal is expected on the framework path */ });
|
|
114
|
+
var port = await b.testing.listenOnRandomPort(server, "127.0.0.1");
|
|
115
|
+
try {
|
|
116
|
+
return await fn("https://127.0.0.1:" + port, port);
|
|
117
|
+
} finally {
|
|
118
|
+
await new Promise(function (resolve) { server.close(function () { resolve(); }); });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Assert a promise rejects with a matching code (string) or message regex.
|
|
123
|
+
async function _expectReject(label, promise, codeOrRe) {
|
|
124
|
+
var err = null;
|
|
125
|
+
try { await promise; } catch (e) { err = e; }
|
|
126
|
+
var ok;
|
|
127
|
+
if (typeof codeOrRe === "string") ok = err != null && err.code === codeOrRe;
|
|
128
|
+
else ok = err != null && codeOrRe.test((err.code || "") + " " + (err.message || ""));
|
|
129
|
+
check(label, ok);
|
|
130
|
+
return err;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- surface --------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function testSurface() {
|
|
136
|
+
check("httpClient.request is a function", typeof b.httpClient.request === "function");
|
|
137
|
+
check("httpClient.configurePool is a function", typeof b.httpClient.configurePool === "function");
|
|
138
|
+
check("httpClient.DEFAULT_CONTROL_PLANE_CAP is a number", typeof b.httpClient.DEFAULT_CONTROL_PLANE_CAP === "number");
|
|
139
|
+
check("httpClient.DEFAULT_GET_CAP is a number", typeof b.httpClient.DEFAULT_GET_CAP === "number");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- configurePool validation + cache teardown ---------------------
|
|
143
|
+
|
|
144
|
+
async function testConfigurePool() {
|
|
145
|
+
check("configurePool: non-object throws",
|
|
146
|
+
(function () { try { b.httpClient.configurePool(42); return false; } catch (_e) { return true; } })());
|
|
147
|
+
check("configurePool: unknown key throws",
|
|
148
|
+
(function () { try { b.httpClient.configurePool({ nope: 1 }); return false; } catch (e) { return /unknown option/.test(e.message); } })());
|
|
149
|
+
check("configurePool: non-positive maxSockets throws (bad-opts)",
|
|
150
|
+
(function () { try { b.httpClient.configurePool({ maxSockets: 0 }); return false; } catch (e) { return e.code === "httpclient/bad-opts" || /maxSockets/i.test(e.message); } })());
|
|
151
|
+
check("configurePool: non-integer keepAliveMsecs throws",
|
|
152
|
+
(function () { try { b.httpClient.configurePool({ keepAliveMsecs: 1.5 }); return false; } catch (_e) { return true; } })());
|
|
153
|
+
check("configurePool: non-boolean keepAlive throws",
|
|
154
|
+
(function () { try { b.httpClient.configurePool({ keepAlive: "yes" }); return false; } catch (e) { return /keepAlive must be a boolean/.test(e.message); } })());
|
|
155
|
+
check("configurePool: bad scheduling throws",
|
|
156
|
+
(function () { try { b.httpClient.configurePool({ scheduling: "random" }); return false; } catch (e) { return /scheduling/.test(e.message); } })());
|
|
157
|
+
|
|
158
|
+
// Valid reconfigure tears down the per-origin cache (both h1 + h2 entries).
|
|
159
|
+
await _withServer(function (req, res) { res.writeHead(200); res.end("ok"); }, async function (base) {
|
|
160
|
+
await b.httpClient.request({ url: base + "/", allowedProtocols: ALLOW, allowInternal: true });
|
|
161
|
+
check("configurePool: an h1 transport is cached before reconfigure",
|
|
162
|
+
b.httpClient._getCachedTransportCount() >= 1);
|
|
163
|
+
b.httpClient.configurePool({ maxSockets: 8, maxFreeSockets: 4 });
|
|
164
|
+
check("configurePool: valid reconfigure clears the transport cache",
|
|
165
|
+
b.httpClient._getCachedTransportCount() === 0);
|
|
166
|
+
});
|
|
167
|
+
// Restore the shipped defaults so later tests / other files see the norm.
|
|
168
|
+
b.httpClient.configurePool({
|
|
169
|
+
maxSockets: b.httpClient.DEFAULT_AGENT_OPTS.maxSockets,
|
|
170
|
+
maxFreeSockets: b.httpClient.DEFAULT_AGENT_OPTS.maxFreeSockets,
|
|
171
|
+
keepAliveMsecs: b.httpClient.DEFAULT_AGENT_OPTS.keepAliveMsecs,
|
|
172
|
+
keepAlive: b.httpClient.DEFAULT_AGENT_OPTS.keepAlive,
|
|
173
|
+
scheduling: b.httpClient.DEFAULT_AGENT_OPTS.scheduling,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- request() argument validation (all reject, no network) --------
|
|
178
|
+
|
|
179
|
+
async function testArgValidation() {
|
|
180
|
+
await _expectReject("request: no opts rejects", b.httpClient.request(), "BAD_ARG");
|
|
181
|
+
await _expectReject("request: no url rejects", b.httpClient.request({ method: "GET" }), "BAD_ARG");
|
|
182
|
+
await _expectReject("request: before not an array rejects",
|
|
183
|
+
b.httpClient.request({ url: "https://x.example/", before: "nope" }), "BAD_ARG");
|
|
184
|
+
await _expectReject("request: before with non-function rejects",
|
|
185
|
+
b.httpClient.request({ url: "https://x.example/", before: [function () {}, 5] }), "BAD_ARG");
|
|
186
|
+
await _expectReject("request: after not an array rejects",
|
|
187
|
+
b.httpClient.request({ url: "https://x.example/", after: {} }), "BAD_ARG");
|
|
188
|
+
await _expectReject("request: onUploadProgress non-function rejects",
|
|
189
|
+
b.httpClient.request({ url: "https://x.example/", onUploadProgress: 1 }), "BAD_ARG");
|
|
190
|
+
await _expectReject("request: onDownloadProgress non-function rejects",
|
|
191
|
+
b.httpClient.request({ url: "https://x.example/", onDownloadProgress: 1 }), "BAD_ARG");
|
|
192
|
+
await _expectReject("request: onChunk non-function rejects",
|
|
193
|
+
b.httpClient.request({ url: "https://x.example/", onChunk: 1 }), "BAD_ARG");
|
|
194
|
+
await _expectReject("request: bad jar shape rejects",
|
|
195
|
+
b.httpClient.request({ url: "https://x.example/", jar: { cookieHeaderFor: 1 } }), "BAD_ARG");
|
|
196
|
+
await _expectReject("request: bad cache shape rejects",
|
|
197
|
+
b.httpClient.request({ url: "https://x.example/", cache: { _lookup: 1 } }), "BAD_ARG");
|
|
198
|
+
await _expectReject("request: negative maxRedirects rejects",
|
|
199
|
+
b.httpClient.request({ url: "https://x.example/", maxRedirects: -1 }), "BAD_ARG");
|
|
200
|
+
await _expectReject("request: non-integer maxRedirects rejects",
|
|
201
|
+
b.httpClient.request({ url: "https://x.example/", maxRedirects: 2.5 }), "BAD_ARG");
|
|
202
|
+
await _expectReject("request: multipart + body together rejects",
|
|
203
|
+
b.httpClient.request({ url: "https://x.example/", body: "x", multipart: { fields: {} } }), "BAD_ARG");
|
|
204
|
+
await _expectReject("request: malformed multipart file entry rejects",
|
|
205
|
+
b.httpClient.request({ url: "https://x.example/", multipart: { files: [{ field: "f" }] } }), "BAD_ARG");
|
|
206
|
+
await _expectReject("request: before hook that throws surfaces BEFORE_THREW",
|
|
207
|
+
b.httpClient.request({ url: "https://x.example/", before: [function () { throw new Error("bad pre"); }] }), "BEFORE_THREW");
|
|
208
|
+
// maxBytesPerSec + transform validation (branch also probed elsewhere).
|
|
209
|
+
await _expectReject("request: maxBytesPerSec non-number rejects",
|
|
210
|
+
b.httpClient.request({ url: "https://x.example/", maxBytesPerSec: "fast" }), /maxBytesPerSec/);
|
|
211
|
+
await _expectReject("request: uploadTransform non-Transform rejects",
|
|
212
|
+
b.httpClient.request({ url: "https://x.example/", uploadTransform: 7 }), /Transform/);
|
|
213
|
+
// Default scheme gate: a plain http URL without ALLOW_HTTP_ALL is refused.
|
|
214
|
+
await _expectReject("request: cleartext http refused without ALLOW_HTTP_ALL opt-in",
|
|
215
|
+
b.httpClient.request({ url: "http://127.0.0.1:1/", allowInternal: true }), /./);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- before / after interceptors -----------------------------------
|
|
219
|
+
|
|
220
|
+
async function testBeforeAfterInterceptors() {
|
|
221
|
+
await _withServer(function (req, res) {
|
|
222
|
+
res.writeHead(200, { "x-echo-inject": req.headers["x-inject"] || "" });
|
|
223
|
+
res.end("ok");
|
|
224
|
+
}, async function (base) {
|
|
225
|
+
var afterRan = 0;
|
|
226
|
+
var res = await b.httpClient.request({
|
|
227
|
+
url: base + "/", method: "GET",
|
|
228
|
+
allowedProtocols: ALLOW, allowInternal: true,
|
|
229
|
+
before: [function (o) { return Object.assign({}, o, { headers: { "x-inject": "on" } }); }],
|
|
230
|
+
after: [
|
|
231
|
+
function () { afterRan += 1; },
|
|
232
|
+
function () { throw new Error("after hooks are best-effort"); }, // must be swallowed
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
check("before: mutated opts reached the wire (server echoed injected header)",
|
|
236
|
+
res.headers["x-echo-inject"] === "on");
|
|
237
|
+
check("after: first hook ran", afterRan === 1);
|
|
238
|
+
check("after: a throwing hook did not break the response", res.statusCode === 200);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- allowedHosts egress gate --------------------------------------
|
|
243
|
+
|
|
244
|
+
async function testAllowedHosts() {
|
|
245
|
+
await _withServer(function (req, res) { res.writeHead(200); res.end("ok"); }, async function (base, port) {
|
|
246
|
+
// Exact host allow.
|
|
247
|
+
var r1 = await b.httpClient.request({
|
|
248
|
+
url: base + "/", allowedHosts: ["127.0.0.1"], allowedProtocols: ALLOW, allowInternal: true });
|
|
249
|
+
check("allowedHosts: exact host allowed", r1.statusCode === 200);
|
|
250
|
+
|
|
251
|
+
// Suffix + glob forms both match 127.0.0.1? No — use a hostname suffix case
|
|
252
|
+
// with the object/method-restricted form on the real loopback host.
|
|
253
|
+
var r2 = await b.httpClient.request({
|
|
254
|
+
url: base + "/", method: "GET",
|
|
255
|
+
allowedHosts: [{ host: "127.0.0.1", methods: ["GET", "HEAD"] }],
|
|
256
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
257
|
+
check("allowedHosts: method-restricted entry allows GET", r2.statusCode === 200);
|
|
258
|
+
|
|
259
|
+
// Method-restricted entry denies a non-listed method.
|
|
260
|
+
var audit = _mkAuditCapture();
|
|
261
|
+
var denied = await _expectReject("allowedHosts: method-restricted entry denies POST",
|
|
262
|
+
b.httpClient.request({
|
|
263
|
+
url: base + "/", method: "POST", body: "x",
|
|
264
|
+
allowedHosts: [{ host: "127.0.0.1", methods: ["GET"] }],
|
|
265
|
+
audit: audit, allowedProtocols: ALLOW, allowInternal: true }), "HOST_DISALLOWED");
|
|
266
|
+
check("allowedHosts: deny emitted a host_denied audit event",
|
|
267
|
+
audit.events.some(function (e) {
|
|
268
|
+
return e.action === "system.httpclient.host_denied" && e.outcome === "denied";
|
|
269
|
+
}));
|
|
270
|
+
void denied; void port;
|
|
271
|
+
|
|
272
|
+
// Host not on the list at all → denied.
|
|
273
|
+
await _expectReject("allowedHosts: unlisted host denied",
|
|
274
|
+
b.httpClient.request({
|
|
275
|
+
url: base + "/", allowedHosts: ["api.partner.example"],
|
|
276
|
+
allowedProtocols: ALLOW, allowInternal: true }), "HOST_DISALLOWED");
|
|
277
|
+
|
|
278
|
+
// Suffix form (".0.0.1") — "127.0.0.1".endsWith(".0.0.1") matches.
|
|
279
|
+
var r3 = await b.httpClient.request({
|
|
280
|
+
url: base + "/", allowedHosts: [".0.0.1"], allowedProtocols: ALLOW, allowInternal: true });
|
|
281
|
+
check("allowedHosts: suffix (.0.0.1) matches 127.0.0.1", r3.statusCode === 200);
|
|
282
|
+
|
|
283
|
+
// Glob form ("*.0.0.1") normalizes to the suffix and matches.
|
|
284
|
+
var r4 = await b.httpClient.request({
|
|
285
|
+
url: base + "/", allowedHosts: ["*.0.0.1"], allowedProtocols: ALLOW, allowInternal: true });
|
|
286
|
+
check("allowedHosts: glob (*.0.0.1) normalizes + matches", r4.statusCode === 200);
|
|
287
|
+
|
|
288
|
+
// Leading-dot exact form (".127.0.0.1") matches via host === allow.slice(1).
|
|
289
|
+
var r5 = await b.httpClient.request({
|
|
290
|
+
url: base + "/", allowedHosts: [".127.0.0.1"], allowedProtocols: ALLOW, allowInternal: true });
|
|
291
|
+
check("allowedHosts: dotted-exact (.127.0.0.1) matches via slice", r5.statusCode === 200);
|
|
292
|
+
|
|
293
|
+
// Caller-supplied agent bypasses the transport cache (h1 override path).
|
|
294
|
+
var customAgent = new http.Agent({ keepAlive: false });
|
|
295
|
+
try {
|
|
296
|
+
var r6 = await b.httpClient.request({
|
|
297
|
+
url: base + "/", agent: customAgent, allowedProtocols: ALLOW, allowInternal: true });
|
|
298
|
+
check("agent override: request succeeds through the caller's agent", r6.statusCode === 200);
|
|
299
|
+
} finally {
|
|
300
|
+
customAgent.destroy();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// String body path (Content-Length + ulTotal via Buffer.byteLength).
|
|
304
|
+
var r7 = await b.httpClient.request({
|
|
305
|
+
url: base + "/", method: "POST", body: "hello-string",
|
|
306
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
307
|
+
check("string body: request with string body succeeds", r7.statusCode === 200);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---- buffered response error branches (h1) -------------------------
|
|
312
|
+
|
|
313
|
+
async function testBufferedErrorBranches() {
|
|
314
|
+
// non-2xx → HTTP_ERROR (default buffer mode)
|
|
315
|
+
await _withServer(function (req, res) {
|
|
316
|
+
res.writeHead(404, { "Content-Type": "text/plain" }); res.end("nope");
|
|
317
|
+
}, async function (base) {
|
|
318
|
+
var err = await _expectReject("buffer 404: rejects HTTP_ERROR",
|
|
319
|
+
b.httpClient.request({ url: base + "/x", allowedProtocols: ALLOW, allowInternal: true }), "HTTP_ERROR");
|
|
320
|
+
check("buffer 404: message names the status", err && /404/.test(err.message));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// always-resolve → non-2xx returned, not thrown
|
|
324
|
+
await _withServer(function (req, res) {
|
|
325
|
+
res.writeHead(503, { "Content-Type": "text/plain" }); res.end("down");
|
|
326
|
+
}, async function (base) {
|
|
327
|
+
var r = await b.httpClient.request({
|
|
328
|
+
url: base + "/x", responseMode: "always-resolve",
|
|
329
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
330
|
+
check("always-resolve: 503 returned structurally", r.statusCode === 503 && r.body.toString() === "down");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// maxResponseBytes cap → RESPONSE_TOO_LARGE
|
|
334
|
+
await _withServer(function (req, res) {
|
|
335
|
+
res.writeHead(200, { "Content-Length": "2000" }); res.end(Buffer.alloc(2000, 0x61));
|
|
336
|
+
}, async function (base) {
|
|
337
|
+
await _expectReject("buffer over-cap: rejects RESPONSE_TOO_LARGE",
|
|
338
|
+
b.httpClient.request({ url: base + "/big", maxResponseBytes: 100,
|
|
339
|
+
allowedProtocols: ALLOW, allowInternal: true }), "RESPONSE_TOO_LARGE");
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---- progress + observer + onChunk hooks (h1) ----------------------
|
|
344
|
+
|
|
345
|
+
async function testProgressAndObserver() {
|
|
346
|
+
var payload = Buffer.alloc(4096, 0x62);
|
|
347
|
+
await _withServer(function (req, res) {
|
|
348
|
+
var chunks = [];
|
|
349
|
+
req.on("data", function (c) { chunks.push(c); });
|
|
350
|
+
req.on("end", function () {
|
|
351
|
+
res.writeHead(200, { "Content-Length": String(payload.length) });
|
|
352
|
+
res.end(payload);
|
|
353
|
+
});
|
|
354
|
+
}, async function (base) {
|
|
355
|
+
var stages = [];
|
|
356
|
+
var dl = [];
|
|
357
|
+
var ul = [];
|
|
358
|
+
var chunkBytes = 0;
|
|
359
|
+
var res = await b.httpClient.request({
|
|
360
|
+
url: base + "/", method: "POST", body: Buffer.alloc(2048, 0x63),
|
|
361
|
+
allowedProtocols: ALLOW, allowInternal: true,
|
|
362
|
+
observer: function (stage) { stages.push(stage); },
|
|
363
|
+
onDownloadProgress: function (p) { dl.push(p); },
|
|
364
|
+
onUploadProgress: function (p) { ul.push(p); },
|
|
365
|
+
onChunk: function (c) { chunkBytes += c.length; },
|
|
366
|
+
});
|
|
367
|
+
check("observer: saw request:start and response:end",
|
|
368
|
+
stages.indexOf("request:start") !== -1 && stages.indexOf("response:end") !== -1);
|
|
369
|
+
check("onDownloadProgress: fired with loaded/total", dl.length > 0 && dl[dl.length - 1].loaded === payload.length && dl[dl.length - 1].total === payload.length);
|
|
370
|
+
check("onUploadProgress: fired and summed to body length", ul.length > 0 && ul[ul.length - 1].loaded === 2048);
|
|
371
|
+
check("onChunk: saw the full response body", chunkBytes === payload.length);
|
|
372
|
+
check("response body intact", res.body.equals(payload));
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---- idle timeout + abort + connection error (h1) ------------------
|
|
377
|
+
|
|
378
|
+
async function testTimeoutAbortConnError() {
|
|
379
|
+
// Idle timeout: server accepts but never responds.
|
|
380
|
+
await _withServer(function () { /* hold the request open, never respond */ }, async function (base) {
|
|
381
|
+
await _expectReject("idle timeout: rejects ETIMEDOUT",
|
|
382
|
+
b.httpClient.request({ url: base + "/hang", idleTimeoutMs: 300,
|
|
383
|
+
allowedProtocols: ALLOW, allowInternal: true }), "ETIMEDOUT");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Pre-aborted signal → rejects before any socket work.
|
|
387
|
+
var pre = new AbortController();
|
|
388
|
+
pre.abort();
|
|
389
|
+
await _expectReject("pre-aborted signal: rejects ABORT",
|
|
390
|
+
b.httpClient.request({ url: "http://127.0.0.1:9/x", signal: pre.signal,
|
|
391
|
+
allowedProtocols: ALLOW, allowInternal: true }), "ABORT");
|
|
392
|
+
|
|
393
|
+
// Abort mid-flight: server holds the request, we abort right after issuing.
|
|
394
|
+
await _withServer(function () { /* hold open */ }, async function (base) {
|
|
395
|
+
var ctrl = new AbortController();
|
|
396
|
+
var p = b.httpClient.request({ url: base + "/hang", signal: ctrl.signal,
|
|
397
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
398
|
+
ctrl.abort();
|
|
399
|
+
await _expectReject("in-flight abort: rejects ABORT", p, "ABORT");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Connection refused: bind a server, capture its port, close it, then hit it.
|
|
403
|
+
var deadPort = await (async function () {
|
|
404
|
+
var s = http.createServer();
|
|
405
|
+
var port = await b.testing.listenOnRandomPort(s, "127.0.0.1");
|
|
406
|
+
await new Promise(function (r) { s.close(function () { r(); }); });
|
|
407
|
+
return port;
|
|
408
|
+
})();
|
|
409
|
+
b.httpClient._resetForTest(); // drop any cached transport for that origin
|
|
410
|
+
await _expectReject("connection refused: rejects with a connect error",
|
|
411
|
+
b.httpClient.request({ url: "http://127.0.0.1:" + deadPort + "/x",
|
|
412
|
+
allowedProtocols: ALLOW, allowInternal: true }), /ECONNREFUSED|REQ_ERROR/);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---- request-body stream error (h1) --------------------------------
|
|
416
|
+
|
|
417
|
+
async function testRequestBodyStreamError() {
|
|
418
|
+
await _withServer(function (req) {
|
|
419
|
+
// Consume + hold: the body error should settle the promise, not the response.
|
|
420
|
+
req.on("data", function () {});
|
|
421
|
+
req.on("error", function () {});
|
|
422
|
+
}, async function (base) {
|
|
423
|
+
var bad = new nodeStream.Readable({
|
|
424
|
+
read: function () { this.destroy(new Error("body source blew up")); },
|
|
425
|
+
});
|
|
426
|
+
await _expectReject("upload body stream error: rejects REQ_BODY_ERROR",
|
|
427
|
+
b.httpClient.request({ url: base + "/u", method: "POST", body: bad,
|
|
428
|
+
allowedProtocols: ALLOW, allowInternal: true }), "REQ_BODY_ERROR");
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---- cookie jar attach + record (h1) -------------------------------
|
|
433
|
+
|
|
434
|
+
async function testCookieJar() {
|
|
435
|
+
var jar = b.httpClient.cookieJar.create();
|
|
436
|
+
var sawCookie = null;
|
|
437
|
+
await _withServer(function (req, res) {
|
|
438
|
+
sawCookie = req.headers.cookie || null;
|
|
439
|
+
res.writeHead(200, { "set-cookie": "sid=abc123; Path=/" });
|
|
440
|
+
res.end("ok");
|
|
441
|
+
}, async function (base) {
|
|
442
|
+
// First request: no cookie yet, server sets one.
|
|
443
|
+
await b.httpClient.request({ url: base + "/", jar: jar, allowedProtocols: ALLOW, allowInternal: true });
|
|
444
|
+
check("jar: first request sent no cookie", sawCookie === null);
|
|
445
|
+
var hdr = jar.cookieHeaderFor(base + "/");
|
|
446
|
+
check("jar: recorded Set-Cookie from the response", typeof hdr === "string" && /sid=abc123/.test(hdr));
|
|
447
|
+
// Second request: jar-derived Cookie header is attached.
|
|
448
|
+
await b.httpClient.request({ url: base + "/", jar: jar, allowedProtocols: ALLOW, allowInternal: true });
|
|
449
|
+
check("jar: second request carried the stored cookie", /sid=abc123/.test(sawCookie || ""));
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---- cookie jar parse / store / match branch coverage --------------
|
|
454
|
+
//
|
|
455
|
+
// Drives the public jar object (b.httpClient.cookieJar.create()) directly:
|
|
456
|
+
// setFromResponse / cookieHeaderFor / getAll / clear / size /
|
|
457
|
+
// setFromSerialized are the documented operator surface for a hand-wired
|
|
458
|
+
// jar, and are the real consumer path for the RFC 6265 parse+store logic
|
|
459
|
+
// that the request()-driven happy-path tests above don't exercise. No
|
|
460
|
+
// network — the jar is a pure in-memory state machine here, with a
|
|
461
|
+
// deterministic injected clock for the expiry branches.
|
|
462
|
+
|
|
463
|
+
function _cjExpectThrow(label, fn, code) {
|
|
464
|
+
var err = null;
|
|
465
|
+
try { fn(); } catch (e) { err = e; }
|
|
466
|
+
check(label, err != null && err.code === code);
|
|
467
|
+
return err;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Minimal seal/unseal contract stand-in for the vault-persist path — the
|
|
471
|
+
// jar only requires { seal, unseal } (validateOpts.requireMethods). Kept
|
|
472
|
+
// reversible + tagged so the no-plaintext assertion is checkable.
|
|
473
|
+
function _cjFakeVault() {
|
|
474
|
+
return {
|
|
475
|
+
seal: function (s) { return "SEAL[" + Buffer.from(String(s), "utf8").toString("base64") + "]"; },
|
|
476
|
+
unseal: function (blob) {
|
|
477
|
+
var m = /^SEAL\[(.*)\]$/.exec(String(blob));
|
|
478
|
+
if (!m) throw new Error("cj fake vault: not a sealed blob");
|
|
479
|
+
return Buffer.from(m[1], "base64").toString("utf8");
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function testCookieJarParseStore() {
|
|
485
|
+
var CJ = b.httpClient.cookieJar;
|
|
486
|
+
|
|
487
|
+
// ---- create() config-time validation (throws, no network) ----
|
|
488
|
+
_cjExpectThrow("cj.create: invalid persist throws BAD_OPT",
|
|
489
|
+
function () { CJ.create({ persist: "redis" }); }, "BAD_OPT");
|
|
490
|
+
_cjExpectThrow("cj.create: persist 'vault' without a vault throws BAD_OPT",
|
|
491
|
+
function () { CJ.create({ persist: "vault" }); }, "BAD_OPT");
|
|
492
|
+
_cjExpectThrow("cj.create: persist 'vault' with a half-shaped vault throws BAD_OPT",
|
|
493
|
+
function () { CJ.create({ persist: "vault", vault: { seal: function () {} } }); }, "BAD_OPT");
|
|
494
|
+
_cjExpectThrow("cj.create: persist 'file' without opts.file throws BAD_OPT",
|
|
495
|
+
function () { CJ.create({ persist: "file" }); }, "BAD_OPT");
|
|
496
|
+
_cjExpectThrow("cj.create: persist 'file' with a relative path throws BAD_OPT",
|
|
497
|
+
function () { CJ.create({ persist: "file", file: "rel/jar.json" }); }, "BAD_OPT");
|
|
498
|
+
// A vault is optional for file mode, but a supplied half-shaped one is refused
|
|
499
|
+
// up front (config-time), not silently at a later flush.
|
|
500
|
+
_cjExpectThrow("cj.create: persist 'file' with a half-shaped vault (missing seal) throws BAD_OPT",
|
|
501
|
+
function () { CJ.create({ persist: "file", file: "/tmp/cj-badvault.json", vault: { unseal: function () {} } }); }, "BAD_OPT");
|
|
502
|
+
_cjExpectThrow("cj.create: flushDebounceMs negative throws BAD_OPT",
|
|
503
|
+
function () { CJ.create({ flushDebounceMs: -1 }); }, "BAD_OPT");
|
|
504
|
+
_cjExpectThrow("cj.create: flushDebounceMs non-integer throws BAD_OPT",
|
|
505
|
+
function () { CJ.create({ flushDebounceMs: 1.5 }); }, "BAD_OPT");
|
|
506
|
+
_cjExpectThrow("cj.create: flushDebounceMs non-number throws BAD_OPT",
|
|
507
|
+
function () { CJ.create({ flushDebounceMs: "soon" }); }, "BAD_OPT");
|
|
508
|
+
check("cj.create: defaults to memory persist", CJ.create().persist === "memory");
|
|
509
|
+
|
|
510
|
+
// ---- _parseSetCookie pure-null / edge branches ----
|
|
511
|
+
check("cj.parse: non-string line → null", CJ._parseSetCookie(123) === null);
|
|
512
|
+
check("cj.parse: empty line → null", CJ._parseSetCookie("") === null);
|
|
513
|
+
check("cj.parse: no '=' (bare token) → null", CJ._parseSetCookie("justname") === null);
|
|
514
|
+
check("cj.parse: leading '=' (empty name) → null", CJ._parseSetCookie("=novalue") === null);
|
|
515
|
+
check("cj.parse: no attributes (no ';') parses name/value",
|
|
516
|
+
(function () { var p = CJ._parseSetCookie("a=b"); return p && p.name === "a" && p.value === "b"; })());
|
|
517
|
+
check("cj.parse: bare attribute (no '=') recorded as empty-string value",
|
|
518
|
+
(function () { var p = CJ._parseSetCookie("a=b; Secure; HttpOnly"); return p && p.attrs.secure === "" && p.attrs.httponly === ""; })());
|
|
519
|
+
check("cj.parse: quoted attribute value is unquoted",
|
|
520
|
+
(function () { var p = CJ._parseSetCookie('a=b; SameSite="Lax"'); return p && p.attrs.samesite === "Lax"; })());
|
|
521
|
+
check("cj.parse: empty value after '=' is allowed",
|
|
522
|
+
(function () { var p = CJ._parseSetCookie("a=; Path=/"); return p && p.name === "a" && p.value === ""; })());
|
|
523
|
+
|
|
524
|
+
// ---- basic store + defaults via the consumer surface ----
|
|
525
|
+
var jar = CJ.create();
|
|
526
|
+
jar.setFromResponse("http://example.com/app/page", "sid=abc; HttpOnly");
|
|
527
|
+
var all = jar.getAll();
|
|
528
|
+
check("cj.store: one row stored", all.length === 1);
|
|
529
|
+
check("cj.store: hostOnly defaults true (no Domain attr)", all[0].hostOnly === true);
|
|
530
|
+
check("cj.store: domain defaults to request host", all[0].domain === "example.com");
|
|
531
|
+
check("cj.store: default path derived from request path", all[0].path === "/app");
|
|
532
|
+
check("cj.store: httpOnly flag captured", all[0].httpOnly === true);
|
|
533
|
+
check("cj.store: secure flag absent by default", all[0].secure === false);
|
|
534
|
+
check("cj.store: session cookie has null expiresAt", all[0].expiresAt === null);
|
|
535
|
+
|
|
536
|
+
// setFromResponse guards: falsy header + array-of-lines + malformed lines skipped.
|
|
537
|
+
var jar2 = CJ.create();
|
|
538
|
+
jar2.setFromResponse("http://example.com/", null);
|
|
539
|
+
jar2.setFromResponse("http://example.com/", undefined);
|
|
540
|
+
check("cj.store: falsy Set-Cookie header is a no-op", jar2.size() === 0);
|
|
541
|
+
jar2.setFromResponse("http://example.com/", ["one=1", "justtoken", "two=2"]);
|
|
542
|
+
check("cj.store: array of Set-Cookie lines stores the valid ones, skips garbage", jar2.size() === 2);
|
|
543
|
+
|
|
544
|
+
// Unparseable request URL → _setOne swallows and stores nothing.
|
|
545
|
+
var jarBad = CJ.create();
|
|
546
|
+
jarBad.setFromResponse("http://[not a url", "x=1");
|
|
547
|
+
check("cj.store: unparseable request URL stores nothing (no throw)", jarBad.size() === 0);
|
|
548
|
+
|
|
549
|
+
// ---- Domain attribute handling ----
|
|
550
|
+
var jarDom = CJ.create();
|
|
551
|
+
// Domain the request host does NOT belong to → rejected.
|
|
552
|
+
jarDom.setFromResponse("http://example.com/", "bad=1; Domain=other.example.org");
|
|
553
|
+
check("cj.domain: Domain the host doesn't match is rejected", jarDom.size() === 0);
|
|
554
|
+
// Leading-dot stripped + subdomain accepted from a matching host.
|
|
555
|
+
jarDom.setFromResponse("http://api.example.com/", "ok=1; Domain=.example.com");
|
|
556
|
+
var domRow = jarDom.getAll()[0];
|
|
557
|
+
check("cj.domain: leading dot stripped from Domain", domRow.domain === "example.com");
|
|
558
|
+
check("cj.domain: Domain attr sets hostOnly false", domRow.hostOnly === false);
|
|
559
|
+
|
|
560
|
+
// ---- Path attribute handling (explicit vs default derivation) ----
|
|
561
|
+
var jarPath = CJ.create();
|
|
562
|
+
jarPath.setFromResponse("http://example.com/a/b/c", "p=1; Path=/api");
|
|
563
|
+
check("cj.path: explicit Path attribute honored", jarPath.getAll()[0].path === "/api");
|
|
564
|
+
var jarPath2 = CJ.create();
|
|
565
|
+
// Path not starting with "/" is ignored → default-path derivation runs.
|
|
566
|
+
jarPath2.setFromResponse("http://example.com/a/b/c", "p=1; Path=nope");
|
|
567
|
+
check("cj.path: non-'/' Path falls back to default path ('/a/b')", jarPath2.getAll()[0].path === "/a/b");
|
|
568
|
+
var jarPath3 = CJ.create();
|
|
569
|
+
jarPath3.setFromResponse("http://example.com/onlyfile", "p=1");
|
|
570
|
+
check("cj.path: single-segment request path defaults to '/'", jarPath3.getAll()[0].path === "/");
|
|
571
|
+
var jarPath4 = CJ.create();
|
|
572
|
+
jarPath4.setFromResponse("http://example.com/", "p=1");
|
|
573
|
+
check("cj.path: root request path defaults to '/'", jarPath4.getAll()[0].path === "/");
|
|
574
|
+
|
|
575
|
+
// ---- SameSite normalization (case-insensitive; invalid → null) ----
|
|
576
|
+
var jarSS = CJ.create();
|
|
577
|
+
jarSS.setFromResponse("http://example.com/", "a=1; SameSite=strict");
|
|
578
|
+
jarSS.setFromResponse("http://example.com/", "b=2; SameSite=LAX");
|
|
579
|
+
jarSS.setFromResponse("http://example.com/", "c=3; SameSite=none");
|
|
580
|
+
jarSS.setFromResponse("http://example.com/", "d=4; SameSite=bogus");
|
|
581
|
+
jarSS.setFromResponse("http://example.com/", "e=5");
|
|
582
|
+
var ssByName = {};
|
|
583
|
+
jarSS.getAll().forEach(function (r) { ssByName[r.name] = r.sameSite; });
|
|
584
|
+
check("cj.samesite: 'strict' → 'Strict'", ssByName.a === "Strict");
|
|
585
|
+
check("cj.samesite: 'LAX' → 'Lax'", ssByName.b === "Lax");
|
|
586
|
+
check("cj.samesite: 'none' → 'None'", ssByName.c === "None");
|
|
587
|
+
check("cj.samesite: unrecognized value → null", ssByName.d === null);
|
|
588
|
+
check("cj.samesite: absent → null", ssByName.e === null);
|
|
589
|
+
|
|
590
|
+
// ---- Expiry: Max-Age / Expires / precedence / deletion ----
|
|
591
|
+
var nowRef = { t: 1600000000000 };
|
|
592
|
+
var clockJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
593
|
+
clockJar.setFromResponse("http://example.com/", "ma=1; Max-Age=100");
|
|
594
|
+
check("cj.expiry: Max-Age sets expiresAt = now + seconds",
|
|
595
|
+
clockJar.getAll()[0].expiresAt === nowRef.t + 100000);
|
|
596
|
+
// Max-Age wins over Expires when both present.
|
|
597
|
+
var precJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
598
|
+
precJar.setFromResponse("http://example.com/", "p=1; Max-Age=50; Expires=Thu, 01 Jan 1970 00:00:00 GMT");
|
|
599
|
+
check("cj.expiry: Max-Age takes precedence over Expires",
|
|
600
|
+
precJar.getAll()[0].expiresAt === nowRef.t + 50000);
|
|
601
|
+
// Max-Age=0 deletes / never stores.
|
|
602
|
+
var zeroJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
603
|
+
zeroJar.setFromResponse("http://example.com/", "z=1");
|
|
604
|
+
check("cj.expiry: session cookie present before Max-Age=0", zeroJar.size() === 1);
|
|
605
|
+
zeroJar.setFromResponse("http://example.com/", "z=1; Max-Age=0");
|
|
606
|
+
check("cj.expiry: Max-Age=0 deletes the matching row", zeroJar.size() === 0);
|
|
607
|
+
// Negative Max-Age also expires immediately.
|
|
608
|
+
var negJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
609
|
+
negJar.setFromResponse("http://example.com/", "n=1; Max-Age=-5");
|
|
610
|
+
check("cj.expiry: negative Max-Age never stores", negJar.size() === 0);
|
|
611
|
+
// Non-numeric Max-Age → ignored → session cookie.
|
|
612
|
+
var nanJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
613
|
+
nanJar.setFromResponse("http://example.com/", "x=1; Max-Age=abc");
|
|
614
|
+
check("cj.expiry: non-numeric Max-Age → session cookie (null expiresAt)",
|
|
615
|
+
nanJar.getAll()[0].expiresAt === null);
|
|
616
|
+
// Past Expires deletes an existing row.
|
|
617
|
+
var expJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
618
|
+
expJar.setFromResponse("http://example.com/", "s=1");
|
|
619
|
+
expJar.setFromResponse("http://example.com/", "s=1; Expires=Wed, 01 Jan 2020 00:00:00 GMT");
|
|
620
|
+
check("cj.expiry: past Expires deletes the existing row", expJar.size() === 0);
|
|
621
|
+
// Unparseable Expires → ignored → session cookie.
|
|
622
|
+
var badExpJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
623
|
+
badExpJar.setFromResponse("http://example.com/", "s=1; Expires=not-a-date");
|
|
624
|
+
check("cj.expiry: unparseable Expires → session cookie", badExpJar.getAll()[0].expiresAt === null);
|
|
625
|
+
|
|
626
|
+
// ---- update preserves createdAt, bumps updatedAt ----
|
|
627
|
+
var upJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
628
|
+
upJar.setFromResponse("http://example.com/", "u=1");
|
|
629
|
+
var created = upJar.getAll()[0].createdAt;
|
|
630
|
+
nowRef.t += 5000;
|
|
631
|
+
upJar.setFromResponse("http://example.com/", "u=2");
|
|
632
|
+
var afterRow = upJar.getAll()[0];
|
|
633
|
+
check("cj.update: same key replaces value", afterRow.value === "2");
|
|
634
|
+
check("cj.update: createdAt preserved across update", afterRow.createdAt === created);
|
|
635
|
+
check("cj.update: updatedAt advanced", afterRow.updatedAt === created + 5000);
|
|
636
|
+
check("cj.update: size unchanged after in-place replace", upJar.size() === 1);
|
|
637
|
+
nowRef.t = 1600000000000;
|
|
638
|
+
|
|
639
|
+
// ---- cookieHeaderFor matching branches ----
|
|
640
|
+
check("cj.header: unparseable URL → null", jar.cookieHeaderFor("::::bad") === null);
|
|
641
|
+
var noMatchJar = CJ.create();
|
|
642
|
+
noMatchJar.setFromResponse("http://example.com/", "a=1");
|
|
643
|
+
check("cj.header: no matching cookie → null",
|
|
644
|
+
noMatchJar.cookieHeaderFor("http://other.com/") === null);
|
|
645
|
+
|
|
646
|
+
// hostOnly: attaches on exact host only, NOT on subdomains.
|
|
647
|
+
var hoJar = CJ.create();
|
|
648
|
+
hoJar.setFromResponse("http://example.com/", "ho=1");
|
|
649
|
+
check("cj.header: hostOnly attaches on exact host",
|
|
650
|
+
/ho=1/.test(hoJar.cookieHeaderFor("http://example.com/") || ""));
|
|
651
|
+
check("cj.header: hostOnly does NOT attach on a subdomain",
|
|
652
|
+
hoJar.cookieHeaderFor("http://sub.example.com/") === null);
|
|
653
|
+
|
|
654
|
+
// Domain cookie: attaches to host + subdomains, not to a look-alike suffix.
|
|
655
|
+
var dmJar = CJ.create();
|
|
656
|
+
dmJar.setFromResponse("http://api.example.com/", "dm=1; Domain=example.com");
|
|
657
|
+
check("cj.header: Domain cookie attaches on the parent host",
|
|
658
|
+
/dm=1/.test(dmJar.cookieHeaderFor("http://example.com/") || ""));
|
|
659
|
+
check("cj.header: Domain cookie attaches on a subdomain",
|
|
660
|
+
/dm=1/.test(dmJar.cookieHeaderFor("http://api.example.com/") || ""));
|
|
661
|
+
check("cj.header: Domain cookie does NOT attach to a suffix look-alike host",
|
|
662
|
+
dmJar.cookieHeaderFor("http://notexample.com/") === null);
|
|
663
|
+
|
|
664
|
+
// Path matching: exact, path-below with '/', trailing-slash cookie path, non-match.
|
|
665
|
+
var pmJar = CJ.create();
|
|
666
|
+
pmJar.setFromResponse("http://example.com/app", "pm=1; Path=/app");
|
|
667
|
+
check("cj.header: path exact match attaches",
|
|
668
|
+
/pm=1/.test(pmJar.cookieHeaderFor("http://example.com/app") || ""));
|
|
669
|
+
check("cj.header: path-below (boundary '/') attaches",
|
|
670
|
+
/pm=1/.test(pmJar.cookieHeaderFor("http://example.com/app/sub") || ""));
|
|
671
|
+
check("cj.header: sibling prefix (no '/' boundary) does NOT attach",
|
|
672
|
+
pmJar.cookieHeaderFor("http://example.com/application") === null);
|
|
673
|
+
var pmTrail = CJ.create();
|
|
674
|
+
pmTrail.setFromResponse("http://example.com/dir/", "pt=1; Path=/dir/");
|
|
675
|
+
check("cj.header: trailing-slash cookie path attaches to a path below",
|
|
676
|
+
/pt=1/.test(pmTrail.cookieHeaderFor("http://example.com/dir/x") || ""));
|
|
677
|
+
|
|
678
|
+
// Secure: only attaches over https.
|
|
679
|
+
var secJar = CJ.create();
|
|
680
|
+
secJar.setFromResponse("https://example.com/", "sec=1; Secure");
|
|
681
|
+
check("cj.header: Secure cookie withheld over http",
|
|
682
|
+
secJar.cookieHeaderFor("http://example.com/") === null);
|
|
683
|
+
check("cj.header: Secure cookie attaches over https",
|
|
684
|
+
/sec=1/.test(secJar.cookieHeaderFor("https://example.com/") || ""));
|
|
685
|
+
|
|
686
|
+
// Expired cookies never attach.
|
|
687
|
+
var expHdrJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
688
|
+
expHdrJar.setFromResponse("http://example.com/", "eh=1; Max-Age=10");
|
|
689
|
+
check("cj.header: unexpired cookie attaches",
|
|
690
|
+
/eh=1/.test(expHdrJar.cookieHeaderFor("http://example.com/") || ""));
|
|
691
|
+
nowRef.t += 20000;
|
|
692
|
+
check("cj.header: expired cookie no longer attaches",
|
|
693
|
+
expHdrJar.cookieHeaderFor("http://example.com/") === null);
|
|
694
|
+
check("cj.header: getAll() also filters the expired row", expHdrJar.getAll().length === 0);
|
|
695
|
+
check("cj.header: size() also filters the expired row", expHdrJar.size() === 0);
|
|
696
|
+
nowRef.t = 1600000000000;
|
|
697
|
+
|
|
698
|
+
// Sort order: longer path first, then earlier createdAt on tie.
|
|
699
|
+
var sortJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
700
|
+
sortJar.setFromResponse("http://example.com/", "root1=1; Path=/");
|
|
701
|
+
nowRef.t += 1000;
|
|
702
|
+
sortJar.setFromResponse("http://example.com/", "root2=1; Path=/");
|
|
703
|
+
sortJar.setFromResponse("http://example.com/deep", "deep=1; Path=/deep");
|
|
704
|
+
var hdr = sortJar.cookieHeaderFor("http://example.com/deep/x");
|
|
705
|
+
check("cj.sort: longer path sorts before shorter", hdr.indexOf("deep=1") < hdr.indexOf("root1=1"));
|
|
706
|
+
check("cj.sort: equal-length paths break ties by createdAt (earlier first)",
|
|
707
|
+
hdr.indexOf("root1=1") < hdr.indexOf("root2=1"));
|
|
708
|
+
nowRef.t = 1600000000000;
|
|
709
|
+
|
|
710
|
+
// ---- clear() branches ----
|
|
711
|
+
var clrJar = CJ.create();
|
|
712
|
+
clrJar.setFromResponse("http://example.com/", "a=1");
|
|
713
|
+
clrJar.setFromResponse("http://example.com/x", "b=1; Path=/x");
|
|
714
|
+
clrJar.setFromResponse("http://other.com/", "a=1");
|
|
715
|
+
_cjExpectThrow("cj.clear: non-object filter throws BAD_OPT",
|
|
716
|
+
function () { clrJar.clear("everything"); }, "BAD_OPT");
|
|
717
|
+
check("cj.clear: filter by domain removes only that domain",
|
|
718
|
+
clrJar.clear({ domain: "other.com" }) === 1 && clrJar.size() === 2);
|
|
719
|
+
check("cj.clear: filter by name + path removes the targeted row",
|
|
720
|
+
clrJar.clear({ name: "b", path: "/x" }) === 1 && clrJar.size() === 1);
|
|
721
|
+
check("cj.clear: filter matching nothing returns 0", clrJar.clear({ name: "zzz" }) === 0);
|
|
722
|
+
check("cj.clear: no filter clears all and returns the prior count",
|
|
723
|
+
clrJar.clear() === 1 && clrJar.size() === 0);
|
|
724
|
+
|
|
725
|
+
// ---- setFromSerialized() branches ----
|
|
726
|
+
var serJar = CJ.create({ clock: function () { return nowRef.t; } });
|
|
727
|
+
_cjExpectThrow("cj.serialized: non-array throws BAD_OPT",
|
|
728
|
+
function () { serJar.setFromSerialized({ name: "x" }); }, "BAD_OPT");
|
|
729
|
+
serJar.setFromSerialized([
|
|
730
|
+
null, // skipped
|
|
731
|
+
{ name: "x" }, // missing domain/path → skipped
|
|
732
|
+
{ name: "ok", domain: "example.com", path: "/", value: "1" },
|
|
733
|
+
{ name: "gone", domain: "example.com", path: "/", value: "2", expiresAt: nowRef.t - 1 }, // expired → skipped
|
|
734
|
+
{ name: "ss", domain: "example.com", path: "/", value: "3", sameSite: "Bogus" }, // invalid sameSite → null
|
|
735
|
+
{ name: "ss2", domain: "example.com", path: "/", value: "4", sameSite: "Lax" },
|
|
736
|
+
]);
|
|
737
|
+
var serRows = {};
|
|
738
|
+
serJar.getAll().forEach(function (r) { serRows[r.name] = r; });
|
|
739
|
+
check("cj.serialized: valid row restored", serRows.ok && serRows.ok.value === "1");
|
|
740
|
+
check("cj.serialized: null / underspecified rows skipped", !serRows.x);
|
|
741
|
+
check("cj.serialized: already-expired row skipped", !serRows.gone);
|
|
742
|
+
check("cj.serialized: invalid sameSite coerced to null", serRows.ss && serRows.ss.sameSite === null);
|
|
743
|
+
check("cj.serialized: valid sameSite preserved", serRows.ss2 && serRows.ss2.sameSite === "Lax");
|
|
744
|
+
check("cj.serialized: createdAt defaults to now when absent", serRows.ok.createdAt === nowRef.t);
|
|
745
|
+
|
|
746
|
+
// Round-trip getAll() → setFromSerialized() into a fresh jar.
|
|
747
|
+
var rtSrc = CJ.create();
|
|
748
|
+
rtSrc.setFromResponse("https://example.com/app", "rt=99; Secure; Path=/app; SameSite=Strict");
|
|
749
|
+
var rtDst = CJ.create();
|
|
750
|
+
rtDst.setFromSerialized(rtSrc.getAll());
|
|
751
|
+
check("cj.serialized: round-trip preserves the attaching cookie",
|
|
752
|
+
/rt=99/.test(rtDst.cookieHeaderFor("https://example.com/app") || ""));
|
|
753
|
+
|
|
754
|
+
// ---- vault persist: values sealed at rest, unsealed on read ----
|
|
755
|
+
var vJar = CJ.create({ persist: "vault", vault: _cjFakeVault() });
|
|
756
|
+
vJar.setFromResponse("http://example.com/", "secret=topsecretvalue");
|
|
757
|
+
var rawRows = vJar._storeForTest();
|
|
758
|
+
check("cj.vault: stored value is sealed (no plaintext at rest)",
|
|
759
|
+
rawRows.length === 1 && rawRows[0].valueRaw.indexOf("topsecretvalue") === -1 &&
|
|
760
|
+
/^SEAL\[/.test(rawRows[0].valueRaw));
|
|
761
|
+
check("cj.vault: getAll() unseals the value",
|
|
762
|
+
vJar.getAll()[0].value === "topsecretvalue");
|
|
763
|
+
check("cj.vault: cookieHeaderFor unseals the value",
|
|
764
|
+
/secret=topsecretvalue/.test(vJar.cookieHeaderFor("http://example.com/") || ""));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ---- cookie jar file persistence (real tmp dir, no network) --------
|
|
768
|
+
|
|
769
|
+
function testCookieJarFilePersist() {
|
|
770
|
+
var CJ = b.httpClient.cookieJar;
|
|
771
|
+
var fs = helpers.fs, os = helpers.os, path = helpers.path;
|
|
772
|
+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-cj-"));
|
|
773
|
+
var jars = [];
|
|
774
|
+
try {
|
|
775
|
+
var file = path.join(dir, "jar.json");
|
|
776
|
+
|
|
777
|
+
// Missing file on first construct is fine (no throw).
|
|
778
|
+
var j1 = CJ.create({ persist: "file", file: file });
|
|
779
|
+
jars.push(j1);
|
|
780
|
+
check("cj.file: missing file → empty jar on first run", j1.size() === 0);
|
|
781
|
+
j1.setFromResponse("http://example.com/", "fsid=filevalue; Path=/");
|
|
782
|
+
j1.flush(); // force the synchronous write (bypasses the debounce)
|
|
783
|
+
check("cj.file: flush() writes the persist file", fs.existsSync(file));
|
|
784
|
+
|
|
785
|
+
// A fresh jar over the same file loads the persisted cookie.
|
|
786
|
+
var j2 = CJ.create({ persist: "file", file: file });
|
|
787
|
+
jars.push(j2);
|
|
788
|
+
check("cj.file: reopened jar loads the persisted cookie",
|
|
789
|
+
/fsid=filevalue/.test(j2.cookieHeaderFor("http://example.com/") || ""));
|
|
790
|
+
|
|
791
|
+
// close() also flushes pending state.
|
|
792
|
+
var closeFile = path.join(dir, "close.json");
|
|
793
|
+
var j3 = CJ.create({ persist: "file", file: closeFile });
|
|
794
|
+
j3.setFromResponse("http://example.com/", "cs=1; Path=/");
|
|
795
|
+
j3.close();
|
|
796
|
+
check("cj.file: close() flushes to disk", fs.existsSync(closeFile));
|
|
797
|
+
var j3b = CJ.create({ persist: "file", file: closeFile });
|
|
798
|
+
jars.push(j3b);
|
|
799
|
+
check("cj.file: state persisted by close() reloads", j3b.size() === 1);
|
|
800
|
+
|
|
801
|
+
// Corrupt (non-JSON) persist file → LOAD_FAILED at construct.
|
|
802
|
+
var corruptFile = path.join(dir, "corrupt.json");
|
|
803
|
+
fs.writeFileSync(corruptFile, "{ this is not json", "utf8");
|
|
804
|
+
var loadErr = _cjExpectThrow("cj.file: corrupt persist file → LOAD_FAILED",
|
|
805
|
+
function () { CJ.create({ persist: "file", file: corruptFile }); }, "LOAD_FAILED");
|
|
806
|
+
check("cj.file: LOAD_FAILED names the offending file",
|
|
807
|
+
loadErr != null && loadErr.message.indexOf(corruptFile) !== -1);
|
|
808
|
+
|
|
809
|
+
// Vault-sealed persist file round-trips with the same vault; opening
|
|
810
|
+
// the sealed bytes without a vault fails to parse → LOAD_FAILED.
|
|
811
|
+
var sealedFile = path.join(dir, "sealed.json");
|
|
812
|
+
var vault = _cjFakeVault();
|
|
813
|
+
var sj = CJ.create({ persist: "file", file: sealedFile, vault: vault });
|
|
814
|
+
sj.setFromResponse("http://example.com/", "sk=sealed; Path=/");
|
|
815
|
+
sj.flush();
|
|
816
|
+
var onDisk = fs.readFileSync(sealedFile, "utf8");
|
|
817
|
+
check("cj.file: vault-sealed persist file has no plaintext cookie value",
|
|
818
|
+
onDisk.indexOf("sealed") === -1 && /^SEAL\[/.test(onDisk));
|
|
819
|
+
var sj2 = CJ.create({ persist: "file", file: sealedFile, vault: vault });
|
|
820
|
+
jars.push(sj2);
|
|
821
|
+
check("cj.file: vault-sealed file reloads with the same vault", sj2.size() === 1);
|
|
822
|
+
_cjExpectThrow("cj.file: sealed file opened without a vault → LOAD_FAILED",
|
|
823
|
+
function () { CJ.create({ persist: "file", file: sealedFile }); }, "LOAD_FAILED");
|
|
824
|
+
} finally {
|
|
825
|
+
for (var i = 0; i < jars.length; i++) { try { jars[i].close(); } catch (_e) {} }
|
|
826
|
+
try { helpers.fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ---- redirect following (h1) ---------------------------------------
|
|
831
|
+
|
|
832
|
+
async function testRedirects() {
|
|
833
|
+
// Simple same-origin chain to a 200.
|
|
834
|
+
await _withServer(function (req, res) {
|
|
835
|
+
if (req.url === "/a") { res.writeHead(302, { Location: "/b" }); res.end(); return; }
|
|
836
|
+
res.writeHead(200); res.end("final");
|
|
837
|
+
}, async function (base) {
|
|
838
|
+
var r = await b.httpClient.request({ url: base + "/a", maxRedirects: 3,
|
|
839
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
840
|
+
check("redirect: followed 302 to final 200", r.statusCode === 200 && r.body.toString() === "final");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// 303 coerces to GET and drops the body.
|
|
844
|
+
await _withServer(function (req, res) {
|
|
845
|
+
if (req.url === "/post") { res.writeHead(303, { Location: "/see" }); res.end(); return; }
|
|
846
|
+
var chunks = [];
|
|
847
|
+
req.on("data", function (c) { chunks.push(c); });
|
|
848
|
+
req.on("end", function () {
|
|
849
|
+
res.writeHead(200, { "x-method": req.method, "x-body-len": String(Buffer.concat(chunks).length) });
|
|
850
|
+
res.end("done");
|
|
851
|
+
});
|
|
852
|
+
}, async function (base) {
|
|
853
|
+
var r = await b.httpClient.request({ url: base + "/post", method: "POST", body: "payload",
|
|
854
|
+
maxRedirects: 2, allowedProtocols: ALLOW, allowInternal: true });
|
|
855
|
+
check("redirect 303: coerced to GET", r.headers["x-method"] === "GET");
|
|
856
|
+
check("redirect 303: body dropped", r.headers["x-body-len"] === "0");
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// 307 preserves method + body.
|
|
860
|
+
await _withServer(function (req, res) {
|
|
861
|
+
if (req.url === "/keep") { res.writeHead(307, { Location: "/echo" }); res.end(); return; }
|
|
862
|
+
var chunks = [];
|
|
863
|
+
req.on("data", function (c) { chunks.push(c); });
|
|
864
|
+
req.on("end", function () {
|
|
865
|
+
res.writeHead(200, { "x-method": req.method });
|
|
866
|
+
res.end(Buffer.concat(chunks));
|
|
867
|
+
});
|
|
868
|
+
}, async function (base) {
|
|
869
|
+
var r = await b.httpClient.request({ url: base + "/keep", method: "PUT", body: "keepme",
|
|
870
|
+
maxRedirects: 2, allowedProtocols: ALLOW, allowInternal: true });
|
|
871
|
+
check("redirect 307: method preserved", r.headers["x-method"] === "PUT");
|
|
872
|
+
check("redirect 307: body preserved", r.body.toString() === "keepme");
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// 3xx with no Location returned as-is.
|
|
876
|
+
await _withServer(function (req, res) { res.writeHead(302); res.end(); }, async function (base) {
|
|
877
|
+
var r = await b.httpClient.request({ url: base + "/noloc", maxRedirects: 3,
|
|
878
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
879
|
+
check("redirect: 3xx without Location returned to caller", r.statusCode === 302);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
// Invalid Location → BAD_REDIRECT.
|
|
883
|
+
await _withServer(function (req, res) {
|
|
884
|
+
res.writeHead(302, { Location: "http://[not a url" }); res.end();
|
|
885
|
+
}, async function (base) {
|
|
886
|
+
await _expectReject("redirect: invalid Location rejects BAD_REDIRECT",
|
|
887
|
+
b.httpClient.request({ url: base + "/bad", maxRedirects: 3,
|
|
888
|
+
allowedProtocols: ALLOW, allowInternal: true }), "BAD_REDIRECT");
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// Redirect budget exhausted → last 3xx returned.
|
|
892
|
+
await _withServer(function (req, res) {
|
|
893
|
+
var n = parseInt(req.url.slice(1), 10) || 0;
|
|
894
|
+
res.writeHead(302, { Location: "/" + (n + 1) }); res.end();
|
|
895
|
+
}, async function (base) {
|
|
896
|
+
var r = await b.httpClient.request({ url: base + "/0", maxRedirects: 2,
|
|
897
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
898
|
+
check("redirect: budget exhausted returns the last 3xx", r.statusCode === 302);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
// onRedirect sync throw → REDIRECT_ABORTED.
|
|
902
|
+
await _withServer(function (req, res) {
|
|
903
|
+
if (req.url === "/a") { res.writeHead(302, { Location: "/b" }); res.end(); return; }
|
|
904
|
+
res.writeHead(200); res.end("final");
|
|
905
|
+
}, async function (base) {
|
|
906
|
+
await _expectReject("onRedirect: sync throw aborts with REDIRECT_ABORTED",
|
|
907
|
+
b.httpClient.request({ url: base + "/a", maxRedirects: 3,
|
|
908
|
+
onRedirect: function () { throw new Error("no thanks"); },
|
|
909
|
+
allowedProtocols: ALLOW, allowInternal: true }), "REDIRECT_ABORTED");
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// onRedirect async (returns a promise) proceeds.
|
|
913
|
+
await _withServer(function (req, res) {
|
|
914
|
+
if (req.url === "/a") { res.writeHead(302, { Location: "/b" }); res.end(); return; }
|
|
915
|
+
res.writeHead(200); res.end("final");
|
|
916
|
+
}, async function (base) {
|
|
917
|
+
var seen = [];
|
|
918
|
+
var r = await b.httpClient.request({ url: base + "/a", maxRedirects: 3,
|
|
919
|
+
onRedirect: function (ev) { seen.push(ev.statusCode); return Promise.resolve(); },
|
|
920
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
921
|
+
check("onRedirect: async hook awaited then follow proceeds",
|
|
922
|
+
r.statusCode === 200 && seen[0] === 302);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// onRedirect async REJECT aborts with the same REDIRECT_ABORTED shape a sync
|
|
926
|
+
// throw produces — an operator who awaits inside the hook (or returns a
|
|
927
|
+
// rejected Promise) must get the same error.code as the sync path, not the
|
|
928
|
+
// raw un-coded rejection. Both the returned-rejected-Promise and the
|
|
929
|
+
// async-function-throws forms reach the same continuation.
|
|
930
|
+
await _withServer(function (req, res) {
|
|
931
|
+
if (req.url === "/a") { res.writeHead(302, { Location: "/b" }); res.end(); return; }
|
|
932
|
+
res.writeHead(200); res.end("final");
|
|
933
|
+
}, async function (base) {
|
|
934
|
+
var e1 = await _expectReject("onRedirect: returned-rejected-Promise aborts with REDIRECT_ABORTED",
|
|
935
|
+
b.httpClient.request({ url: base + "/a", maxRedirects: 3,
|
|
936
|
+
onRedirect: function () { return Promise.reject(new Error("async no")); },
|
|
937
|
+
allowedProtocols: ALLOW, allowInternal: true }), "REDIRECT_ABORTED");
|
|
938
|
+
check("onRedirect: async-reject message names the hook refusal",
|
|
939
|
+
e1 && /onRedirect hook refused redirect: async no/.test(e1.message));
|
|
940
|
+
await _expectReject("onRedirect: async-function throw aborts with REDIRECT_ABORTED",
|
|
941
|
+
b.httpClient.request({ url: base + "/a", maxRedirects: 3,
|
|
942
|
+
onRedirect: async function () { throw new Error("awaited no"); },
|
|
943
|
+
allowedProtocols: ALLOW, allowInternal: true }), "REDIRECT_ABORTED");
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ---- cross-origin auth-header strip on redirect --------------------
|
|
948
|
+
|
|
949
|
+
async function testCrossOriginStrip() {
|
|
950
|
+
var sawAuthOnB = "unset";
|
|
951
|
+
var sawCookieOnB = "unset";
|
|
952
|
+
var sawProxyAuthOnB = "unset";
|
|
953
|
+
var sawKeepOnB = "unset";
|
|
954
|
+
var bBase = null;
|
|
955
|
+
await _withTwoServers(
|
|
956
|
+
// A: redirect cross-origin to B's absolute URL (bBase set before any request).
|
|
957
|
+
function (req, res) { res.writeHead(302, { Location: bBase + "/landing" }); res.end(); },
|
|
958
|
+
// B: record whether the sensitive + non-sensitive headers survived the hop.
|
|
959
|
+
function (req, res) {
|
|
960
|
+
sawAuthOnB = req.headers.authorization || null;
|
|
961
|
+
sawCookieOnB = req.headers.cookie || null;
|
|
962
|
+
sawProxyAuthOnB = req.headers["proxy-authorization"] || null;
|
|
963
|
+
sawKeepOnB = req.headers["x-keep"] || null;
|
|
964
|
+
res.writeHead(200); res.end("b");
|
|
965
|
+
},
|
|
966
|
+
async function (baseA, baseB) {
|
|
967
|
+
bBase = baseB;
|
|
968
|
+
// Mixed-case sensitive header names exercise the lower-cased comparison
|
|
969
|
+
// in _stripCrossOriginAuth — every entry of SENSITIVE_HEADERS_LC
|
|
970
|
+
// (authorization / cookie / proxy-authorization) must drop on the hop.
|
|
971
|
+
var r = await b.httpClient.request({
|
|
972
|
+
url: baseA + "/start", maxRedirects: 2,
|
|
973
|
+
headers: {
|
|
974
|
+
Authorization: "Bearer secret-token",
|
|
975
|
+
Cookie: "sid=secret-session",
|
|
976
|
+
"Proxy-Authorization": "Basic c2VjcmV0",
|
|
977
|
+
"X-Keep": "1",
|
|
978
|
+
},
|
|
979
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
980
|
+
check("cross-origin redirect: reached origin B", r.statusCode === 200 && r.body.toString() === "b");
|
|
981
|
+
check("cross-origin redirect: Authorization stripped on hop to B", sawAuthOnB === null);
|
|
982
|
+
check("cross-origin redirect: Cookie stripped on hop to B", sawCookieOnB === null);
|
|
983
|
+
check("cross-origin redirect: Proxy-Authorization stripped on hop to B", sawProxyAuthOnB === null);
|
|
984
|
+
check("cross-origin redirect: non-sensitive header preserved", sawKeepOnB === "1");
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// ---- proxy short-circuit: cloud-metadata IP is never overridable ----
|
|
989
|
+
//
|
|
990
|
+
// When a proxy is configured AND the operator waives local SSRF defense with
|
|
991
|
+
// allowInternal:true, the DNS-resolution SSRF check is skipped (the proxy
|
|
992
|
+
// resolves in its own network context). But the TEXTUAL cloud-metadata-IP
|
|
993
|
+
// block still fires first — 169.254.169.254 (AWS/GCP/Azure/OpenStack/DO IMDS)
|
|
994
|
+
// is refused at the hostname layer so the proxy never receives the request,
|
|
995
|
+
// even with allowInternal + a proxy. network-proxy is a process-global; the
|
|
996
|
+
// same module instance backs http-client's `require("./network-proxy")`.
|
|
997
|
+
async function testProxyMetadataBlock() {
|
|
998
|
+
var networkProxy = require("../../lib/network-proxy");
|
|
999
|
+
networkProxy.set({ http: "http://127.0.0.1:9" }); // a proxy need not be reachable — the block fires before connect
|
|
1000
|
+
try {
|
|
1001
|
+
var err = await _expectReject(
|
|
1002
|
+
"proxy + allowInternal: cloud-metadata IP still refused at the textual layer",
|
|
1003
|
+
b.httpClient.request({ url: "http://169.254.169.254/latest/meta-data/",
|
|
1004
|
+
allowInternal: true, allowedProtocols: ALLOW }),
|
|
1005
|
+
/cloud-metadata/);
|
|
1006
|
+
check("proxy metadata block: rejection is the SSRF cloud-metadata refusal",
|
|
1007
|
+
err && err.code === "ssrf-guard/blocked-cloud-metadata");
|
|
1008
|
+
} finally {
|
|
1009
|
+
networkProxy._resetForTest();
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// ---- HTTP/2 (h2c) code path ----------------------------------------
|
|
1014
|
+
|
|
1015
|
+
async function testH2cPaths() {
|
|
1016
|
+
await _withH2cServer(function (stream, headers) {
|
|
1017
|
+
var path = headers[":path"];
|
|
1018
|
+
if (path === "/big") { stream.respond({ ":status": 200 }); stream.end(Buffer.alloc(3000, 0x64)); return; }
|
|
1019
|
+
if (path === "/err") { stream.respond({ ":status": 500 }); stream.end("boom"); return; }
|
|
1020
|
+
if (path === "/echo") {
|
|
1021
|
+
var chunks = [];
|
|
1022
|
+
stream.on("data", function (c) { chunks.push(c); });
|
|
1023
|
+
stream.on("end", function () {
|
|
1024
|
+
stream.respond({ ":status": 200, "content-type": "text/plain" });
|
|
1025
|
+
stream.end(Buffer.concat(chunks));
|
|
1026
|
+
});
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
stream.respond({ ":status": 200, "content-type": "text/plain" });
|
|
1030
|
+
stream.end("h2c-ok");
|
|
1031
|
+
}, async function (base) {
|
|
1032
|
+
var r = await b.httpClient.request({ url: base + "/x", preferH2: true,
|
|
1033
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1034
|
+
check("h2c: GET succeeds over prior-knowledge h2c",
|
|
1035
|
+
r.statusCode === 200 && r.body.toString() === "h2c-ok");
|
|
1036
|
+
check("h2c: transport cached as h2", b.httpClient._getCachedTransportKind(base + "/x") === "h2");
|
|
1037
|
+
|
|
1038
|
+
// non-2xx → HTTP_ERROR
|
|
1039
|
+
await _expectReject("h2c: non-2xx rejects HTTP_ERROR",
|
|
1040
|
+
b.httpClient.request({ url: base + "/err", preferH2: true,
|
|
1041
|
+
allowedProtocols: ALLOW, allowInternal: true }), "HTTP_ERROR");
|
|
1042
|
+
|
|
1043
|
+
// always-resolve returns the non-2xx structurally
|
|
1044
|
+
var ar = await b.httpClient.request({ url: base + "/err", preferH2: true,
|
|
1045
|
+
responseMode: "always-resolve", allowedProtocols: ALLOW, allowInternal: true });
|
|
1046
|
+
check("h2c: always-resolve returns 500 body", ar.statusCode === 500 && ar.body.toString() === "boom");
|
|
1047
|
+
|
|
1048
|
+
// oversized response → RESPONSE_TOO_LARGE
|
|
1049
|
+
await _expectReject("h2c: over-cap rejects RESPONSE_TOO_LARGE",
|
|
1050
|
+
b.httpClient.request({ url: base + "/big", preferH2: true, maxResponseBytes: 100,
|
|
1051
|
+
allowedProtocols: ALLOW, allowInternal: true }), "RESPONSE_TOO_LARGE");
|
|
1052
|
+
|
|
1053
|
+
// POST body echoes back
|
|
1054
|
+
var echo = await b.httpClient.request({ url: base + "/echo", method: "POST", body: "h2-body",
|
|
1055
|
+
preferH2: true, allowedProtocols: ALLOW, allowInternal: true });
|
|
1056
|
+
check("h2c: POST body delivered + echoed", echo.body.toString() === "h2-body");
|
|
1057
|
+
|
|
1058
|
+
// stream mode over h2c
|
|
1059
|
+
var s = await b.httpClient.request({ url: base + "/x", preferH2: true, responseMode: "stream",
|
|
1060
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1061
|
+
var drained = await new Promise(function (resolve, reject) {
|
|
1062
|
+
var cs = [];
|
|
1063
|
+
s.body.on("data", function (c) { cs.push(c); });
|
|
1064
|
+
s.body.on("end", function () { resolve(Buffer.concat(cs)); });
|
|
1065
|
+
s.body.on("error", reject);
|
|
1066
|
+
});
|
|
1067
|
+
check("h2c: stream-mode body drains to the full payload", drained.toString() === "h2c-ok");
|
|
1068
|
+
});
|
|
1069
|
+
b.httpClient._resetForTest();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// ---- multipart valid round-trip (buffer body path) -----------------
|
|
1073
|
+
|
|
1074
|
+
async function testMultipartValidRoundTrip() {
|
|
1075
|
+
var received = null;
|
|
1076
|
+
var contentType = null;
|
|
1077
|
+
var contentLength = null;
|
|
1078
|
+
await _withServer(function (req, res) {
|
|
1079
|
+
contentType = req.headers["content-type"];
|
|
1080
|
+
contentLength = req.headers["content-length"];
|
|
1081
|
+
var chunks = [];
|
|
1082
|
+
req.on("data", function (c) { chunks.push(c); });
|
|
1083
|
+
req.on("end", function () { received = Buffer.concat(chunks).toString("utf8"); res.writeHead(200); res.end("ok"); });
|
|
1084
|
+
}, async function (base) {
|
|
1085
|
+
var r = await b.httpClient.request({
|
|
1086
|
+
url: base + "/u",
|
|
1087
|
+
multipart: {
|
|
1088
|
+
fields: { title: "hello", tags: ["a", "b"] }, // array form → two parts
|
|
1089
|
+
files: [{ field: "doc", content: "FILEDATA", filename: "d.txt", contentType: "text/plain" }],
|
|
1090
|
+
},
|
|
1091
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1092
|
+
check("multipart: POST default method + 200", r.statusCode === 200);
|
|
1093
|
+
check("multipart: server saw multipart Content-Type", /^multipart\/form-data;\s*boundary=/i.test(contentType || ""));
|
|
1094
|
+
check("multipart: Content-Length set for all-buffer body", contentLength != null && Number(contentLength) > 0);
|
|
1095
|
+
check("multipart: array field emitted both values",
|
|
1096
|
+
/name="tags"/.test(received) && received.indexOf("a") !== -1 && received.indexOf("b") !== -1);
|
|
1097
|
+
check("multipart: file part carries content + filename",
|
|
1098
|
+
received.indexOf('name="doc"') !== -1 && received.indexOf("FILEDATA") !== -1 && received.indexOf('filename="d.txt"') !== -1);
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// ---- downloadStream maxBytes cap (defensive) -----------------------
|
|
1103
|
+
|
|
1104
|
+
async function testDownloadMaxBytes() {
|
|
1105
|
+
await _withServer(function (req, res) {
|
|
1106
|
+
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
|
1107
|
+
res.end(Buffer.alloc(4096, 0x65));
|
|
1108
|
+
}, async function (base) {
|
|
1109
|
+
var dir = b.testing.tempDir("httpclient-cov-dl");
|
|
1110
|
+
try {
|
|
1111
|
+
var dest = helpers.path.join(dir.path, "capped.bin");
|
|
1112
|
+
await _expectReject("downloadStream: over maxBytes rejects response-too-large",
|
|
1113
|
+
b.httpClient.downloadStream({
|
|
1114
|
+
url: base + "/big", dest: dest, maxBytes: 512,
|
|
1115
|
+
allowedProtocols: ALLOW, allowInternal: true }), "httpclient/response-too-large");
|
|
1116
|
+
check("downloadStream: over-cap left no dest file", !helpers.fs.existsSync(dest));
|
|
1117
|
+
} finally {
|
|
1118
|
+
dir.cleanup();
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ---- pinned-DNS lookup contract (unit) -----------------------------
|
|
1124
|
+
|
|
1125
|
+
function testPinnedLookupContract() {
|
|
1126
|
+
var lk = b.httpClient._pinnedLookupForTest([
|
|
1127
|
+
{ address: "10.9.8.7", family: 4 }, { address: "10.9.8.6", family: 4 }]);
|
|
1128
|
+
var allRes = null, singleAddr = null, singleFam = null, fnAddr = null;
|
|
1129
|
+
lk("h", { all: true }, function (err, addrs) { allRes = addrs; });
|
|
1130
|
+
lk("h", {}, function (err, addr, fam) { singleAddr = addr; singleFam = fam; });
|
|
1131
|
+
lk("h", function (err, addr) { fnAddr = addr; }); // options-as-callback shape
|
|
1132
|
+
check("pinnedLookup: all:true returns the full family list",
|
|
1133
|
+
Array.isArray(allRes) && allRes.length === 2 && allRes[0].address === "10.9.8.7");
|
|
1134
|
+
check("pinnedLookup: single returns the first address + family",
|
|
1135
|
+
singleAddr === "10.9.8.7" && singleFam === 4);
|
|
1136
|
+
check("pinnedLookup: options-as-callback shape resolves the first address", fnAddr === "10.9.8.7");
|
|
1137
|
+
check("pinnedLookup: empty / null ips yields undefined (no pinning)",
|
|
1138
|
+
b.httpClient._pinnedLookupForTest([]) === undefined &&
|
|
1139
|
+
b.httpClient._pinnedLookupForTest(null) === undefined);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// ---- multipart body build: injection rejects + streaming entry -----
|
|
1143
|
+
|
|
1144
|
+
async function testMultipartBuildBranches() {
|
|
1145
|
+
function mpReject(label, multipart) {
|
|
1146
|
+
return _expectReject(label,
|
|
1147
|
+
b.httpClient.request({ url: "https://x.example/", multipart: multipart }), "BAD_ARG");
|
|
1148
|
+
}
|
|
1149
|
+
await mpReject("multipart: CRLF in field name refused (header injection)",
|
|
1150
|
+
{ fields: { "a\r\nInjected: 1": "v" } });
|
|
1151
|
+
await mpReject("multipart: empty field name refused",
|
|
1152
|
+
{ fields: { "": "v" } });
|
|
1153
|
+
await mpReject("multipart: CRLF in filename refused (header injection)",
|
|
1154
|
+
{ files: [{ field: "f", content: "x", filename: "a\r\nX-Injected: 1" }] });
|
|
1155
|
+
await mpReject("multipart: CRLF in contentType refused (header injection)",
|
|
1156
|
+
{ files: [{ field: "f", content: "x", contentType: "text/plain\r\nX-Injected: 1" }] });
|
|
1157
|
+
await mpReject("multipart: non-object file entry refused",
|
|
1158
|
+
{ files: [42] });
|
|
1159
|
+
await mpReject("multipart: file entry missing field refused",
|
|
1160
|
+
{ files: [{ content: "x" }] });
|
|
1161
|
+
await mpReject("multipart: file entry with two sources refused",
|
|
1162
|
+
{ files: [{ field: "f", content: "x", filePath: "/nope" }] });
|
|
1163
|
+
await mpReject("multipart: non-buffer/string content refused",
|
|
1164
|
+
{ files: [{ field: "f", content: 42 }] });
|
|
1165
|
+
// filePath-only entry pointing at a path that can't be stat'd → the build
|
|
1166
|
+
// throws before any network work, surfaced as BAD_ARG.
|
|
1167
|
+
await mpReject("multipart: unreadable filePath entry refused",
|
|
1168
|
+
{ files: [{ field: "f", filePath: helpers.path.join("nope-no-such-dir", "missing-" + Date.now() + ".bin") }] });
|
|
1169
|
+
|
|
1170
|
+
// filePath-only entry pointing at a directory (stat succeeds, not a regular
|
|
1171
|
+
// file) → BAD_ARG.
|
|
1172
|
+
var dirEntryDir = b.testing.tempDir("httpclient-cov-mpdir");
|
|
1173
|
+
try {
|
|
1174
|
+
await mpReject("multipart: directory filePath entry refused (not a regular file)",
|
|
1175
|
+
{ files: [{ field: "f", filePath: dirEntryDir.path }] });
|
|
1176
|
+
} finally {
|
|
1177
|
+
dirEntryDir.cleanup();
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Valid: a content file WITHOUT filename defaults to "blob".
|
|
1181
|
+
var body = null;
|
|
1182
|
+
await _withServer(function (req, res) {
|
|
1183
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
1184
|
+
req.on("end", function () { body = Buffer.concat(chunks).toString("utf8"); res.writeHead(200); res.end("ok"); });
|
|
1185
|
+
}, async function (base) {
|
|
1186
|
+
var r = await b.httpClient.request({ url: base + "/mp",
|
|
1187
|
+
multipart: { files: [{ field: "f", content: "PAYLOAD" }] },
|
|
1188
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1189
|
+
check("multipart: content file without a filename defaults to blob",
|
|
1190
|
+
r.statusCode === 200 && /filename="blob"/.test(body || ""));
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
// Valid: an operator-supplied Readable stream entry (unknown size) →
|
|
1194
|
+
// Content-Length omitted, chunked transfer, iterator streams the bytes.
|
|
1195
|
+
var got = null, hadCL = "unset", te = "unset";
|
|
1196
|
+
await _withServer(function (req, res) {
|
|
1197
|
+
hadCL = req.headers["content-length"] || null;
|
|
1198
|
+
te = req.headers["transfer-encoding"] || null;
|
|
1199
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
1200
|
+
req.on("end", function () { got = Buffer.concat(chunks).toString("utf8"); res.writeHead(200); res.end("ok"); });
|
|
1201
|
+
}, async function (base) {
|
|
1202
|
+
var src = nodeStream.Readable.from([Buffer.from("STREAM-ENTRY-DATA")]);
|
|
1203
|
+
var r = await b.httpClient.request({ url: base + "/mp",
|
|
1204
|
+
multipart: { fields: { a: "1" }, files: [{ field: "f", stream: src, filename: "s.bin" }] },
|
|
1205
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1206
|
+
check("multipart: stream entry (unknown size) omits Content-Length + chunks",
|
|
1207
|
+
r.statusCode === 200 && hadCL === null && te === "chunked");
|
|
1208
|
+
check("multipart: stream entry body reached the server",
|
|
1209
|
+
got != null && got.indexOf("STREAM-ENTRY-DATA") !== -1);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
// Valid: an operator-supplied stream entry WITH a declared `size` → the
|
|
1213
|
+
// framework can statically resolve the total body length, so Content-Length
|
|
1214
|
+
// is set and the transfer is NOT chunked (the sizeKnown branch).
|
|
1215
|
+
var sizedBody = null, sizedCL = "unset", sizedTE = "unset";
|
|
1216
|
+
await _withServer(function (req, res) {
|
|
1217
|
+
sizedCL = req.headers["content-length"] || null;
|
|
1218
|
+
sizedTE = req.headers["transfer-encoding"] || null;
|
|
1219
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
1220
|
+
req.on("end", function () { sizedBody = Buffer.concat(chunks).toString("utf8"); res.writeHead(200); res.end("ok"); });
|
|
1221
|
+
}, async function (base) {
|
|
1222
|
+
var payload = Buffer.from("STREAM-SIZED-ENTRY-DATA");
|
|
1223
|
+
var src = nodeStream.Readable.from([payload]);
|
|
1224
|
+
var r = await b.httpClient.request({ url: base + "/mp",
|
|
1225
|
+
multipart: { files: [{ field: "f", stream: src, size: payload.length, filename: "s.bin" }] },
|
|
1226
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1227
|
+
check("multipart: sized stream entry sets Content-Length + is not chunked",
|
|
1228
|
+
r.statusCode === 200 && sizedCL != null && Number(sizedCL) > 0 && sizedTE === null);
|
|
1229
|
+
check("multipart: sized stream entry body reached the server",
|
|
1230
|
+
sizedBody != null && sizedBody.indexOf("STREAM-SIZED-ENTRY-DATA") !== -1);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// Valid: a filePath entry WITHOUT an explicit filename → path.basename.
|
|
1234
|
+
var fpBody = null;
|
|
1235
|
+
await _withServer(function (req, res) {
|
|
1236
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
1237
|
+
req.on("end", function () { fpBody = Buffer.concat(chunks).toString("utf8"); res.writeHead(200); res.end("ok"); });
|
|
1238
|
+
}, async function (base) {
|
|
1239
|
+
var dir = b.testing.tempDir("httpclient-cov-fpn");
|
|
1240
|
+
try {
|
|
1241
|
+
var fp = helpers.path.join(dir.path, "the-name.dat");
|
|
1242
|
+
helpers.fs.writeFileSync(fp, "FILEPATH-DATA");
|
|
1243
|
+
var r = await b.httpClient.request({ url: base + "/mp",
|
|
1244
|
+
multipart: { files: [{ field: "f", filePath: fp }] },
|
|
1245
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1246
|
+
check("multipart: filePath entry without filename defaults to path.basename",
|
|
1247
|
+
r.statusCode === 200 && /filename="the-name\.dat"/.test(fpBody || ""));
|
|
1248
|
+
} finally {
|
|
1249
|
+
dir.cleanup();
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// ---- jar merges with a caller-supplied Cookie header ---------------
|
|
1255
|
+
|
|
1256
|
+
async function testJarCookieMerge() {
|
|
1257
|
+
var jar = b.httpClient.cookieJar.create();
|
|
1258
|
+
var saw = null;
|
|
1259
|
+
await _withServer(function (req, res) {
|
|
1260
|
+
saw = req.headers.cookie || null;
|
|
1261
|
+
res.writeHead(200, { "set-cookie": "sid=z9; Path=/" });
|
|
1262
|
+
res.end("ok");
|
|
1263
|
+
}, async function (base) {
|
|
1264
|
+
// Seed the jar from a first response.
|
|
1265
|
+
await b.httpClient.request({ url: base + "/", jar: jar, allowedProtocols: ALLOW, allowInternal: true });
|
|
1266
|
+
// Second request supplies its OWN Cookie header — the jar supplements it.
|
|
1267
|
+
await b.httpClient.request({ url: base + "/", jar: jar,
|
|
1268
|
+
headers: { Cookie: "caller=1" }, allowedProtocols: ALLOW, allowInternal: true });
|
|
1269
|
+
check("jar: caller Cookie header merged with the jar cookie (both present)",
|
|
1270
|
+
/caller=1/.test(saw || "") && /sid=z9/.test(saw || ""));
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ---- stream-mode non-2xx error body (h1) ---------------------------
|
|
1275
|
+
|
|
1276
|
+
async function testStreamModeHttpError() {
|
|
1277
|
+
// Small error body: rejects HTTP_ERROR with a bounded err.body prefix.
|
|
1278
|
+
await _withServer(function (req, res) {
|
|
1279
|
+
res.writeHead(404, { "Content-Type": "application/problem+json" });
|
|
1280
|
+
res.end('{"detail":"missing"}');
|
|
1281
|
+
}, async function (base) {
|
|
1282
|
+
var err = await _expectReject("stream 404: rejects HTTP_ERROR",
|
|
1283
|
+
b.httpClient.request({ url: base + "/x", responseMode: "stream",
|
|
1284
|
+
allowedProtocols: ALLOW, allowInternal: true }), "HTTP_ERROR");
|
|
1285
|
+
check("stream 404: err.body carries the error payload prefix",
|
|
1286
|
+
err && Buffer.isBuffer(err.body) && /missing/.test(err.body.toString("utf8")));
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
// Large error body: the collector fills its 16 KiB cap, destroys the
|
|
1290
|
+
// stream, and still rejects (doesn't hang on a slow / large error body).
|
|
1291
|
+
await _withServer(function (req, res) {
|
|
1292
|
+
res.writeHead(413, { "Content-Type": "text/plain" });
|
|
1293
|
+
res.end(Buffer.alloc(40000, 0x7a));
|
|
1294
|
+
}, async function (base) {
|
|
1295
|
+
var err = await _expectReject("stream 413: oversized error body still rejects HTTP_ERROR",
|
|
1296
|
+
b.httpClient.request({ url: base + "/big", responseMode: "stream",
|
|
1297
|
+
allowedProtocols: ALLOW, allowInternal: true }), "HTTP_ERROR");
|
|
1298
|
+
check("stream 413: err.body prefix capped at 16 KiB",
|
|
1299
|
+
err && Buffer.isBuffer(err.body) && err.body.length <= 16384 && err.body.length > 0);
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// ---- bandwidth throttle + transform interpose (h1) -----------------
|
|
1304
|
+
|
|
1305
|
+
async function testThrottleAndTransformH1() {
|
|
1306
|
+
var payload = Buffer.alloc(64, 0x71);
|
|
1307
|
+
await _withServer(function (req, res) {
|
|
1308
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
1309
|
+
req.on("end", function () { res.writeHead(200); res.end(payload); });
|
|
1310
|
+
}, async function (base) {
|
|
1311
|
+
var dlSeen = 0, ulSeen = 0, chunkSeen = 0;
|
|
1312
|
+
// Upload body larger than the per-second budget forces the token-bucket
|
|
1313
|
+
// to stall at least once (exercises the refill / setTimeout wait branch).
|
|
1314
|
+
var upBody = Buffer.alloc(1500, 0x72);
|
|
1315
|
+
var res = await b.httpClient.request({
|
|
1316
|
+
url: base + "/t", method: "POST", body: upBody,
|
|
1317
|
+
maxBytesPerSec: 1024,
|
|
1318
|
+
downloadTransform: function () { return new nodeStream.PassThrough(); }, // factory form
|
|
1319
|
+
uploadTransform: new nodeStream.PassThrough(), // instance form
|
|
1320
|
+
onUploadProgress: function (p) { ulSeen = p.loaded; },
|
|
1321
|
+
onDownloadProgress: function (p) { dlSeen = p.loaded; },
|
|
1322
|
+
onChunk: function (c) { chunkSeen += c.length; },
|
|
1323
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1324
|
+
check("throttle h1: response intact through the throttle + transform chain",
|
|
1325
|
+
res.body.equals(payload));
|
|
1326
|
+
check("throttle h1: upload progress summed to the body length", ulSeen === 1500);
|
|
1327
|
+
check("throttle h1: download progress + onChunk observed the full payload",
|
|
1328
|
+
dlSeen === payload.length && chunkSeen === payload.length);
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// ---- upload progress on a piped Readable body (no throttle) --------
|
|
1333
|
+
|
|
1334
|
+
async function testUploadProgressPipedBody() {
|
|
1335
|
+
await _withServer(function (req, res) {
|
|
1336
|
+
req.on("data", function () {}); req.on("end", function () { res.writeHead(200); res.end("ok"); });
|
|
1337
|
+
}, async function (base) {
|
|
1338
|
+
var total = 0;
|
|
1339
|
+
var src = nodeStream.Readable.from([Buffer.from("AAAA"), Buffer.from("BBBB")]);
|
|
1340
|
+
var res = await b.httpClient.request({ url: base + "/u", method: "POST", body: src,
|
|
1341
|
+
onUploadProgress: function (p) { total = p.loaded; },
|
|
1342
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1343
|
+
check("upload progress (piped Readable): summed bytes across data events",
|
|
1344
|
+
res.statusCode === 200 && total === 8);
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// ---- onChunk hook that throws is drop-silent (h1 + h2) -------------
|
|
1349
|
+
|
|
1350
|
+
async function testOnChunkThrowDropSilent() {
|
|
1351
|
+
var payload = Buffer.from("ONCHUNK-DROP-SILENT-PAYLOAD");
|
|
1352
|
+
// h1: a throwing onChunk must not break the buffered read — the observe
|
|
1353
|
+
// Transform swallows the operator-hook throw and the body arrives intact.
|
|
1354
|
+
await _withServer(function (req, res) { res.writeHead(200); res.end(payload); },
|
|
1355
|
+
async function (base) {
|
|
1356
|
+
var r = await b.httpClient.request({ url: base + "/x",
|
|
1357
|
+
onChunk: function () { throw new Error("onChunk boom"); },
|
|
1358
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1359
|
+
check("onChunk throw (h1): request still resolves with the full body",
|
|
1360
|
+
r.statusCode === 200 && r.body.equals(payload));
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
// h2: same drop-silent contract over the h2 pipeline.
|
|
1364
|
+
await _withH2cServer(function (stream) {
|
|
1365
|
+
stream.on("error", function () {});
|
|
1366
|
+
stream.respond({ ":status": 200 }); stream.end(payload);
|
|
1367
|
+
}, async function (base) {
|
|
1368
|
+
var r = await b.httpClient.request({ url: base + "/x", preferH2: true,
|
|
1369
|
+
onChunk: function () { throw new Error("onChunk boom h2"); },
|
|
1370
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1371
|
+
check("onChunk throw (h2): request still resolves with the full body",
|
|
1372
|
+
r.statusCode === 200 && r.body.equals(payload));
|
|
1373
|
+
});
|
|
1374
|
+
b.httpClient._resetForTest();
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// ---- download-transform error surfaces on the buffered read (h1) ---
|
|
1378
|
+
|
|
1379
|
+
async function testDownloadTransformErrorH1() {
|
|
1380
|
+
await _withServer(function (req, res) { res.writeHead(200); res.end(Buffer.alloc(64, 1)); },
|
|
1381
|
+
async function (base) {
|
|
1382
|
+
await _expectReject("h1 buffer-mode download-transform error rejects RES_ERROR",
|
|
1383
|
+
b.httpClient.request({ url: base + "/x",
|
|
1384
|
+
downloadTransform: function () {
|
|
1385
|
+
return new nodeStream.Transform({ transform: function (c, e, cb) { cb(new Error("xform boom")); } });
|
|
1386
|
+
},
|
|
1387
|
+
allowedProtocols: ALLOW, allowInternal: true }), "RES_ERROR");
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// ---- downloadStream lifecycle: success / verify / mismatch / non-2xx
|
|
1392
|
+
|
|
1393
|
+
async function testDownloadStreamLifecycle() {
|
|
1394
|
+
// Config-time validation rejects (async — the opts shape is checked first).
|
|
1395
|
+
await _expectReject("downloadStream: non-object opts rejects bad-opts",
|
|
1396
|
+
b.httpClient.downloadStream(1), "httpclient/bad-opts");
|
|
1397
|
+
await _expectReject("downloadStream: unknown hash alg rejects",
|
|
1398
|
+
b.httpClient.downloadStream({ url: "https://x/", dest: "/x", hash: "md5" }), /hash must be one of/);
|
|
1399
|
+
await _expectReject("downloadStream: non-hex expected digest rejects",
|
|
1400
|
+
b.httpClient.downloadStream({ url: "https://x/", dest: "/x", expected: "nothex!!" }), /hex digest/);
|
|
1401
|
+
|
|
1402
|
+
var payload = Buffer.from("DOWNLOAD-STREAM-PAYLOAD-abc123");
|
|
1403
|
+
await _withServer(function (req, res) {
|
|
1404
|
+
if (req.url === "/redir") { res.writeHead(302, { Location: "/elsewhere" }); res.end(); return; }
|
|
1405
|
+
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
|
1406
|
+
res.end(payload);
|
|
1407
|
+
}, async function (base) {
|
|
1408
|
+
var dir = b.testing.tempDir("httpclient-cov-dllife");
|
|
1409
|
+
try {
|
|
1410
|
+
// Success (no expected): file lands, hash + bytesWritten returned.
|
|
1411
|
+
var dest1 = helpers.path.join(dir.path, "a.bin");
|
|
1412
|
+
var d1 = await b.httpClient.downloadStream({ url: base + "/f", dest: dest1,
|
|
1413
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1414
|
+
check("downloadStream: success returns statusCode / bytesWritten / hash",
|
|
1415
|
+
d1.statusCode === 200 && d1.bytesWritten === payload.length &&
|
|
1416
|
+
typeof d1.hash === "string" && d1.hash.length > 0);
|
|
1417
|
+
check("downloadStream: success created the dest file", helpers.fs.existsSync(dest1));
|
|
1418
|
+
|
|
1419
|
+
// Verified: expected === the first download's hash → renamed into place.
|
|
1420
|
+
var dest2 = helpers.path.join(dir.path, "b.bin");
|
|
1421
|
+
var d2 = await b.httpClient.downloadStream({ url: base + "/f", dest: dest2, expected: d1.hash,
|
|
1422
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1423
|
+
check("downloadStream: matching expected hash completes the download",
|
|
1424
|
+
d2.statusCode === 200 && helpers.fs.existsSync(dest2));
|
|
1425
|
+
|
|
1426
|
+
// Mismatch: wrong expected → hash-mismatch, tmp removed, no dest.
|
|
1427
|
+
var dest3 = helpers.path.join(dir.path, "c.bin");
|
|
1428
|
+
var audit = _mkAuditCapture();
|
|
1429
|
+
await _expectReject("downloadStream: wrong expected hash rejects hash-mismatch",
|
|
1430
|
+
b.httpClient.downloadStream({ url: base + "/f", dest: dest3, expected: "deadbeefcafe",
|
|
1431
|
+
audit: audit, allowedProtocols: ALLOW, allowInternal: true }), "httpclient/hash-mismatch");
|
|
1432
|
+
check("downloadStream: mismatch left no dest file", !helpers.fs.existsSync(dest3));
|
|
1433
|
+
check("downloadStream: mismatch emitted a refused audit event",
|
|
1434
|
+
audit.events.some(function (e) { return e.action === "system.httpclient.download_stream.refused"; }));
|
|
1435
|
+
|
|
1436
|
+
// 3xx slips through stream mode (no redirect follow) → http-error.
|
|
1437
|
+
var dest4 = helpers.path.join(dir.path, "d.bin");
|
|
1438
|
+
var err = await _expectReject("downloadStream: 3xx upstream rejects http-error",
|
|
1439
|
+
b.httpClient.downloadStream({ url: base + "/redir", dest: dest4,
|
|
1440
|
+
allowedProtocols: ALLOW, allowInternal: true }), "httpclient/http-error");
|
|
1441
|
+
check("downloadStream: http-error carries the upstream status", err && err.statusCode === 302);
|
|
1442
|
+
|
|
1443
|
+
// Rename failure: point dest at an existing directory so the atomic
|
|
1444
|
+
// tmp→dest rename fails; the tmp is cleaned + a refused audit emits.
|
|
1445
|
+
var destDir = helpers.path.join(dir.path, "adir");
|
|
1446
|
+
helpers.fs.mkdirSync(destDir);
|
|
1447
|
+
var raudit = _mkAuditCapture();
|
|
1448
|
+
await _expectReject("downloadStream: rename onto a directory rejects rename-failed",
|
|
1449
|
+
b.httpClient.downloadStream({ url: base + "/f", dest: destDir, audit: raudit,
|
|
1450
|
+
allowedProtocols: ALLOW, allowInternal: true }), "httpclient/rename-failed");
|
|
1451
|
+
check("downloadStream: rename failure emitted a refused audit event",
|
|
1452
|
+
raudit.events.some(function (e) {
|
|
1453
|
+
return e.action === "system.httpclient.download_stream.refused" && e.metadata.reason === "rename-failed";
|
|
1454
|
+
}));
|
|
1455
|
+
} finally {
|
|
1456
|
+
dir.cleanup();
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
// request-failed (connection refused) → refused audit + rethrow.
|
|
1461
|
+
var failDir = b.testing.tempDir("httpclient-cov-dlfail");
|
|
1462
|
+
try {
|
|
1463
|
+
var audit2 = _mkAuditCapture();
|
|
1464
|
+
b.httpClient._resetForTest();
|
|
1465
|
+
var e = null;
|
|
1466
|
+
try {
|
|
1467
|
+
await b.httpClient.downloadStream({ url: "http://127.0.0.1:1/x",
|
|
1468
|
+
dest: helpers.path.join(failDir.path, "x.bin"),
|
|
1469
|
+
audit: audit2, allowedProtocols: ALLOW, allowInternal: true });
|
|
1470
|
+
} catch (err) { e = err; }
|
|
1471
|
+
check("downloadStream: connection failure rethrows + audits refused (request-failed)",
|
|
1472
|
+
e != null && audit2.events.some(function (x) {
|
|
1473
|
+
return x.action === "system.httpclient.download_stream.refused" && x.metadata.reason === "request-failed";
|
|
1474
|
+
}));
|
|
1475
|
+
} finally {
|
|
1476
|
+
failDir.cleanup();
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// ---- uploadMultipartStream lifecycle -------------------------------
|
|
1481
|
+
|
|
1482
|
+
async function testUploadMultipartStreamLifecycle() {
|
|
1483
|
+
// Config-time validation rejects (async — the opts shape is checked first).
|
|
1484
|
+
await _expectReject("uploadMultipartStream: non-object opts rejects bad-opts",
|
|
1485
|
+
b.httpClient.uploadMultipartStream(1), "httpclient/bad-opts");
|
|
1486
|
+
await _expectReject("uploadMultipartStream: missing file object rejects",
|
|
1487
|
+
b.httpClient.uploadMultipartStream({ url: "https://x/" }), /file must be an object/);
|
|
1488
|
+
await _expectReject("uploadMultipartStream: non-object fields rejects",
|
|
1489
|
+
b.httpClient.uploadMultipartStream({ url: "https://x/", file: { path: "/p", fieldName: "f" }, fields: [1] }), /fields must be an object/);
|
|
1490
|
+
|
|
1491
|
+
var dir = b.testing.tempDir("httpclient-cov-upload");
|
|
1492
|
+
try {
|
|
1493
|
+
var src = helpers.path.join(dir.path, "artifact.txt");
|
|
1494
|
+
helpers.fs.writeFileSync(src, "UPLOAD-STREAM-BODY");
|
|
1495
|
+
|
|
1496
|
+
var sawCT = null, sawBodyLen = 0;
|
|
1497
|
+
await _withServer(function (req, res) {
|
|
1498
|
+
sawCT = req.headers["content-type"] || null;
|
|
1499
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
1500
|
+
req.on("end", function () { sawBodyLen = Buffer.concat(chunks).length; res.writeHead(200); res.end("stored"); });
|
|
1501
|
+
}, async function (base) {
|
|
1502
|
+
var audit = _mkAuditCapture();
|
|
1503
|
+
var r = await b.httpClient.uploadMultipartStream({
|
|
1504
|
+
url: base + "/up",
|
|
1505
|
+
file: { path: src, fieldName: "artifact", contentType: "text/plain" },
|
|
1506
|
+
fields: { tag: "v1" }, audit: audit,
|
|
1507
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1508
|
+
check("uploadMultipartStream: success returns statusCode + response",
|
|
1509
|
+
r.statusCode === 200 && r.response && r.response.body.toString() === "stored");
|
|
1510
|
+
check("uploadMultipartStream: server saw a multipart Content-Type + a body",
|
|
1511
|
+
/^multipart\/form-data;\s*boundary=/i.test(sawCT || "") && sawBodyLen > 0);
|
|
1512
|
+
check("uploadMultipartStream: completed audit emitted",
|
|
1513
|
+
audit.events.some(function (e) { return e.action === "system.httpclient.upload_stream.completed"; }));
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
// Missing file path → missing-file.
|
|
1517
|
+
await _expectReject("uploadMultipartStream: missing file rejects missing-file",
|
|
1518
|
+
b.httpClient.uploadMultipartStream({ url: "https://x.example/up",
|
|
1519
|
+
file: { path: helpers.path.join(dir.path, "nope.txt"), fieldName: "f" },
|
|
1520
|
+
allowedProtocols: ALLOW, allowInternal: true }), "httpclient/missing-file");
|
|
1521
|
+
|
|
1522
|
+
// A directory path (not a regular file) → missing-file.
|
|
1523
|
+
await _expectReject("uploadMultipartStream: directory path rejects missing-file",
|
|
1524
|
+
b.httpClient.uploadMultipartStream({ url: "https://x.example/up",
|
|
1525
|
+
file: { path: dir.path, fieldName: "f" },
|
|
1526
|
+
allowedProtocols: ALLOW, allowInternal: true }), "httpclient/missing-file");
|
|
1527
|
+
|
|
1528
|
+
// Request failure (connection refused) → refused audit + rethrow.
|
|
1529
|
+
var audit2 = _mkAuditCapture();
|
|
1530
|
+
b.httpClient._resetForTest();
|
|
1531
|
+
var e = null;
|
|
1532
|
+
try {
|
|
1533
|
+
await b.httpClient.uploadMultipartStream({ url: "http://127.0.0.1:1/up",
|
|
1534
|
+
file: { path: src, fieldName: "f" }, audit: audit2,
|
|
1535
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1536
|
+
} catch (err) { e = err; }
|
|
1537
|
+
check("uploadMultipartStream: connection failure rethrows + audits refused (request-failed)",
|
|
1538
|
+
e != null && audit2.events.some(function (x) {
|
|
1539
|
+
return x.action === "system.httpclient.upload_stream.refused" && x.metadata.reason === "request-failed";
|
|
1540
|
+
}));
|
|
1541
|
+
} finally {
|
|
1542
|
+
dir.cleanup();
|
|
1543
|
+
b.httpClient._resetForTest();
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// ---- RFC 9111 outbound cache paths ---------------------------------
|
|
1548
|
+
|
|
1549
|
+
function _newHttpCache(extra) {
|
|
1550
|
+
var store = b.httpClient.cache.memoryStore({ maxBytes: 1048576, maxEntries: 64 });
|
|
1551
|
+
return b.httpClient.cache.create(Object.assign({ store: store }, extra || {}));
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
async function testCachePaths() {
|
|
1555
|
+
// Fresh HIT: max-age=60 → second request served from cache (no upstream hit).
|
|
1556
|
+
var hitCount = 0;
|
|
1557
|
+
await _withServer(function (req, res) {
|
|
1558
|
+
hitCount += 1;
|
|
1559
|
+
res.writeHead(200, { "Cache-Control": "max-age=60", "Content-Type": "text/plain" });
|
|
1560
|
+
res.end("fresh-body");
|
|
1561
|
+
}, async function (base) {
|
|
1562
|
+
var cache = _newHttpCache();
|
|
1563
|
+
var m = await b.httpClient.request({ url: base + "/hit", cache: cache, allowedProtocols: ALLOW, allowInternal: true });
|
|
1564
|
+
var h = await b.httpClient.request({ url: base + "/hit", cache: cache, allowedProtocols: ALLOW, allowInternal: true });
|
|
1565
|
+
check("cache: first request is a MISS, second a HIT",
|
|
1566
|
+
m.headers["x-blamejs-cache"] === "MISS" && h.headers["x-blamejs-cache"] === "HIT");
|
|
1567
|
+
check("cache: HIT served without a second upstream fetch", hitCount === 1);
|
|
1568
|
+
check("cache: HIT carries the stored body + an Age header",
|
|
1569
|
+
h.body.toString() === "fresh-body" && typeof h.headers.age === "string");
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
// Inline revalidation → 304 Not Modified → REVALIDATED.
|
|
1573
|
+
await _withServer(function (req, res) {
|
|
1574
|
+
if (req.headers["if-none-match"] === '"v1"') { res.writeHead(304, { ETag: '"v1"' }); res.end(); return; }
|
|
1575
|
+
res.writeHead(200, { "Cache-Control": "max-age=0", "ETag": '"v1"' });
|
|
1576
|
+
res.end("revalidate-body");
|
|
1577
|
+
}, async function (base) {
|
|
1578
|
+
var cache = _newHttpCache();
|
|
1579
|
+
await b.httpClient.request({ url: base + "/rev", cache: cache, allowedProtocols: ALLOW, allowInternal: true });
|
|
1580
|
+
var r = await b.httpClient.request({ url: base + "/rev", cache: cache, allowedProtocols: ALLOW, allowInternal: true });
|
|
1581
|
+
check("cache: stale-then-304 marks REVALIDATED + keeps the stored body",
|
|
1582
|
+
r.headers["x-blamejs-cache"] === "REVALIDATED" && r.body.toString() === "revalidate-body");
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
// Inline revalidation → 200 fresh response → MISS (store replaced).
|
|
1586
|
+
var n = 0;
|
|
1587
|
+
await _withServer(function (req, res) {
|
|
1588
|
+
n += 1;
|
|
1589
|
+
res.writeHead(200, { "Cache-Control": "max-age=0", "ETag": '"e' + n + '"' });
|
|
1590
|
+
res.end("v" + n);
|
|
1591
|
+
}, async function (base) {
|
|
1592
|
+
var cache = _newHttpCache();
|
|
1593
|
+
await b.httpClient.request({ url: base + "/fr", cache: cache, allowedProtocols: ALLOW, allowInternal: true });
|
|
1594
|
+
var r = await b.httpClient.request({ url: base + "/fr", cache: cache, allowedProtocols: ALLOW, allowInternal: true });
|
|
1595
|
+
check("cache: revalidation returning 200 is a fresh MISS with the new body",
|
|
1596
|
+
r.headers["x-blamejs-cache"] === "MISS" && r.body.toString() === "v2");
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
// stale-while-revalidate: serve STALE immediately + background refresh.
|
|
1600
|
+
var swrHits = 0;
|
|
1601
|
+
await _withServer(function (req, res) {
|
|
1602
|
+
swrHits += 1;
|
|
1603
|
+
res.writeHead(200, { "Cache-Control": "max-age=0, stale-while-revalidate=60", "ETag": '"s' + swrHits + '"' });
|
|
1604
|
+
res.end("swr" + swrHits);
|
|
1605
|
+
}, async function (base) {
|
|
1606
|
+
var cache = _newHttpCache({ revalidateInBackground: true });
|
|
1607
|
+
await b.httpClient.request({ url: base + "/swr", cache: cache, allowedProtocols: ALLOW, allowInternal: true }); // MISS + store
|
|
1608
|
+
var s = await b.httpClient.request({ url: base + "/swr", cache: cache, allowedProtocols: ALLOW, allowInternal: true }); // STALE served
|
|
1609
|
+
check("cache: stale-while-revalidate serves STALE immediately",
|
|
1610
|
+
s.headers["x-blamejs-cache"] === "STALE" && s.body.toString() === "swr1");
|
|
1611
|
+
// Background revalidation fires async — wait for the upstream to see it
|
|
1612
|
+
// so the fire-and-forget request completes inside the server's lifetime.
|
|
1613
|
+
await helpers.waitUntil(function () { return swrHits >= 2; },
|
|
1614
|
+
{ timeoutMs: 5000, label: "cache swr: background revalidation reached upstream" });
|
|
1615
|
+
check("cache: swr background revalidation refreshed from upstream", swrHits >= 2);
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
// stale-if-error: revalidation fails → serve STALE within the SIE window.
|
|
1619
|
+
await _withServer(function (req, res) {
|
|
1620
|
+
if (req.headers["if-none-match"]) { req.destroy(); return; } // fail the revalidation
|
|
1621
|
+
res.writeHead(200, { "Cache-Control": "max-age=0, stale-if-error=60", "ETag": '"sie"' });
|
|
1622
|
+
res.end("sie-body");
|
|
1623
|
+
}, async function (base) {
|
|
1624
|
+
var cache = _newHttpCache();
|
|
1625
|
+
await b.httpClient.request({ url: base + "/sie", cache: cache, allowedProtocols: ALLOW, allowInternal: true }); // MISS + store
|
|
1626
|
+
var r = await b.httpClient.request({ url: base + "/sie", cache: cache, allowedProtocols: ALLOW, allowInternal: true }); // reval errors → STALE
|
|
1627
|
+
check("cache: stale-if-error serves STALE when revalidation fails",
|
|
1628
|
+
r.headers["x-blamejs-cache"] === "STALE" && r.body.toString() === "sie-body");
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
// Revalidation error with NO stale-if-error window → the error propagates.
|
|
1632
|
+
await _withServer(function (req, res) {
|
|
1633
|
+
if (req.headers["if-none-match"]) { req.destroy(); return; } // fail the revalidation
|
|
1634
|
+
res.writeHead(200, { "Cache-Control": "max-age=0", "ETag": '"z"' });
|
|
1635
|
+
res.end("zbody");
|
|
1636
|
+
}, async function (base) {
|
|
1637
|
+
var cache = _newHttpCache();
|
|
1638
|
+
await b.httpClient.request({ url: base + "/z", cache: cache, allowedProtocols: ALLOW, allowInternal: true }); // MISS + store
|
|
1639
|
+
var e = null;
|
|
1640
|
+
try { await b.httpClient.request({ url: base + "/z", cache: cache, allowedProtocols: ALLOW, allowInternal: true }); }
|
|
1641
|
+
catch (err) { e = err; }
|
|
1642
|
+
check("cache: revalidation error without a stale window propagates the error",
|
|
1643
|
+
e != null && /ECONNRESET|ECONNREFUSED|socket|REQ_ERROR|RES_ERROR/i.test((e.code || "") + " " + (e.message || "")));
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
// Cache MISS whose network fetch follows a redirect (the cache path threads
|
|
1647
|
+
// through the redirect-aware request, not the single-shot one).
|
|
1648
|
+
await _withServer(function (req, res) {
|
|
1649
|
+
if (req.url === "/start") { res.writeHead(302, { Location: "/final" }); res.end(); return; }
|
|
1650
|
+
res.writeHead(200, { "Cache-Control": "max-age=60" }); res.end("finalbody");
|
|
1651
|
+
}, async function (base) {
|
|
1652
|
+
var cache = _newHttpCache();
|
|
1653
|
+
var r = await b.httpClient.request({ url: base + "/start", cache: cache, maxRedirects: 3,
|
|
1654
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1655
|
+
check("cache: MISS follows a redirect through the cache path",
|
|
1656
|
+
r.statusCode === 200 && r.body.toString() === "finalbody" && r.headers["x-blamejs-cache"] === "MISS");
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
// stale-while-revalidate where the BACKGROUND revalidation itself errors —
|
|
1660
|
+
// the stale response is already returned; the failure is swallowed.
|
|
1661
|
+
var sawConditional = false;
|
|
1662
|
+
await _withServer(function (req, res) {
|
|
1663
|
+
if (req.headers["if-none-match"]) { sawConditional = true; req.destroy(); return; }
|
|
1664
|
+
res.writeHead(200, { "Cache-Control": "max-age=0, stale-while-revalidate=60", "ETag": '"sw"' });
|
|
1665
|
+
res.end("swbody");
|
|
1666
|
+
}, async function (base) {
|
|
1667
|
+
var cache = _newHttpCache({ revalidateInBackground: true });
|
|
1668
|
+
await b.httpClient.request({ url: base + "/s", cache: cache, allowedProtocols: ALLOW, allowInternal: true }); // MISS + store
|
|
1669
|
+
var s = await b.httpClient.request({ url: base + "/s", cache: cache, allowedProtocols: ALLOW, allowInternal: true }); // STALE served
|
|
1670
|
+
check("cache: swr still serves STALE when the background refresh fails",
|
|
1671
|
+
s.headers["x-blamejs-cache"] === "STALE");
|
|
1672
|
+
await helpers.waitUntil(function () { return sawConditional; },
|
|
1673
|
+
{ timeoutMs: 5000, label: "cache swr-error: background revalidation attempted" });
|
|
1674
|
+
check("cache: swr background revalidation was attempted (and its error swallowed)", sawConditional);
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
// Non-GET method bypasses the cache entirely (network every time).
|
|
1678
|
+
var posts = 0;
|
|
1679
|
+
await _withServer(function (req, res) { posts += 1; req.resume(); res.writeHead(200); res.end("p"); },
|
|
1680
|
+
async function (base) {
|
|
1681
|
+
var cache = _newHttpCache();
|
|
1682
|
+
await b.httpClient.request({ url: base + "/p", method: "POST", body: "x", cache: cache, allowedProtocols: ALLOW, allowInternal: true });
|
|
1683
|
+
await b.httpClient.request({ url: base + "/p", method: "POST", body: "x", cache: cache, allowedProtocols: ALLOW, allowInternal: true });
|
|
1684
|
+
check("cache: POST bypasses the cache (both reach upstream)", posts === 2);
|
|
1685
|
+
});
|
|
1686
|
+
b.httpClient._resetForTest();
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// ---- HTTP/2 (h2c) extended paths -----------------------------------
|
|
1690
|
+
|
|
1691
|
+
async function testH2cExtended() {
|
|
1692
|
+
await _withH2cServer(function (stream, headers) {
|
|
1693
|
+
stream.on("error", function () {}); // guard server-side stream errors on client reset
|
|
1694
|
+
var p = headers[":path"];
|
|
1695
|
+
if (p === "/hdr") {
|
|
1696
|
+
stream.respond({ ":status": 200,
|
|
1697
|
+
"x-saw-custom": headers["x-custom"] || "",
|
|
1698
|
+
"x-saw-connection": headers["connection"] || "absent", // connection-specific header must be stripped
|
|
1699
|
+
"x-saw-ae": headers["accept-encoding"] || "" });
|
|
1700
|
+
stream.end("ok");
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (p === "/echo") {
|
|
1704
|
+
var chunks = []; stream.on("data", function (c) { chunks.push(c); });
|
|
1705
|
+
stream.on("end", function () { stream.respond({ ":status": 200 }); stream.end(Buffer.concat(chunks)); });
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if (p === "/cookie") { stream.respond({ ":status": 200, "set-cookie": "h2sid=q1; Path=/" }); stream.end("ck"); return; }
|
|
1709
|
+
if (p === "/err500") { stream.respond({ ":status": 500 }); stream.end("boom"); return; }
|
|
1710
|
+
if (p === "/reset") { stream.close(http2.constants.NGHTTP2_INTERNAL_ERROR); return; }
|
|
1711
|
+
stream.respond({ ":status": 200 }); stream.end("ok");
|
|
1712
|
+
}, async function (base) {
|
|
1713
|
+
// Custom + connection-specific headers: connection stripped, custom passes,
|
|
1714
|
+
// an explicit accept-encoding is preserved (default is identity otherwise).
|
|
1715
|
+
var r = await b.httpClient.request({ url: base + "/hdr", preferH2: true,
|
|
1716
|
+
headers: { "X-Custom": "cv", "Connection": "keep-alive", "Accept-Encoding": "gzip" },
|
|
1717
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1718
|
+
check("h2c headers: custom forwarded, connection-specific stripped",
|
|
1719
|
+
r.headers["x-saw-custom"] === "cv" && r.headers["x-saw-connection"] === "absent");
|
|
1720
|
+
check("h2c headers: explicit accept-encoding preserved", r.headers["x-saw-ae"] === "gzip");
|
|
1721
|
+
|
|
1722
|
+
// Buffer request body → content-length set, body echoed intact.
|
|
1723
|
+
var echo = await b.httpClient.request({ url: base + "/echo", preferH2: true, method: "POST",
|
|
1724
|
+
body: Buffer.from("h2-buffer-body"), allowedProtocols: ALLOW, allowInternal: true });
|
|
1725
|
+
check("h2c buffer body: echoed intact", echo.body.toString() === "h2-buffer-body");
|
|
1726
|
+
|
|
1727
|
+
// Cookie jar records Set-Cookie over h2.
|
|
1728
|
+
var jar = b.httpClient.cookieJar.create();
|
|
1729
|
+
await b.httpClient.request({ url: base + "/cookie", preferH2: true, jar: jar, allowedProtocols: ALLOW, allowInternal: true });
|
|
1730
|
+
check("h2c jar: Set-Cookie recorded from the h2 response",
|
|
1731
|
+
/h2sid=q1/.test(jar.cookieHeaderFor(base + "/cookie") || ""));
|
|
1732
|
+
|
|
1733
|
+
// Observer fires request:start + response:end over h2.
|
|
1734
|
+
var stages = [];
|
|
1735
|
+
await b.httpClient.request({ url: base + "/ok", preferH2: true,
|
|
1736
|
+
observer: function (s) { stages.push(s); }, allowedProtocols: ALLOW, allowInternal: true });
|
|
1737
|
+
check("h2c observer: request:start + response:end fired",
|
|
1738
|
+
stages.indexOf("request:start") !== -1 && stages.indexOf("response:end") !== -1);
|
|
1739
|
+
|
|
1740
|
+
// Stream-mode non-2xx over h2 → HTTP_ERROR with bounded err.body.
|
|
1741
|
+
var se = await _expectReject("h2c stream 500: rejects HTTP_ERROR",
|
|
1742
|
+
b.httpClient.request({ url: base + "/err500", preferH2: true, responseMode: "stream",
|
|
1743
|
+
allowedProtocols: ALLOW, allowInternal: true }), "HTTP_ERROR");
|
|
1744
|
+
check("h2c stream 500: err.body carries the error payload",
|
|
1745
|
+
se && Buffer.isBuffer(se.body) && /boom/.test(se.body.toString()));
|
|
1746
|
+
|
|
1747
|
+
// Upload throttle stages over h2 (paces the request body).
|
|
1748
|
+
var up = await b.httpClient.request({ url: base + "/echo", preferH2: true, method: "POST",
|
|
1749
|
+
body: Buffer.alloc(1500, 0x33), maxBytesPerSec: 1024, allowedProtocols: ALLOW, allowInternal: true });
|
|
1750
|
+
check("h2c upload throttle: paced body delivered intact", up.body.length === 1500);
|
|
1751
|
+
|
|
1752
|
+
// Download transform that errors → surfaced on the pipeline tail as H2_STREAM_ERROR.
|
|
1753
|
+
await _expectReject("h2c download-transform error rejects H2_STREAM_ERROR",
|
|
1754
|
+
b.httpClient.request({ url: base + "/ok", preferH2: true,
|
|
1755
|
+
downloadTransform: function () {
|
|
1756
|
+
return new nodeStream.Transform({ transform: function (c, e, cb) { cb(new Error("xform boom")); } });
|
|
1757
|
+
},
|
|
1758
|
+
allowedProtocols: ALLOW, allowInternal: true }), "H2_STREAM_ERROR");
|
|
1759
|
+
|
|
1760
|
+
// Piped Readable request body that errors → REQ_BODY_ERROR over h2.
|
|
1761
|
+
var badBody = new nodeStream.Readable({ read: function () { this.destroy(new Error("body blew up")); } });
|
|
1762
|
+
await _expectReject("h2c upload body stream error rejects REQ_BODY_ERROR",
|
|
1763
|
+
b.httpClient.request({ url: base + "/echo", preferH2: true, method: "POST", body: badBody,
|
|
1764
|
+
allowedProtocols: ALLOW, allowInternal: true }), "REQ_BODY_ERROR");
|
|
1765
|
+
|
|
1766
|
+
// Server resets the stream → client surfaces the stream error.
|
|
1767
|
+
await _expectReject("h2c server stream reset surfaces a stream error",
|
|
1768
|
+
b.httpClient.request({ url: base + "/reset", preferH2: true,
|
|
1769
|
+
allowedProtocols: ALLOW, allowInternal: true }), /ERR_HTTP2|H2_STREAM_ERROR/);
|
|
1770
|
+
});
|
|
1771
|
+
b.httpClient._resetForTest();
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// ---- HTTP/2 (h2c) connect error / idle timeout / in-flight abort ---
|
|
1775
|
+
|
|
1776
|
+
async function testH2cTimeoutAbortConnErr() {
|
|
1777
|
+
// Connect error — no listener on the captured port.
|
|
1778
|
+
var deadPort = await (async function () {
|
|
1779
|
+
var s = http.createServer();
|
|
1780
|
+
var port = await b.testing.listenOnRandomPort(s, "127.0.0.1");
|
|
1781
|
+
await new Promise(function (r) { s.close(function () { r(); }); });
|
|
1782
|
+
return port;
|
|
1783
|
+
})();
|
|
1784
|
+
b.httpClient._resetForTest();
|
|
1785
|
+
await _expectReject("h2c: connect to a dead port rejects with a connect error",
|
|
1786
|
+
b.httpClient.request({ url: "http://127.0.0.1:" + deadPort + "/x", preferH2: true,
|
|
1787
|
+
allowedProtocols: ALLOW, allowInternal: true }), /ECONNREFUSED|REQ_ERROR|H2_/);
|
|
1788
|
+
|
|
1789
|
+
// Idle timeout — server accepts the stream but never responds.
|
|
1790
|
+
await _withH2cServer(function (stream) { stream.on("error", function () {}); /* never respond */ },
|
|
1791
|
+
async function (base) {
|
|
1792
|
+
await _expectReject("h2c: idle timeout rejects ETIMEDOUT",
|
|
1793
|
+
b.httpClient.request({ url: base + "/hang", preferH2: true, idleTimeoutMs: 300,
|
|
1794
|
+
allowedProtocols: ALLOW, allowInternal: true }), "ETIMEDOUT");
|
|
1795
|
+
});
|
|
1796
|
+
b.httpClient._resetForTest();
|
|
1797
|
+
|
|
1798
|
+
// In-flight abort — abort after the stream is on the wire.
|
|
1799
|
+
var onWire = false;
|
|
1800
|
+
await _withH2cServer(function (stream) { stream.on("error", function () {}); onWire = true; /* never respond */ },
|
|
1801
|
+
async function (base) {
|
|
1802
|
+
var ctrl = new AbortController();
|
|
1803
|
+
var p = b.httpClient.request({ url: base + "/hang", preferH2: true, signal: ctrl.signal,
|
|
1804
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1805
|
+
await helpers.waitUntil(function () { return onWire; },
|
|
1806
|
+
{ timeoutMs: 5000, label: "h2c abort: stream reached the server" });
|
|
1807
|
+
ctrl.abort();
|
|
1808
|
+
await _expectReject("h2c: in-flight abort rejects ABORT", p, "ABORT");
|
|
1809
|
+
});
|
|
1810
|
+
b.httpClient._resetForTest();
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// ---- h1 in-flight abort with the abort listener already attached ---
|
|
1814
|
+
|
|
1815
|
+
async function testH1AbortOnWire() {
|
|
1816
|
+
var onWire = false;
|
|
1817
|
+
await _withServer(function (req, res) { onWire = true; void res; /* hold the request open */ },
|
|
1818
|
+
async function (base) {
|
|
1819
|
+
var ctrl = new AbortController();
|
|
1820
|
+
var p = b.httpClient.request({ url: base + "/hang", signal: ctrl.signal,
|
|
1821
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1822
|
+
await helpers.waitUntil(function () { return onWire; },
|
|
1823
|
+
{ timeoutMs: 5000, label: "h1 abort: request reached the server" });
|
|
1824
|
+
ctrl.abort();
|
|
1825
|
+
await _expectReject("h1: in-flight abort (listener attached) rejects ABORT", p, "ABORT");
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// ---- default-port origin keys (unit) -------------------------------
|
|
1830
|
+
|
|
1831
|
+
function testDefaultPortKeys() {
|
|
1832
|
+
// A portless URL exercises the IANA default-port fallback inside the
|
|
1833
|
+
// origin-key builder (443 for https, 80 for http). No transport is
|
|
1834
|
+
// cached for these hosts, so the lookup is a pure parse + map miss.
|
|
1835
|
+
check("origin key: portless https resolves without a cached transport",
|
|
1836
|
+
b.httpClient._getCachedTransportKind("https://origin.invalid/") === null);
|
|
1837
|
+
check("origin key: portless http resolves without a cached transport",
|
|
1838
|
+
b.httpClient._getCachedTransportKind("http://origin.invalid/") === null);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// ---- throttled/transformed upload body error (h1 + h2) -------------
|
|
1842
|
+
|
|
1843
|
+
async function testThrottledUploadBodyError() {
|
|
1844
|
+
// h1: an erroring body routed through the throttle stages → REQ_BODY_ERROR.
|
|
1845
|
+
await _withServer(function (req) { req.on("data", function () {}); req.on("error", function () {}); },
|
|
1846
|
+
async function (base) {
|
|
1847
|
+
var bad = new nodeStream.Readable({ read: function () { this.destroy(new Error("throttled body blew up")); } });
|
|
1848
|
+
await _expectReject("throttle h1: erroring upload body rejects REQ_BODY_ERROR",
|
|
1849
|
+
b.httpClient.request({ url: base + "/u", method: "POST", body: bad, maxBytesPerSec: 1024,
|
|
1850
|
+
allowedProtocols: ALLOW, allowInternal: true }), "REQ_BODY_ERROR");
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
// h2: same, over the h2 stream upload stages.
|
|
1854
|
+
await _withH2cServer(function (stream) { stream.on("error", function () {}); stream.on("data", function () {}); },
|
|
1855
|
+
async function (base) {
|
|
1856
|
+
var bad = new nodeStream.Readable({ read: function () { this.destroy(new Error("throttled body blew up")); } });
|
|
1857
|
+
await _expectReject("throttle h2: erroring upload body rejects REQ_BODY_ERROR",
|
|
1858
|
+
b.httpClient.request({ url: base + "/u", method: "POST", body: bad, preferH2: true, maxBytesPerSec: 1024,
|
|
1859
|
+
allowedProtocols: ALLOW, allowInternal: true }), "REQ_BODY_ERROR");
|
|
1860
|
+
});
|
|
1861
|
+
b.httpClient._resetForTest();
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// ---- h2 pre-aborted signal + h2 transport teardown -----------------
|
|
1865
|
+
|
|
1866
|
+
async function testH2PreAbortAndTeardown() {
|
|
1867
|
+
// Scope 1: warm h2, pre-aborted signal, then _resetForTest tears down the
|
|
1868
|
+
// live h2 session (its per-origin key isn't re-warmed inside this scope, so
|
|
1869
|
+
// no old-session `close` can race a fresh entry).
|
|
1870
|
+
await _withH2cServer(function (stream) { stream.on("error", function () {}); stream.respond({ ":status": 200 }); stream.end("ok"); },
|
|
1871
|
+
async function (base) {
|
|
1872
|
+
await b.httpClient.request({ url: base + "/x", preferH2: true, allowedProtocols: ALLOW, allowInternal: true });
|
|
1873
|
+
check("h2 teardown: transport cached as h2 after the first request",
|
|
1874
|
+
b.httpClient._getCachedTransportKind(base + "/x") === "h2");
|
|
1875
|
+
|
|
1876
|
+
// Pre-aborted signal on a warm h2 origin → rejects before any stream.
|
|
1877
|
+
var ac = new AbortController();
|
|
1878
|
+
ac.abort();
|
|
1879
|
+
await _expectReject("h2 pre-aborted signal: rejects ABORT",
|
|
1880
|
+
b.httpClient.request({ url: base + "/x", preferH2: true, signal: ac.signal,
|
|
1881
|
+
allowedProtocols: ALLOW, allowInternal: true }), "ABORT");
|
|
1882
|
+
|
|
1883
|
+
b.httpClient._resetForTest(); // covers _resetForTest's live-h2-session teardown branch
|
|
1884
|
+
check("h2 teardown: _resetForTest cleared the live h2 session",
|
|
1885
|
+
b.httpClient._getCachedTransportCount() === 0);
|
|
1886
|
+
});
|
|
1887
|
+
|
|
1888
|
+
// Scope 2 (fresh origin): warm h2, then configurePool tears down the live
|
|
1889
|
+
// h2 session + clears the cache.
|
|
1890
|
+
await _withH2cServer(function (stream) { stream.on("error", function () {}); stream.respond({ ":status": 200 }); stream.end("ok"); },
|
|
1891
|
+
async function (base) {
|
|
1892
|
+
await b.httpClient.request({ url: base + "/y", preferH2: true, allowedProtocols: ALLOW, allowInternal: true });
|
|
1893
|
+
check("h2 teardown: transport cached before configurePool",
|
|
1894
|
+
b.httpClient._getCachedTransportKind(base + "/y") === "h2");
|
|
1895
|
+
b.httpClient.configurePool({ maxSockets: 8 });
|
|
1896
|
+
check("h2 teardown: configurePool cleared the h2 transport",
|
|
1897
|
+
b.httpClient._getCachedTransportCount() === 0);
|
|
1898
|
+
b.httpClient.configurePool({
|
|
1899
|
+
maxSockets: b.httpClient.DEFAULT_AGENT_OPTS.maxSockets,
|
|
1900
|
+
maxFreeSockets: b.httpClient.DEFAULT_AGENT_OPTS.maxFreeSockets,
|
|
1901
|
+
keepAliveMsecs: b.httpClient.DEFAULT_AGENT_OPTS.keepAliveMsecs,
|
|
1902
|
+
keepAlive: b.httpClient.DEFAULT_AGENT_OPTS.keepAlive,
|
|
1903
|
+
scheduling: b.httpClient.DEFAULT_AGENT_OPTS.scheduling,
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
b.httpClient._resetForTest();
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// ---- additional request() validation branches ---------------------
|
|
1910
|
+
//
|
|
1911
|
+
// Covers the download-side transform reject (the upload side is probed
|
|
1912
|
+
// above), the array-form transform normalization (valid + a bad entry
|
|
1913
|
+
// inside the array), and a `before` hook that returns nothing (the
|
|
1914
|
+
// running opts stay unchanged — the `if (ret && typeof ret === object)`
|
|
1915
|
+
// false arm).
|
|
1916
|
+
|
|
1917
|
+
async function testMoreArgValidation() {
|
|
1918
|
+
await _expectReject("request: downloadTransform non-Transform rejects",
|
|
1919
|
+
b.httpClient.request({ url: "https://x.example/", downloadTransform: 7 }), /Transform/);
|
|
1920
|
+
// Array form with a bad entry — the loop inside _coerceTransforms rejects.
|
|
1921
|
+
await _expectReject("request: array uploadTransform with a bad entry rejects",
|
|
1922
|
+
b.httpClient.request({ url: "https://x.example/",
|
|
1923
|
+
uploadTransform: [new nodeStream.PassThrough(), 5] }), /Transform/);
|
|
1924
|
+
|
|
1925
|
+
// Valid array-form download transform (two PassThroughs) + a before hook
|
|
1926
|
+
// that returns undefined (opts unchanged) both drive the real wire path.
|
|
1927
|
+
var payload = Buffer.from("ARRAY-TRANSFORM-AND-VOID-BEFORE");
|
|
1928
|
+
await _withServer(function (req, res) { res.writeHead(200); res.end(payload); },
|
|
1929
|
+
async function (base) {
|
|
1930
|
+
var beforeRan = 0;
|
|
1931
|
+
var r = await b.httpClient.request({ url: base + "/x",
|
|
1932
|
+
before: [function () { beforeRan += 1; /* returns undefined — opts unchanged */ }],
|
|
1933
|
+
downloadTransform: [
|
|
1934
|
+
function () { return new nodeStream.PassThrough(); },
|
|
1935
|
+
new nodeStream.PassThrough(),
|
|
1936
|
+
],
|
|
1937
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1938
|
+
check("before: a hook returning undefined leaves opts unchanged (request still fires)",
|
|
1939
|
+
beforeRan === 1 && r.statusCode === 200);
|
|
1940
|
+
check("downloadTransform: array of stages passes the body through intact",
|
|
1941
|
+
r.body.equals(payload));
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// ---- HTTP_ERROR permanent-flag classification (RFC 9110 §15.5) ------
|
|
1946
|
+
//
|
|
1947
|
+
// _isPermanentStatus: 408 / 425 / 429 are retryable 4xx (permanent=false);
|
|
1948
|
+
// any other 4xx is permanent; 5xx is not permanent. The buffered non-2xx
|
|
1949
|
+
// path stamps err.permanent from this — an operator's retry policy keys
|
|
1950
|
+
// off it, so a wrong flag silently changes retry behaviour.
|
|
1951
|
+
|
|
1952
|
+
async function testPermanentFlag() {
|
|
1953
|
+
async function _permFor(status) {
|
|
1954
|
+
var err = null;
|
|
1955
|
+
await _withServer(function (req, res) { res.writeHead(status); res.end("s" + status); },
|
|
1956
|
+
async function (base) {
|
|
1957
|
+
try {
|
|
1958
|
+
await b.httpClient.request({ url: base + "/e",
|
|
1959
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
1960
|
+
} catch (e) { err = e; }
|
|
1961
|
+
});
|
|
1962
|
+
return err;
|
|
1963
|
+
}
|
|
1964
|
+
var e403 = await _permFor(403);
|
|
1965
|
+
check("permanent: 403 is a permanent HTTP_ERROR (statusCode + permanent true)",
|
|
1966
|
+
e403 && e403.code === "HTTP_ERROR" && e403.statusCode === 403 && e403.permanent === true);
|
|
1967
|
+
var e429 = await _permFor(429);
|
|
1968
|
+
check("permanent: 429 (too-many-requests) is NOT permanent (retryable)",
|
|
1969
|
+
e429 && e429.statusCode === 429 && e429.permanent === false);
|
|
1970
|
+
var e408 = await _permFor(408);
|
|
1971
|
+
check("permanent: 408 (request-timeout) is NOT permanent (retryable)",
|
|
1972
|
+
e408 && e408.statusCode === 408 && e408.permanent === false);
|
|
1973
|
+
var e503 = await _permFor(503);
|
|
1974
|
+
check("permanent: 503 (5xx) is NOT permanent (retryable)",
|
|
1975
|
+
e503 && e503.statusCode === 503 && e503.permanent === false);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// ---- maxRedirects: 0 — the explicit no-follow branch ---------------
|
|
1979
|
+
//
|
|
1980
|
+
// Distinct from undefined/null: `maxRedirects === 0` short-circuits to the
|
|
1981
|
+
// single-shot path (request(), line ~1038 `=== 0` disjunct) so a 3xx is NOT
|
|
1982
|
+
// followed. always-resolve surfaces the 3xx structurally.
|
|
1983
|
+
|
|
1984
|
+
async function testMaxRedirectsZero() {
|
|
1985
|
+
await _withServer(function (req, res) { res.writeHead(302, { Location: "/next" }); res.end(); },
|
|
1986
|
+
async function (base) {
|
|
1987
|
+
var r = await b.httpClient.request({ url: base + "/a", maxRedirects: 0,
|
|
1988
|
+
responseMode: "always-resolve", allowedProtocols: ALLOW, allowInternal: true });
|
|
1989
|
+
check("maxRedirects 0: 3xx not followed, returned structurally in always-resolve",
|
|
1990
|
+
r.statusCode === 302);
|
|
1991
|
+
// Default buffer mode with maxRedirects:0 surfaces the 3xx as HTTP_ERROR
|
|
1992
|
+
// carrying the status (the operator inspects err.statusCode).
|
|
1993
|
+
var err = await _expectReject("maxRedirects 0: buffer-mode 3xx rejects HTTP_ERROR",
|
|
1994
|
+
b.httpClient.request({ url: base + "/a", maxRedirects: 0,
|
|
1995
|
+
allowedProtocols: ALLOW, allowInternal: true }), "HTTP_ERROR");
|
|
1996
|
+
check("maxRedirects 0: HTTP_ERROR carries the 3xx status", err && err.statusCode === 302);
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// ---- 301/302 method coercion + HEAD preservation -------------------
|
|
2001
|
+
//
|
|
2002
|
+
// RFC 9110 §15.4.{2,3}: historical clients coerce a non-GET/HEAD 301/302 to
|
|
2003
|
+
// GET and drop the body; a HEAD is preserved. The 303 case is covered
|
|
2004
|
+
// elsewhere — this pins the 301/302 arm of the same branch (line ~1329).
|
|
2005
|
+
|
|
2006
|
+
async function test301302Coercion() {
|
|
2007
|
+
// 302 + POST → coerced to GET, body dropped.
|
|
2008
|
+
await _withServer(function (req, res) {
|
|
2009
|
+
if (req.url === "/p") { res.writeHead(302, { Location: "/landing" }); res.end(); return; }
|
|
2010
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
2011
|
+
req.on("end", function () {
|
|
2012
|
+
res.writeHead(200, { "x-method": req.method, "x-body-len": String(Buffer.concat(chunks).length) });
|
|
2013
|
+
res.end("ok");
|
|
2014
|
+
});
|
|
2015
|
+
}, async function (base) {
|
|
2016
|
+
var r = await b.httpClient.request({ url: base + "/p", method: "POST", body: "dropme",
|
|
2017
|
+
maxRedirects: 2, allowedProtocols: ALLOW, allowInternal: true });
|
|
2018
|
+
check("redirect 302: POST coerced to GET", r.headers["x-method"] === "GET");
|
|
2019
|
+
check("redirect 302: request body dropped on coercion", r.headers["x-body-len"] === "0");
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
// 301 + PUT → coerced to GET as well.
|
|
2023
|
+
await _withServer(function (req, res) {
|
|
2024
|
+
if (req.url === "/one") { res.writeHead(301, { Location: "/landed" }); res.end(); return; }
|
|
2025
|
+
res.writeHead(200, { "x-method": req.method }); res.end("ok");
|
|
2026
|
+
}, async function (base) {
|
|
2027
|
+
var r = await b.httpClient.request({ url: base + "/one", method: "PUT", body: "x",
|
|
2028
|
+
maxRedirects: 2, allowedProtocols: ALLOW, allowInternal: true });
|
|
2029
|
+
check("redirect 301: non-GET coerced to GET", r.headers["x-method"] === "GET");
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
// 302 + HEAD → method preserved (HEAD is exempt from the coercion).
|
|
2033
|
+
await _withServer(function (req, res) {
|
|
2034
|
+
if (req.url === "/h") { res.writeHead(302, { Location: "/done" }); res.end(); return; }
|
|
2035
|
+
res.writeHead(200, { "x-method": req.method }); res.end();
|
|
2036
|
+
}, async function (base) {
|
|
2037
|
+
var r = await b.httpClient.request({ url: base + "/h", method: "HEAD",
|
|
2038
|
+
maxRedirects: 2, allowedProtocols: ALLOW, allowInternal: true });
|
|
2039
|
+
check("redirect 302: HEAD method preserved (not coerced)", r.headers["x-method"] === "HEAD");
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// ---- allowedHosts edge branches ------------------------------------
|
|
2044
|
+
//
|
|
2045
|
+
// The empty allowedHosts array is a no-op (gate skipped); an empty-string
|
|
2046
|
+
// entry is skipped (continue); an object entry with no `methods` allows any
|
|
2047
|
+
// method. All three are branch arms the happy-path allow tests don't reach.
|
|
2048
|
+
|
|
2049
|
+
async function testAllowedHostsEdges() {
|
|
2050
|
+
await _withServer(function (req, res) { res.writeHead(200); res.end("ok"); },
|
|
2051
|
+
async function (base) {
|
|
2052
|
+
// Empty array → gate not applied at all (length > 0 false).
|
|
2053
|
+
var r0 = await b.httpClient.request({ url: base + "/", allowedHosts: [],
|
|
2054
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2055
|
+
check("allowedHosts: empty array is a no-op (request allowed)", r0.statusCode === 200);
|
|
2056
|
+
|
|
2057
|
+
// An empty-string entry is skipped; a later valid entry still matches.
|
|
2058
|
+
var r1 = await b.httpClient.request({ url: base + "/",
|
|
2059
|
+
allowedHosts: ["", { host: "" }, "127.0.0.1"],
|
|
2060
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2061
|
+
check("allowedHosts: empty entries skipped, a valid later entry matches", r1.statusCode === 200);
|
|
2062
|
+
|
|
2063
|
+
// Object entry WITHOUT a methods array → any method allowed.
|
|
2064
|
+
var r2 = await b.httpClient.request({ url: base + "/", method: "DELETE",
|
|
2065
|
+
allowedHosts: [{ host: "127.0.0.1" }],
|
|
2066
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2067
|
+
check("allowedHosts: object entry without methods allows any method (DELETE)", r2.statusCode === 200);
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// ---- uploadMultipartStream: explicit filename + default contentType ----
|
|
2072
|
+
//
|
|
2073
|
+
// The happy-path lifecycle test passes an explicit contentType and lets the
|
|
2074
|
+
// filename default; this pins the opposite arms — an explicit file.filename
|
|
2075
|
+
// wins over path.basename, and an omitted contentType defaults to
|
|
2076
|
+
// application/octet-stream on the emitted part header.
|
|
2077
|
+
|
|
2078
|
+
async function testUploadStreamFilenameDefaults() {
|
|
2079
|
+
var dir = b.testing.tempDir("httpclient-cov-upnm");
|
|
2080
|
+
try {
|
|
2081
|
+
var src = helpers.path.join(dir.path, "on-disk-name.bin");
|
|
2082
|
+
helpers.fs.writeFileSync(src, "NAMED-UPLOAD-BODY");
|
|
2083
|
+
var body = null;
|
|
2084
|
+
await _withServer(function (req, res) {
|
|
2085
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
2086
|
+
req.on("end", function () { body = Buffer.concat(chunks).toString("utf8"); res.writeHead(200); res.end("ok"); });
|
|
2087
|
+
}, async function (base) {
|
|
2088
|
+
var r = await b.httpClient.uploadMultipartStream({
|
|
2089
|
+
url: base + "/up",
|
|
2090
|
+
file: { path: src, fieldName: "artifact", filename: "override.dat" }, // explicit filename, no contentType
|
|
2091
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2092
|
+
check("uploadMultipartStream: explicit filename overrides path.basename",
|
|
2093
|
+
r.statusCode === 200 && /filename="override\.dat"/.test(body || "") &&
|
|
2094
|
+
(body || "").indexOf("on-disk-name.bin") === -1);
|
|
2095
|
+
check("uploadMultipartStream: omitted contentType defaults to application/octet-stream",
|
|
2096
|
+
/Content-Type:\s*application\/octet-stream/i.test(body || ""));
|
|
2097
|
+
});
|
|
2098
|
+
} finally {
|
|
2099
|
+
dir.cleanup();
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// ---- download progress when upstream omits Content-Length ----------
|
|
2104
|
+
//
|
|
2105
|
+
// dlTotal is null when the response carries no Content-Length; the progress
|
|
2106
|
+
// callback still fires with loaded byte counts and total === null (the
|
|
2107
|
+
// chunked-response arm the Content-Length happy-path test doesn't reach).
|
|
2108
|
+
|
|
2109
|
+
async function testDownloadProgressNoContentLength() {
|
|
2110
|
+
var payload = Buffer.alloc(2048, 0x77);
|
|
2111
|
+
await _withServer(function (req, res) {
|
|
2112
|
+
// No Content-Length — Node frames this as chunked transfer.
|
|
2113
|
+
res.writeHead(200, { "Content-Type": "application/octet-stream" });
|
|
2114
|
+
res.end(payload);
|
|
2115
|
+
}, async function (base) {
|
|
2116
|
+
var last = null;
|
|
2117
|
+
var r = await b.httpClient.request({ url: base + "/nc",
|
|
2118
|
+
onDownloadProgress: function (p) { last = p; },
|
|
2119
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2120
|
+
check("download progress (no Content-Length): total is null, loaded summed to body",
|
|
2121
|
+
r.body.equals(payload) && last != null && last.total === null && last.loaded === payload.length);
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// ---- configurePool: inherited-prototype key skipped -----------------
|
|
2126
|
+
//
|
|
2127
|
+
// The for-in validation loop guards with hasOwnProperty; an opts object that
|
|
2128
|
+
// inherits an enumerable key from its prototype must skip it (the `continue`)
|
|
2129
|
+
// rather than reject it as unknown or copy it into the pool config.
|
|
2130
|
+
|
|
2131
|
+
async function testConfigurePoolInheritedKey() {
|
|
2132
|
+
var proto = { maxSockets: 999999 }; // inherited — must be ignored by the loop
|
|
2133
|
+
var o = Object.create(proto);
|
|
2134
|
+
o.maxFreeSockets = 4; // own — the only key that applies
|
|
2135
|
+
b.httpClient.configurePool(o);
|
|
2136
|
+
check("configurePool: inherited prototype key skipped (no unknown-option throw)",
|
|
2137
|
+
b.httpClient.DEFAULT_AGENT_OPTS.maxSockets !== 999999); // proto value never adopted
|
|
2138
|
+
// Restore the shipped defaults for later tests / other files.
|
|
2139
|
+
b.httpClient.configurePool({
|
|
2140
|
+
maxSockets: b.httpClient.DEFAULT_AGENT_OPTS.maxSockets,
|
|
2141
|
+
maxFreeSockets: b.httpClient.DEFAULT_AGENT_OPTS.maxFreeSockets,
|
|
2142
|
+
keepAliveMsecs: b.httpClient.DEFAULT_AGENT_OPTS.keepAliveMsecs,
|
|
2143
|
+
keepAlive: b.httpClient.DEFAULT_AGENT_OPTS.keepAlive,
|
|
2144
|
+
scheduling: b.httpClient.DEFAULT_AGENT_OPTS.scheduling,
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// ---- pinned-DNS lookup: family fallback + null-options shape ---------
|
|
2149
|
+
//
|
|
2150
|
+
// An ip entry with no `family` defaults to 4; the returned lookup called with a
|
|
2151
|
+
// null `options` argument (neither a function nor an object) falls back to `{}`
|
|
2152
|
+
// and resolves the single-address form.
|
|
2153
|
+
|
|
2154
|
+
function testPinnedLookupExtraShapes() {
|
|
2155
|
+
var lk = b.httpClient._pinnedLookupForTest([{ address: "1.2.3.4" }]); // no family → defaults to 4
|
|
2156
|
+
var famAddr = null, famFam = null;
|
|
2157
|
+
lk("h", {}, function (err, addr, fam) { famAddr = addr; famFam = fam; });
|
|
2158
|
+
check("pinnedLookup: ip without family defaults family to 4",
|
|
2159
|
+
famAddr === "1.2.3.4" && famFam === 4);
|
|
2160
|
+
var nAddr = null;
|
|
2161
|
+
lk("h", null, function (err, addr) { nAddr = addr; }); // null options → `options || {}` fallback
|
|
2162
|
+
check("pinnedLookup: null options falls back to single-address resolution", nAddr === "1.2.3.4");
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// ---- HTTPS caller-agent path (real TLS 200) -------------------------
|
|
2166
|
+
//
|
|
2167
|
+
// opts.agent bypasses the per-origin transport cache and drives _requestH1 over
|
|
2168
|
+
// TLS directly (the https lib arm + pinned lookup). The operator's own agent
|
|
2169
|
+
// carries rejectUnauthorized:false for the loopback self-signed cert — the
|
|
2170
|
+
// framework's default posture is untouched (its own transport still refuses it,
|
|
2171
|
+
// exercised separately below).
|
|
2172
|
+
|
|
2173
|
+
async function testHttpsCallerAgent() {
|
|
2174
|
+
await _withHttpsServer(function (req, res) { res.writeHead(200); res.end("tls-ok"); }, null,
|
|
2175
|
+
async function (base) {
|
|
2176
|
+
var agent = new https.Agent({ rejectUnauthorized: false });
|
|
2177
|
+
try {
|
|
2178
|
+
var r = await b.httpClient.request({
|
|
2179
|
+
url: base + "/", agent: agent, allowedProtocols: ALLOW, allowInternal: true });
|
|
2180
|
+
check("https caller-agent: request completes over TLS (200)",
|
|
2181
|
+
r.statusCode === 200 && r.body.toString() === "tls-ok");
|
|
2182
|
+
} finally {
|
|
2183
|
+
agent.destroy();
|
|
2184
|
+
}
|
|
2185
|
+
});
|
|
2186
|
+
b.httpClient._resetForTest();
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// ---- HTTPS framework transport: self-signed refused -----------------
|
|
2190
|
+
//
|
|
2191
|
+
// Without a caller agent, an https origin routes through _getTransport's https
|
|
2192
|
+
// branch → _connectHttpsWithAlpn → http2.connect with the framework's fixed
|
|
2193
|
+
// TLS posture (no rejectUnauthorized). The loopback self-signed cert fails
|
|
2194
|
+
// verification, exercising the session error arm (and proving the framework
|
|
2195
|
+
// does NOT silently trust an unverifiable peer).
|
|
2196
|
+
|
|
2197
|
+
async function testHttpsFrameworkTransportError() {
|
|
2198
|
+
await _withHttpsServer(function (req, res) { res.writeHead(200); res.end("x"); }, null,
|
|
2199
|
+
async function (base) {
|
|
2200
|
+
var err = await _expectReject("https framework transport: self-signed cert refused",
|
|
2201
|
+
b.httpClient.request({ url: base + "/", allowedProtocols: ALLOW, allowInternal: true }),
|
|
2202
|
+
/CERT|SSL|SELF_SIGNED|TLS|ERR_/i);
|
|
2203
|
+
check("https framework transport: rejection is a TLS/cert error (fail-closed)",
|
|
2204
|
+
err != null && err.code !== "HTTP_ERROR");
|
|
2205
|
+
});
|
|
2206
|
+
b.httpClient._resetForTest();
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// ---- proxy request path (proxy configured, non-metadata host) -------
|
|
2210
|
+
//
|
|
2211
|
+
// With a proxy configured AND allowInternal:true, the textual metadata block
|
|
2212
|
+
// runs then the DNS SSRF resolution is skipped (ips:null), and the request is
|
|
2213
|
+
// dispatched through the proxy agent (_requestH1 with proxyAgent). Pointing the
|
|
2214
|
+
// proxy at a dead local port surfaces the connect error — the branch is the
|
|
2215
|
+
// dispatch-through-proxy path, not the failure code.
|
|
2216
|
+
|
|
2217
|
+
async function testProxyRequestPath() {
|
|
2218
|
+
var networkProxy = require("../../lib/network-proxy");
|
|
2219
|
+
var deadPort = await (async function () {
|
|
2220
|
+
var s = http.createServer();
|
|
2221
|
+
var port = await b.testing.listenOnRandomPort(s, "127.0.0.1");
|
|
2222
|
+
await new Promise(function (r) { s.close(function () { r(); }); });
|
|
2223
|
+
return port;
|
|
2224
|
+
})();
|
|
2225
|
+
networkProxy.set({ http: "http://127.0.0.1:" + deadPort });
|
|
2226
|
+
b.httpClient._resetForTest();
|
|
2227
|
+
try {
|
|
2228
|
+
await _expectReject("proxy path: request dispatched through the proxy agent (dead proxy → connect error)",
|
|
2229
|
+
b.httpClient.request({ url: "http://example.com/thing",
|
|
2230
|
+
allowInternal: true, allowedProtocols: ALLOW }), /ECONNREFUSED|REQ_ERROR/);
|
|
2231
|
+
} finally {
|
|
2232
|
+
networkProxy._resetForTest();
|
|
2233
|
+
b.httpClient._resetForTest();
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// ---- redirect parse edge cases -------------------------------------
|
|
2238
|
+
//
|
|
2239
|
+
// (a) a malformed INITIAL url with maxRedirects>0 fails the redirect layer's
|
|
2240
|
+
// origin parse (caught; the next hop rejects); (b) a Location that URL can
|
|
2241
|
+
// construct but safeUrl refuses (userinfo) is caught at the next-origin
|
|
2242
|
+
// parse; (c) an onRedirect hook that throws a falsy value still aborts with
|
|
2243
|
+
// REDIRECT_ABORTED (the String(e) message arm).
|
|
2244
|
+
|
|
2245
|
+
async function testRedirectParseEdgeCases() {
|
|
2246
|
+
// (a) malformed initial URL, redirect-following requested.
|
|
2247
|
+
await _expectReject("redirect layer: malformed initial URL rejects (origin parse caught)",
|
|
2248
|
+
b.httpClient.request({ url: "http://[not a url", maxRedirects: 2,
|
|
2249
|
+
allowedProtocols: ALLOW, allowInternal: true }), /./);
|
|
2250
|
+
|
|
2251
|
+
// (b) Location carrying userinfo — URL builds it, safeUrl refuses it.
|
|
2252
|
+
await _withServer(function (req, res) {
|
|
2253
|
+
res.writeHead(302, { Location: "http://user:pass@127.0.0.1:1/x" }); res.end();
|
|
2254
|
+
}, async function (base) {
|
|
2255
|
+
var err = await _expectReject("redirect: unparseable next-origin (userinfo Location) rejects",
|
|
2256
|
+
b.httpClient.request({ url: base + "/r", maxRedirects: 2,
|
|
2257
|
+
allowedProtocols: ALLOW, allowInternal: true }), /userinfo|safe-url/);
|
|
2258
|
+
check("redirect: userinfo Location surfaces the safe-url refusal",
|
|
2259
|
+
err != null && /userinfo-disallowed/.test(err.code || ""));
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
// (c) onRedirect throws a falsy (undefined) — String(e) message arm.
|
|
2263
|
+
await _withServer(function (req, res) {
|
|
2264
|
+
if (req.url === "/a") { res.writeHead(302, { Location: "/b" }); res.end(); return; }
|
|
2265
|
+
res.writeHead(200); res.end("final");
|
|
2266
|
+
}, async function (base) {
|
|
2267
|
+
var err = await _expectReject("onRedirect: throwing a message-less error still aborts REDIRECT_ABORTED",
|
|
2268
|
+
b.httpClient.request({ url: base + "/a", maxRedirects: 3,
|
|
2269
|
+
onRedirect: function () { throw new Error(""); },
|
|
2270
|
+
allowedProtocols: ALLOW, allowInternal: true }), "REDIRECT_ABORTED");
|
|
2271
|
+
check("onRedirect: empty-message throw uses the String(e) fallback",
|
|
2272
|
+
err != null && /refused redirect: Error$/.test(err.message));
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// ---- allowedHosts deny with an audit sink that throws ---------------
|
|
2277
|
+
//
|
|
2278
|
+
// The host-denied audit emit is wrapped drop-silent — an audit sink whose
|
|
2279
|
+
// safeEmit throws must not convert the deny into a different failure; the
|
|
2280
|
+
// request still rejects HOST_DISALLOWED.
|
|
2281
|
+
|
|
2282
|
+
async function testAdversarialAuditOnHostDeny() {
|
|
2283
|
+
var throwingAudit = { safeEmit: function () { throw new Error("audit sink boom"); } };
|
|
2284
|
+
await _expectReject("allowedHosts deny: a throwing audit sink is swallowed (still HOST_DISALLOWED)",
|
|
2285
|
+
b.httpClient.request({ url: "http://127.0.0.1:9/x", allowedHosts: ["api.partner.example"],
|
|
2286
|
+
audit: throwingAudit, allowedProtocols: ALLOW, allowInternal: true }), "HOST_DISALLOWED");
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// ---- h1 best-effort hooks that throw + observer on error paths ------
|
|
2290
|
+
//
|
|
2291
|
+
// onDownloadProgress / onUploadProgress / jar.setFromResponse are all best-
|
|
2292
|
+
// effort: a throw from any is caught and the request still resolves. The
|
|
2293
|
+
// observer must fire on the request-error and response-error phases too.
|
|
2294
|
+
|
|
2295
|
+
async function testH1BestEffortHooksThrow() {
|
|
2296
|
+
var payload = Buffer.from("H1-HOOKS-THROW-PAYLOAD");
|
|
2297
|
+
// Progress hooks + jar.setFromResponse all throw; request still resolves 200.
|
|
2298
|
+
var throwingJar = {
|
|
2299
|
+
cookieHeaderFor: function () { return null; },
|
|
2300
|
+
setFromResponse: function () { throw new Error("jar setFromResponse boom"); },
|
|
2301
|
+
};
|
|
2302
|
+
await _withServer(function (req, res) {
|
|
2303
|
+
req.on("data", function () {});
|
|
2304
|
+
req.on("end", function () {
|
|
2305
|
+
res.writeHead(200, { "set-cookie": "x=1; Path=/", "Content-Length": String(payload.length) });
|
|
2306
|
+
res.end(payload);
|
|
2307
|
+
});
|
|
2308
|
+
}, async function (base) {
|
|
2309
|
+
var r = await b.httpClient.request({
|
|
2310
|
+
url: base + "/", method: "POST", body: Buffer.from("uploadbody"),
|
|
2311
|
+
jar: throwingJar,
|
|
2312
|
+
onUploadProgress: function () { throw new Error("onUploadProgress boom"); },
|
|
2313
|
+
onDownloadProgress: function () { throw new Error("onDownloadProgress boom"); },
|
|
2314
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2315
|
+
check("h1 hooks-throw: request still resolves with the full body",
|
|
2316
|
+
r.statusCode === 200 && r.body.equals(payload));
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
// Observer fires on a request-phase error (connection refused).
|
|
2320
|
+
var reqStages = [];
|
|
2321
|
+
await _expectReject("h1 request error (observer): rejects with a connect error",
|
|
2322
|
+
b.httpClient.request({ url: "http://127.0.0.1:9/x",
|
|
2323
|
+
observer: function (stage) { reqStages.push(stage); },
|
|
2324
|
+
allowedProtocols: ALLOW, allowInternal: true }), /ECONNREFUSED|REQ_ERROR/);
|
|
2325
|
+
check("h1 observer: fired an error stage on request failure", reqStages.indexOf("error") !== -1);
|
|
2326
|
+
|
|
2327
|
+
// Observer fires on a response-phase error (download-transform error).
|
|
2328
|
+
var resStages = [];
|
|
2329
|
+
await _withServer(function (req, res) { res.writeHead(200); res.end(Buffer.alloc(64, 1)); },
|
|
2330
|
+
async function (base) {
|
|
2331
|
+
await _expectReject("h1 response error (observer): download-transform error rejects RES_ERROR",
|
|
2332
|
+
b.httpClient.request({ url: base + "/x",
|
|
2333
|
+
observer: function (stage) { resStages.push(stage); },
|
|
2334
|
+
downloadTransform: function () {
|
|
2335
|
+
return new nodeStream.Transform({ transform: function (c, e, cb) { cb(new Error("xform boom")); } });
|
|
2336
|
+
},
|
|
2337
|
+
allowedProtocols: ALLOW, allowInternal: true }), "RES_ERROR");
|
|
2338
|
+
check("h1 observer: fired an error stage on response failure", resStages.indexOf("error") !== -1);
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// ---- h1 stream mode WITH download progress --------------------------
|
|
2343
|
+
//
|
|
2344
|
+
// Stream mode composes the download pipeline with the progress emitter when
|
|
2345
|
+
// onDownloadProgress is set (the `onDownloadProgress ? _emitDownload : null`
|
|
2346
|
+
// truthy arm in the stream branch); the drained body is intact and progress
|
|
2347
|
+
// summed to the payload.
|
|
2348
|
+
|
|
2349
|
+
async function testH1StreamModeDownloadProgress() {
|
|
2350
|
+
var payload = Buffer.alloc(3072, 0x70);
|
|
2351
|
+
await _withServer(function (req, res) {
|
|
2352
|
+
res.writeHead(200, { "Content-Length": String(payload.length) }); res.end(payload);
|
|
2353
|
+
}, async function (base) {
|
|
2354
|
+
var last = null;
|
|
2355
|
+
var s = await b.httpClient.request({ url: base + "/s", responseMode: "stream",
|
|
2356
|
+
onDownloadProgress: function (p) { last = p; },
|
|
2357
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2358
|
+
var drained = await new Promise(function (resolve, reject) {
|
|
2359
|
+
var cs = []; s.body.on("data", function (c) { cs.push(c); });
|
|
2360
|
+
s.body.on("end", function () { resolve(Buffer.concat(cs)); });
|
|
2361
|
+
s.body.on("error", reject);
|
|
2362
|
+
});
|
|
2363
|
+
check("h1 stream+progress: body drains intact and progress summed",
|
|
2364
|
+
drained.equals(payload) && last != null && last.loaded === payload.length);
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// ---- h1 wall-clock timeout (TimeoutError abort arm) -----------------
|
|
2369
|
+
//
|
|
2370
|
+
// A small timeoutMs to a server that never responds trips the wall-clock
|
|
2371
|
+
// AbortSignal (reason.name === "TimeoutError"), distinct from the idle
|
|
2372
|
+
// req 'timeout'. The abort handler maps a TimeoutError reason to ETIMEDOUT.
|
|
2373
|
+
|
|
2374
|
+
async function testH1WallClockTimeout() {
|
|
2375
|
+
await _withServer(function () { /* hold open, never respond */ }, async function (base) {
|
|
2376
|
+
var err = await _expectReject("h1 wall-clock timeout: rejects ETIMEDOUT",
|
|
2377
|
+
b.httpClient.request({ url: base + "/hang", timeoutMs: 300,
|
|
2378
|
+
allowedProtocols: ALLOW, allowInternal: true }), "ETIMEDOUT");
|
|
2379
|
+
check("h1 wall-clock timeout: ETIMEDOUT flagged non-permanent (retryable)",
|
|
2380
|
+
err != null && err.permanent === false);
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// ---- h2 error observers, jar-throw, wall-clock timeout --------------
|
|
2385
|
+
|
|
2386
|
+
async function testH2ErrorObserversAndTimeout() {
|
|
2387
|
+
// h2 stream error WITH observer (server resets the stream).
|
|
2388
|
+
var streamStages = [];
|
|
2389
|
+
await _withH2cServer(function (stream) {
|
|
2390
|
+
stream.on("error", function () {});
|
|
2391
|
+
stream.close(http2.constants.NGHTTP2_INTERNAL_ERROR);
|
|
2392
|
+
}, async function (base) {
|
|
2393
|
+
await _expectReject("h2 stream error (observer): server reset surfaces a stream error",
|
|
2394
|
+
b.httpClient.request({ url: base + "/reset", preferH2: true,
|
|
2395
|
+
observer: function (s) { streamStages.push(s); },
|
|
2396
|
+
allowedProtocols: ALLOW, allowInternal: true }), /ERR_HTTP2|H2_STREAM_ERROR/);
|
|
2397
|
+
check("h2 observer: fired an error stage on stream reset", streamStages.indexOf("error") !== -1);
|
|
2398
|
+
});
|
|
2399
|
+
b.httpClient._resetForTest();
|
|
2400
|
+
|
|
2401
|
+
// h2 buffered download-transform error WITH observer.
|
|
2402
|
+
var xfStages = [];
|
|
2403
|
+
await _withH2cServer(function (stream) {
|
|
2404
|
+
stream.on("error", function () {});
|
|
2405
|
+
stream.respond({ ":status": 200 }); stream.end("ok");
|
|
2406
|
+
}, async function (base) {
|
|
2407
|
+
await _expectReject("h2 buffered transform error (observer): rejects H2_STREAM_ERROR",
|
|
2408
|
+
b.httpClient.request({ url: base + "/ok", preferH2: true,
|
|
2409
|
+
observer: function (s) { xfStages.push(s); },
|
|
2410
|
+
downloadTransform: function () {
|
|
2411
|
+
return new nodeStream.Transform({ transform: function (c, e, cb) { cb(new Error("xform boom")); } });
|
|
2412
|
+
},
|
|
2413
|
+
allowedProtocols: ALLOW, allowInternal: true }), "H2_STREAM_ERROR");
|
|
2414
|
+
check("h2 observer: fired an error stage on the transform-pipeline error",
|
|
2415
|
+
xfStages.indexOf("error") !== -1);
|
|
2416
|
+
});
|
|
2417
|
+
b.httpClient._resetForTest();
|
|
2418
|
+
|
|
2419
|
+
// h2 jar.setFromResponse throws — best-effort, request still resolves.
|
|
2420
|
+
var throwingJar = {
|
|
2421
|
+
cookieHeaderFor: function () { return null; },
|
|
2422
|
+
setFromResponse: function () { throw new Error("h2 jar boom"); },
|
|
2423
|
+
};
|
|
2424
|
+
await _withH2cServer(function (stream) {
|
|
2425
|
+
stream.on("error", function () {});
|
|
2426
|
+
stream.respond({ ":status": 200, "set-cookie": "h2x=1; Path=/" }); stream.end("ok");
|
|
2427
|
+
}, async function (base) {
|
|
2428
|
+
var r = await b.httpClient.request({ url: base + "/c", preferH2: true, jar: throwingJar,
|
|
2429
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2430
|
+
check("h2 jar-throw: a throwing jar is swallowed (request resolves 200)", r.statusCode === 200);
|
|
2431
|
+
});
|
|
2432
|
+
b.httpClient._resetForTest();
|
|
2433
|
+
|
|
2434
|
+
// h2 wall-clock timeout → TimeoutError abort arm → ETIMEDOUT.
|
|
2435
|
+
await _withH2cServer(function (stream) { stream.on("error", function () {}); /* never respond */ },
|
|
2436
|
+
async function (base) {
|
|
2437
|
+
await _expectReject("h2 wall-clock timeout: rejects ETIMEDOUT",
|
|
2438
|
+
b.httpClient.request({ url: base + "/hang", preferH2: true, timeoutMs: 300,
|
|
2439
|
+
allowedProtocols: ALLOW, allowInternal: true }), "ETIMEDOUT");
|
|
2440
|
+
});
|
|
2441
|
+
b.httpClient._resetForTest();
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// ---- cache: custom + suppressed status header -----------------------
|
|
2445
|
+
//
|
|
2446
|
+
// statusHeader renames (or, when null, suppresses) the cache-decision response
|
|
2447
|
+
// header. The decision is always mirrored on res.cacheStatus regardless.
|
|
2448
|
+
|
|
2449
|
+
async function testCacheStatusHeaderVariants() {
|
|
2450
|
+
await _withServer(function (req, res) {
|
|
2451
|
+
res.writeHead(200, { "Cache-Control": "max-age=60" }); res.end("body");
|
|
2452
|
+
}, async function (base) {
|
|
2453
|
+
// Custom header name.
|
|
2454
|
+
var cCustom = b.httpClient.cache.create({
|
|
2455
|
+
store: b.httpClient.cache.memoryStore({ maxBytes: 1048576, maxEntries: 64 }),
|
|
2456
|
+
statusHeader: "x-cache" });
|
|
2457
|
+
await b.httpClient.request({ url: base + "/c1", cache: cCustom, allowedProtocols: ALLOW, allowInternal: true });
|
|
2458
|
+
var h1 = await b.httpClient.request({ url: base + "/c1", cache: cCustom, allowedProtocols: ALLOW, allowInternal: true });
|
|
2459
|
+
check("cache: custom statusHeader carries the decision + default header absent",
|
|
2460
|
+
h1.headers["x-cache"] === "HIT" && h1.headers["x-blamejs-cache"] === undefined);
|
|
2461
|
+
|
|
2462
|
+
// Suppressed (null) — no decision header at all, cacheStatus still set.
|
|
2463
|
+
var cNone = b.httpClient.cache.create({
|
|
2464
|
+
store: b.httpClient.cache.memoryStore({ maxBytes: 1048576, maxEntries: 64 }),
|
|
2465
|
+
statusHeader: null });
|
|
2466
|
+
await b.httpClient.request({ url: base + "/c2", cache: cNone, allowedProtocols: ALLOW, allowInternal: true });
|
|
2467
|
+
var h2 = await b.httpClient.request({ url: base + "/c2", cache: cNone, allowedProtocols: ALLOW, allowInternal: true });
|
|
2468
|
+
check("cache: suppressed statusHeader emits no decision header but sets cacheStatus",
|
|
2469
|
+
h2.headers["x-blamejs-cache"] === undefined && h2.cacheStatus === "HIT");
|
|
2470
|
+
});
|
|
2471
|
+
b.httpClient._resetForTest();
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
// ---- cache: observability sinks that throw are drop-silent ----------
|
|
2475
|
+
//
|
|
2476
|
+
// The cache's _emit / _obsEvent calls are each wrapped drop-silent. A cache
|
|
2477
|
+
// whose observability sinks throw must never convert a cache decision into a
|
|
2478
|
+
// request failure — across miss, hit, stale-while-revalidate, stale-if-error,
|
|
2479
|
+
// and inline 304 revalidation.
|
|
2480
|
+
|
|
2481
|
+
async function testCacheObservabilityThrows() {
|
|
2482
|
+
function _throwingCache(extra) {
|
|
2483
|
+
var real = _newHttpCache(extra);
|
|
2484
|
+
var t = Object.create(real);
|
|
2485
|
+
t._emit = function () { throw new Error("cache emit boom"); };
|
|
2486
|
+
t._obsEvent = function () { throw new Error("cache obs boom"); };
|
|
2487
|
+
return t;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// miss + hit (max-age fresh).
|
|
2491
|
+
await _withServer(function (req, res) {
|
|
2492
|
+
res.writeHead(200, { "Cache-Control": "max-age=60" }); res.end("ok");
|
|
2493
|
+
}, async function (base) {
|
|
2494
|
+
var c = _throwingCache();
|
|
2495
|
+
var m = await b.httpClient.request({ url: base + "/m", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2496
|
+
var h = await b.httpClient.request({ url: base + "/m", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2497
|
+
check("cache throwing-sinks: miss+hit still succeed",
|
|
2498
|
+
m.headers["x-blamejs-cache"] === "MISS" && h.headers["x-blamejs-cache"] === "HIT");
|
|
2499
|
+
});
|
|
2500
|
+
|
|
2501
|
+
// stale-while-revalidate served STALE (background refresh).
|
|
2502
|
+
var swrHits = 0;
|
|
2503
|
+
await _withServer(function (req, res) {
|
|
2504
|
+
swrHits += 1;
|
|
2505
|
+
res.writeHead(200, { "Cache-Control": "max-age=0, stale-while-revalidate=60", "ETag": '"sw' + swrHits + '"' });
|
|
2506
|
+
res.end("swr");
|
|
2507
|
+
}, async function (base) {
|
|
2508
|
+
var c = _throwingCache({ revalidateInBackground: true });
|
|
2509
|
+
await b.httpClient.request({ url: base + "/swr", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2510
|
+
var s = await b.httpClient.request({ url: base + "/swr", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2511
|
+
check("cache throwing-sinks: swr still serves STALE", s.headers["x-blamejs-cache"] === "STALE");
|
|
2512
|
+
await helpers.waitUntil(function () { return swrHits >= 2; },
|
|
2513
|
+
{ timeoutMs: 5000, label: "cache throwing-sinks swr: background revalidation reached upstream" });
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
// stale-if-error serves STALE when revalidation fails.
|
|
2517
|
+
await _withServer(function (req, res) {
|
|
2518
|
+
if (req.headers["if-none-match"]) { req.destroy(); return; }
|
|
2519
|
+
res.writeHead(200, { "Cache-Control": "max-age=0, stale-if-error=60", "ETag": '"sie"' });
|
|
2520
|
+
res.end("sie");
|
|
2521
|
+
}, async function (base) {
|
|
2522
|
+
var c = _throwingCache();
|
|
2523
|
+
await b.httpClient.request({ url: base + "/sie", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2524
|
+
var r = await b.httpClient.request({ url: base + "/sie", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2525
|
+
check("cache throwing-sinks: stale-if-error still serves STALE",
|
|
2526
|
+
r.headers["x-blamejs-cache"] === "STALE");
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
// inline 304 revalidation → REVALIDATED.
|
|
2530
|
+
await _withServer(function (req, res) {
|
|
2531
|
+
if (req.headers["if-none-match"] === '"v1"') { res.writeHead(304, { ETag: '"v1"' }); res.end(); return; }
|
|
2532
|
+
res.writeHead(200, { "Cache-Control": "max-age=0", "ETag": '"v1"' }); res.end("rev-body");
|
|
2533
|
+
}, async function (base) {
|
|
2534
|
+
var c = _throwingCache();
|
|
2535
|
+
await b.httpClient.request({ url: base + "/rev", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2536
|
+
var r = await b.httpClient.request({ url: base + "/rev", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2537
|
+
check("cache throwing-sinks: inline 304 still marks REVALIDATED",
|
|
2538
|
+
r.headers["x-blamejs-cache"] === "REVALIDATED" && r.body.toString() === "rev-body");
|
|
2539
|
+
});
|
|
2540
|
+
b.httpClient._resetForTest();
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// ---- cache: malformed stored entry treated as a miss ----------------
|
|
2544
|
+
//
|
|
2545
|
+
// If _evaluateStored throws on a hit (a corrupt entry), the cache drops the
|
|
2546
|
+
// entry (invalidate) and falls back to the network as a fresh MISS rather than
|
|
2547
|
+
// surfacing the corruption as a request failure.
|
|
2548
|
+
|
|
2549
|
+
async function testCacheMalformedEntry() {
|
|
2550
|
+
var upstream = 0;
|
|
2551
|
+
await _withServer(function (req, res) {
|
|
2552
|
+
upstream += 1;
|
|
2553
|
+
res.writeHead(200, { "Cache-Control": "max-age=60" }); res.end("net" + upstream);
|
|
2554
|
+
}, async function (base) {
|
|
2555
|
+
var real = _newHttpCache();
|
|
2556
|
+
var c = Object.create(real);
|
|
2557
|
+
c._evaluateStored = function () { throw new Error("corrupt entry"); };
|
|
2558
|
+
await b.httpClient.request({ url: base + "/e", cache: c, allowedProtocols: ALLOW, allowInternal: true }); // MISS + store
|
|
2559
|
+
var r = await b.httpClient.request({ url: base + "/e", cache: c, allowedProtocols: ALLOW, allowInternal: true }); // corrupt → MISS
|
|
2560
|
+
check("cache malformed-entry: a throwing _evaluateStored falls back to a network MISS",
|
|
2561
|
+
r.headers["x-blamejs-cache"] === "MISS" && upstream === 2);
|
|
2562
|
+
});
|
|
2563
|
+
b.httpClient._resetForTest();
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
// ---- cache: _refreshFrom304 returning falsy uses the stored entry ---
|
|
2567
|
+
//
|
|
2568
|
+
// When the 304 merge helper returns a falsy value, the revalidated response
|
|
2569
|
+
// falls back to the original stored entry (the `rev.refreshed || entry` arms).
|
|
2570
|
+
|
|
2571
|
+
async function testCacheRefreshFalsy() {
|
|
2572
|
+
await _withServer(function (req, res) {
|
|
2573
|
+
if (req.headers["if-none-match"] === '"r1"') { res.writeHead(304, { ETag: '"r1"' }); res.end(); return; }
|
|
2574
|
+
res.writeHead(200, { "Cache-Control": "max-age=0", "ETag": '"r1"' }); res.end("stored-body");
|
|
2575
|
+
}, async function (base) {
|
|
2576
|
+
var real = _newHttpCache();
|
|
2577
|
+
var c = Object.create(real);
|
|
2578
|
+
c._refreshFrom304 = function () { return undefined; }; // falsy → fall back to stored entry
|
|
2579
|
+
await b.httpClient.request({ url: base + "/rf", cache: c, allowedProtocols: ALLOW, allowInternal: true }); // MISS + store
|
|
2580
|
+
var r = await b.httpClient.request({ url: base + "/rf", cache: c, allowedProtocols: ALLOW, allowInternal: true }); // 304 → stored entry
|
|
2581
|
+
check("cache refresh-falsy: 304 with a falsy refresh serves the stored entry, REVALIDATED",
|
|
2582
|
+
r.headers["x-blamejs-cache"] === "REVALIDATED" && r.body.toString() === "stored-body");
|
|
2583
|
+
});
|
|
2584
|
+
b.httpClient._resetForTest();
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
// ---- multipart: a Buffer field value -------------------------------
|
|
2588
|
+
//
|
|
2589
|
+
// A field value that is already a Buffer is used directly (the Buffer.isBuffer
|
|
2590
|
+
// true arm) rather than String()-coerced.
|
|
2591
|
+
|
|
2592
|
+
async function testMultipartBufferFieldValue() {
|
|
2593
|
+
var body = null;
|
|
2594
|
+
await _withServer(function (req, res) {
|
|
2595
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
2596
|
+
req.on("end", function () { body = Buffer.concat(chunks).toString("utf8"); res.writeHead(200); res.end("ok"); });
|
|
2597
|
+
}, async function (base) {
|
|
2598
|
+
var r = await b.httpClient.request({ url: base + "/mp",
|
|
2599
|
+
multipart: { fields: { blob: Buffer.from("BUFFER-FIELD-VALUE") } },
|
|
2600
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2601
|
+
check("multipart: a Buffer field value is emitted verbatim",
|
|
2602
|
+
r.statusCode === 200 && (body || "").indexOf("BUFFER-FIELD-VALUE") !== -1);
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
// ---- h2 headers: inherited-prototype key skipped --------------------
|
|
2607
|
+
//
|
|
2608
|
+
// _toH2Headers guards its for-in with hasOwnProperty; a headers object with an
|
|
2609
|
+
// inherited enumerable key must not forward it as an h2 header.
|
|
2610
|
+
|
|
2611
|
+
async function testH2InheritedHeaderKey() {
|
|
2612
|
+
var sawInherited = "unset", sawOwn = "unset";
|
|
2613
|
+
await _withH2cServer(function (stream, headers) {
|
|
2614
|
+
sawInherited = headers["x-inherited"] || "absent";
|
|
2615
|
+
sawOwn = headers["x-own"] || "absent";
|
|
2616
|
+
stream.respond({ ":status": 200 }); stream.end("ok");
|
|
2617
|
+
}, async function (base) {
|
|
2618
|
+
var h = Object.create({ "x-inherited": "leaked" });
|
|
2619
|
+
h["x-own"] = "kept";
|
|
2620
|
+
var r = await b.httpClient.request({ url: base + "/h", preferH2: true, headers: h,
|
|
2621
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2622
|
+
check("h2 headers: inherited prototype key not forwarded; own key forwarded",
|
|
2623
|
+
r.statusCode === 200 && sawInherited === "absent" && sawOwn === "kept");
|
|
2624
|
+
});
|
|
2625
|
+
b.httpClient._resetForTest();
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
// ---- throttled upload with a string body ---------------------------
|
|
2629
|
+
//
|
|
2630
|
+
// A non-stream string body routed through the throttle stages is coerced via
|
|
2631
|
+
// Buffer.from(String(body)) (the Buffer.isBuffer false arm of _pipeThrottledUpload).
|
|
2632
|
+
|
|
2633
|
+
async function testThrottledUploadStringBody() {
|
|
2634
|
+
var received = null;
|
|
2635
|
+
await _withServer(function (req, res) {
|
|
2636
|
+
var chunks = []; req.on("data", function (c) { chunks.push(c); });
|
|
2637
|
+
req.on("end", function () { received = Buffer.concat(chunks).toString("utf8"); res.writeHead(200); res.end("ok"); });
|
|
2638
|
+
}, async function (base) {
|
|
2639
|
+
var r = await b.httpClient.request({ url: base + "/u", method: "POST",
|
|
2640
|
+
body: "THROTTLED-STRING-BODY", maxBytesPerSec: 1024,
|
|
2641
|
+
allowedProtocols: ALLOW, allowInternal: true });
|
|
2642
|
+
check("throttle: a string upload body is coerced + delivered intact",
|
|
2643
|
+
r.statusCode === 200 && received === "THROTTLED-STRING-BODY");
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// ---- _getCachedTransportKind accepts a URL object ------------------
|
|
2648
|
+
|
|
2649
|
+
function testGetCachedTransportKindUrlObject() {
|
|
2650
|
+
check("diagnostic: _getCachedTransportKind accepts a URL object (no cached transport → null)",
|
|
2651
|
+
b.httpClient._getCachedTransportKind(new URL("http://origin.invalid/")) === null);
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// ---- downloadStream: generic (non-httpclient) pipeline failure -----
|
|
2655
|
+
//
|
|
2656
|
+
// A mid-body socket destroy makes streamPromises.pipeline reject with a Node
|
|
2657
|
+
// system error (ECONNRESET), not an HttpClientError — the catch re-wraps it via
|
|
2658
|
+
// _hcErr(e.code || ..., e.message || ...) rather than rethrowing as-is, and the
|
|
2659
|
+
// tmp file is cleaned up + a refused audit emits.
|
|
2660
|
+
|
|
2661
|
+
async function testDownloadStreamGenericPipelineError() {
|
|
2662
|
+
var dir = b.testing.tempDir("httpclient-cov-plgen");
|
|
2663
|
+
try {
|
|
2664
|
+
await _withServer(function (req, res) {
|
|
2665
|
+
res.writeHead(200, { "Content-Length": "100000" });
|
|
2666
|
+
res.write(Buffer.alloc(200, 1));
|
|
2667
|
+
setTimeout(function () { try { res.socket.destroy(); } catch (_e) { /* noop */ } }, 20);
|
|
2668
|
+
}, async function (base) {
|
|
2669
|
+
var dest = helpers.path.join(dir.path, "partial.bin");
|
|
2670
|
+
var audit = _mkAuditCapture();
|
|
2671
|
+
var err = await _expectReject("downloadStream: mid-body socket destroy rejects a wrapped pipeline error",
|
|
2672
|
+
b.httpClient.downloadStream({ url: base + "/f", dest: dest, audit: audit,
|
|
2673
|
+
allowedProtocols: ALLOW, allowInternal: true }), /ECONNRESET|pipeline/i);
|
|
2674
|
+
check("downloadStream: generic pipeline error is wrapped as an HttpClientError",
|
|
2675
|
+
err != null && err.isHttpClientError === true);
|
|
2676
|
+
check("downloadStream: partial download left no dest file", !helpers.fs.existsSync(dest));
|
|
2677
|
+
check("downloadStream: pipeline failure emitted a refused audit event",
|
|
2678
|
+
audit.events.some(function (e) {
|
|
2679
|
+
return e.action === "system.httpclient.download_stream.refused" && e.metadata.reason === "pipeline-failed";
|
|
2680
|
+
}));
|
|
2681
|
+
});
|
|
2682
|
+
} finally {
|
|
2683
|
+
dir.cleanup();
|
|
2684
|
+
b.httpClient._resetForTest();
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// ---- downloadStream: a throwing audit sink is swallowed ------------
|
|
2689
|
+
//
|
|
2690
|
+
// _emitAudit wraps the sink call try/catch — an audit sink that throws on a
|
|
2691
|
+
// refused download must not mask the original request failure.
|
|
2692
|
+
|
|
2693
|
+
async function testDownloadStreamAuditThrows() {
|
|
2694
|
+
var dir = b.testing.tempDir("httpclient-cov-auditthrow");
|
|
2695
|
+
try {
|
|
2696
|
+
var throwingAudit = { safeEmit: function () { throw new Error("download audit boom"); } };
|
|
2697
|
+
b.httpClient._resetForTest();
|
|
2698
|
+
var e = null;
|
|
2699
|
+
try {
|
|
2700
|
+
await b.httpClient.downloadStream({ url: "http://127.0.0.1:9/x",
|
|
2701
|
+
dest: helpers.path.join(dir.path, "x.bin"),
|
|
2702
|
+
audit: throwingAudit, allowedProtocols: ALLOW, allowInternal: true });
|
|
2703
|
+
} catch (err) { e = err; }
|
|
2704
|
+
check("downloadStream: a throwing audit sink is swallowed; the original failure still surfaces",
|
|
2705
|
+
e != null);
|
|
2706
|
+
} finally {
|
|
2707
|
+
dir.cleanup();
|
|
2708
|
+
b.httpClient._resetForTest();
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
// ---- before hook throwing a falsy value (String(e) message arm) ----
|
|
2713
|
+
|
|
2714
|
+
async function testBeforeHookFalsyThrow() {
|
|
2715
|
+
var err = await _expectReject("before: a hook throwing a message-less error surfaces BEFORE_THREW",
|
|
2716
|
+
b.httpClient.request({ url: "https://x.example/",
|
|
2717
|
+
before: [function () { throw new Error(""); }] }), "BEFORE_THREW");
|
|
2718
|
+
check("before: empty-message throw uses the String(e) fallback",
|
|
2719
|
+
err != null && /before\[0\] threw: Error$/.test(err.message));
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// ---- cache: additional reachable branches --------------------------
|
|
2723
|
+
//
|
|
2724
|
+
// (a) a Last-Modified (no ETag) stored entry drives an If-Modified-Since
|
|
2725
|
+
// conditional revalidation; (b) a no-store response is evaluated
|
|
2726
|
+
// non-cacheable so nothing is stored (every request is a MISS);
|
|
2727
|
+
// (c) a _lookup that throws is treated as a miss (drop-silent); (d) a
|
|
2728
|
+
// _refreshFrom304 that throws falls back to the stored entry.
|
|
2729
|
+
|
|
2730
|
+
async function testCacheMoreBranches() {
|
|
2731
|
+
// (a) Last-Modified conditional revalidation.
|
|
2732
|
+
var sawIMS = false;
|
|
2733
|
+
await _withServer(function (req, res) {
|
|
2734
|
+
if (req.headers["if-modified-since"]) { sawIMS = true; res.writeHead(304); res.end(); return; }
|
|
2735
|
+
res.writeHead(200, { "Cache-Control": "max-age=0", "Last-Modified": "Wed, 21 Oct 2020 07:28:00 GMT" });
|
|
2736
|
+
res.end("lm-body");
|
|
2737
|
+
}, async function (base) {
|
|
2738
|
+
var c = _newHttpCache();
|
|
2739
|
+
await b.httpClient.request({ url: base + "/lm", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2740
|
+
var r = await b.httpClient.request({ url: base + "/lm", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2741
|
+
check("cache: Last-Modified entry sends If-Modified-Since + REVALIDATEs",
|
|
2742
|
+
sawIMS === true && r.headers["x-blamejs-cache"] === "REVALIDATED" && r.body.toString() === "lm-body");
|
|
2743
|
+
});
|
|
2744
|
+
|
|
2745
|
+
// (b) no-store response is not cacheable (every request reaches upstream).
|
|
2746
|
+
var nsHits = 0;
|
|
2747
|
+
await _withServer(function (req, res) {
|
|
2748
|
+
nsHits += 1; res.writeHead(200, { "Cache-Control": "no-store" }); res.end("ns");
|
|
2749
|
+
}, async function (base) {
|
|
2750
|
+
var c = _newHttpCache();
|
|
2751
|
+
await b.httpClient.request({ url: base + "/ns", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2752
|
+
var r = await b.httpClient.request({ url: base + "/ns", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2753
|
+
check("cache: a no-store response is never stored (both requests are MISS)",
|
|
2754
|
+
nsHits === 2 && r.headers["x-blamejs-cache"] === "MISS");
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2757
|
+
// (c) a _lookup that throws is treated as a miss.
|
|
2758
|
+
await _withServer(function (req, res) {
|
|
2759
|
+
res.writeHead(200, { "Cache-Control": "max-age=60" }); res.end("ok");
|
|
2760
|
+
}, async function (base) {
|
|
2761
|
+
var real = _newHttpCache();
|
|
2762
|
+
var c = Object.create(real);
|
|
2763
|
+
c._lookup = function () { throw new Error("lookup boom"); };
|
|
2764
|
+
var r = await b.httpClient.request({ url: base + "/lk", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2765
|
+
check("cache: a throwing _lookup is treated as a MISS", r.headers["x-blamejs-cache"] === "MISS");
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
// (d) a _refreshFrom304 that throws falls back to the stored entry.
|
|
2769
|
+
await _withServer(function (req, res) {
|
|
2770
|
+
if (req.headers["if-none-match"] === '"r1"') { res.writeHead(304, { ETag: '"r1"' }); res.end(); return; }
|
|
2771
|
+
res.writeHead(200, { "Cache-Control": "max-age=0", "ETag": '"r1"' }); res.end("rbody");
|
|
2772
|
+
}, async function (base) {
|
|
2773
|
+
var real = _newHttpCache();
|
|
2774
|
+
var c = Object.create(real);
|
|
2775
|
+
c._refreshFrom304 = function () { throw new Error("refresh boom"); };
|
|
2776
|
+
await b.httpClient.request({ url: base + "/rt", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2777
|
+
var r = await b.httpClient.request({ url: base + "/rt", cache: c, allowedProtocols: ALLOW, allowInternal: true });
|
|
2778
|
+
check("cache: a throwing _refreshFrom304 falls back to the stored entry (REVALIDATED)",
|
|
2779
|
+
r.headers["x-blamejs-cache"] === "REVALIDATED" && r.body.toString() === "rbody");
|
|
2780
|
+
});
|
|
2781
|
+
b.httpClient._resetForTest();
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// Destroy the httpClient transport pool and wait for every TCP handle to
|
|
2785
|
+
// close, so the async teardown completes inside run() rather than in the
|
|
2786
|
+
// forked worker's post-run grace window. Poll, don't sleep.
|
|
2787
|
+
async function _drainTcpHandles() {
|
|
2788
|
+
b.httpClient._resetForTest();
|
|
2789
|
+
if (typeof process.getActiveResourcesInfo !== "function") return;
|
|
2790
|
+
await helpers.waitUntil(function () {
|
|
2791
|
+
return process.getActiveResourcesInfo().filter(function (t) {
|
|
2792
|
+
return t === "TCPSocketWrap" || t === "TCPServerWrap";
|
|
2793
|
+
}).length === 0;
|
|
2794
|
+
}, { timeoutMs: 5000, label: "http-client: TCP handle drain after _resetForTest" });
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
async function run() {
|
|
2798
|
+
try {
|
|
2799
|
+
testSurface();
|
|
2800
|
+
await testConfigurePool();
|
|
2801
|
+
await testArgValidation();
|
|
2802
|
+
await testBeforeAfterInterceptors();
|
|
2803
|
+
await testAllowedHosts();
|
|
2804
|
+
await testBufferedErrorBranches();
|
|
2805
|
+
await testProgressAndObserver();
|
|
2806
|
+
await testTimeoutAbortConnError();
|
|
2807
|
+
await testRequestBodyStreamError();
|
|
2808
|
+
await testCookieJar();
|
|
2809
|
+
testCookieJarParseStore();
|
|
2810
|
+
testCookieJarFilePersist();
|
|
2811
|
+
await testRedirects();
|
|
2812
|
+
await testCrossOriginStrip();
|
|
2813
|
+
await testProxyMetadataBlock();
|
|
2814
|
+
await testH2cPaths();
|
|
2815
|
+
await testMultipartValidRoundTrip();
|
|
2816
|
+
await testDownloadMaxBytes();
|
|
2817
|
+
testPinnedLookupContract();
|
|
2818
|
+
await testMultipartBuildBranches();
|
|
2819
|
+
await testJarCookieMerge();
|
|
2820
|
+
await testStreamModeHttpError();
|
|
2821
|
+
await testThrottleAndTransformH1();
|
|
2822
|
+
await testUploadProgressPipedBody();
|
|
2823
|
+
await testOnChunkThrowDropSilent();
|
|
2824
|
+
await testDownloadTransformErrorH1();
|
|
2825
|
+
await testDownloadStreamLifecycle();
|
|
2826
|
+
await testUploadMultipartStreamLifecycle();
|
|
2827
|
+
await testCachePaths();
|
|
2828
|
+
await testH2cExtended();
|
|
2829
|
+
await testH2cTimeoutAbortConnErr();
|
|
2830
|
+
await testH1AbortOnWire();
|
|
2831
|
+
testDefaultPortKeys();
|
|
2832
|
+
await testThrottledUploadBodyError();
|
|
2833
|
+
await testH2PreAbortAndTeardown();
|
|
2834
|
+
await testMoreArgValidation();
|
|
2835
|
+
await testPermanentFlag();
|
|
2836
|
+
await testMaxRedirectsZero();
|
|
2837
|
+
await test301302Coercion();
|
|
2838
|
+
await testAllowedHostsEdges();
|
|
2839
|
+
await testUploadStreamFilenameDefaults();
|
|
2840
|
+
await testDownloadProgressNoContentLength();
|
|
2841
|
+
await testConfigurePoolInheritedKey();
|
|
2842
|
+
testPinnedLookupExtraShapes();
|
|
2843
|
+
await testHttpsCallerAgent();
|
|
2844
|
+
await testHttpsFrameworkTransportError();
|
|
2845
|
+
await testProxyRequestPath();
|
|
2846
|
+
await testRedirectParseEdgeCases();
|
|
2847
|
+
await testAdversarialAuditOnHostDeny();
|
|
2848
|
+
await testH1BestEffortHooksThrow();
|
|
2849
|
+
await testH1StreamModeDownloadProgress();
|
|
2850
|
+
await testH1WallClockTimeout();
|
|
2851
|
+
await testH2ErrorObserversAndTimeout();
|
|
2852
|
+
await testCacheStatusHeaderVariants();
|
|
2853
|
+
await testCacheObservabilityThrows();
|
|
2854
|
+
await testCacheMalformedEntry();
|
|
2855
|
+
await testCacheRefreshFalsy();
|
|
2856
|
+
await testMultipartBufferFieldValue();
|
|
2857
|
+
await testH2InheritedHeaderKey();
|
|
2858
|
+
await testThrottledUploadStringBody();
|
|
2859
|
+
testGetCachedTransportKindUrlObject();
|
|
2860
|
+
await testDownloadStreamGenericPipelineError();
|
|
2861
|
+
await testDownloadStreamAuditThrows();
|
|
2862
|
+
await testBeforeHookFalsyThrow();
|
|
2863
|
+
await testCacheMoreBranches();
|
|
2864
|
+
} finally {
|
|
2865
|
+
await _drainTcpHandles();
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
module.exports = { run: run };
|
|
2870
|
+
|
|
2871
|
+
if (require.main === module) {
|
|
2872
|
+
run().then(function () { console.log("OK http-client — " + helpers.getChecks() + " checks"); })
|
|
2873
|
+
.catch(function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); });
|
|
2874
|
+
}
|