diff --git a/package-lock.json b/package-lock.json index b950304f7..01b9854ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "@types/plist": "^3.0.5", "@typescript-eslint/eslint-plugin": "^8.63.0", "@typescript-eslint/parser": "^8.63.0", - "axios": "^1.18.1", "commander": "^15.0.0", "escape-html": "^1.0.3", "eslint": "^10.7.0", @@ -2470,19 +2469,6 @@ "node": ">=0.4.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2586,26 +2572,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/babel-jest": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", @@ -2864,20 +2830,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3085,19 +3037,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -3193,16 +3132,6 @@ "node": ">=0.10.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3235,21 +3164,6 @@ "node": ">=6" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -3313,55 +3227,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3826,27 +3691,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3864,23 +3708,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/from": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", @@ -3909,16 +3736,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/futoin-hkdf": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.5.3.tgz", @@ -3948,31 +3765,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -3983,20 +3775,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -4084,19 +3862,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4136,48 +3901,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/hexy": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.4.0.tgz", @@ -4204,20 +3927,6 @@ "dev": true, "license": "MIT" }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -5310,16 +5019,6 @@ "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -5334,29 +5033,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -5860,16 +5536,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index a44e9ab20..3788a7fc4 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "@types/plist": "^3.0.5", "@typescript-eslint/eslint-plugin": "^8.63.0", "@typescript-eslint/parser": "^8.63.0", - "axios": "^1.18.1", "commander": "^15.0.0", "escape-html": "^1.0.3", "eslint": "^10.7.0", diff --git a/src/lib/HAPServer.spec.ts b/src/lib/HAPServer.spec.ts index e42884490..d6d5100d8 100644 --- a/src/lib/HAPServer.spec.ts +++ b/src/lib/HAPServer.spec.ts @@ -1,9 +1,7 @@ -import axios, { AxiosError, AxiosResponse } from "axios"; import crypto from "crypto"; import createDebug from "debug"; -import { Agent } from "http"; import tweetnacl from "tweetnacl"; -import { PairingStates, PairMethods, TLVValues } from "../internal-types"; +import { HAPMimeTypes, PairingStates, PairMethods, TLVValues } from "../internal-types"; import { HAPHTTPClient } from "../test-utils/HAPHTTPClient"; import { PairSetupClient } from "../test-utils/PairSetupClient"; import { PairVerifyClient } from "../test-utils/PairVerifyClient"; @@ -58,15 +56,24 @@ describe("HAPServer", () => { publicKey: Buffer.alloc(0), }; - let httpAgent: Agent; + let clients: HAPHTTPClient[]; let server: HAPServer; - async function bindServer(server: HAPServer, port = 0, host = "localhost"): Promise<[port: number, string: string]> { + async function bindServer(server: HAPServer, port = 0, host = "localhost"): Promise<[port: number, address: string]> { const listenPromise: Promise<[number, string]> = awaitEventOnce(server, HAPServerEventTypes.LISTENING); server.listen(port, host); return await listenPromise; } + // Creates a connected client and registers it for teardown. One client instance owns one TCP connection for its + // entire lifetime, which is what binds the server's per-connection pairing state to the requests a test sends. + async function connectClient(port: number, address = "localhost"): Promise { + const client = new HAPHTTPClient(address, port); + await client.connect(); + clients.push(client); + return client; + } + beforeEach(() => { accessoryInfoUnpaired = AccessoryInfo.create(serverUsername); // @ts-expect-error: private access @@ -89,13 +96,13 @@ describe("HAPServer", () => { clientInfo.publicKey = Buffer.from(clientKeyPair.publicKey); accessoryInfoPaired.addPairedClient(clientUsername, clientInfo.publicKey, PermissionTypes.ADMIN); - // used to do long living http connections without own tcp interface - httpAgent = new Agent({ - keepAlive: true, - }); + clients = []; }); afterEach(() => { + for (const client of clients) { + client.destroy(); + } server?.stop(); server?.destroy(); }); @@ -106,27 +113,28 @@ describe("HAPServer", () => { const promise: Promise = awaitEventOnce(server, HAPServerEventTypes.IDENTIFY); - const request: Promise> = axios.post(`http://localhost:${port}/identify`, { httpAgent }); + // The identify response is only sent once the event callback fires, so the request bytes go out first, the event is + // handled, and only then is the response read off the connection. + const client = await connectClient(port); + client.write(client.formatHTTPRequest("POST", "/identify")); const callback = await promise; callback(); // signal successful identify! - const response = await request; - expect(response.data).toBeFalsy(); + const response = await client.readHTTPResponse(); + expect(response.statusCode).toBe(HAPHTTPCode.NO_CONTENT); + expect(response.body.length).toBe(0); }); test("reject unpaired identify on paired server", async () => { server = new HAPServer(accessoryInfoPaired); const [port] = await bindServer(server); - try { - const response = await axios.post(`http://localhost:${port}/identify`, { httpAgent }); - fail(`Expected erroneous response, got ${response}`); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPPairingHTTPCode.BAD_REQUEST); - expect(error.response?.data).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); - } + const client = await connectClient(port); + + const response = await client.writeHTTPRequest("POST", "/identify"); + expect(response.statusCode).toBe(HAPPairingHTTPCode.BAD_REQUEST); + expect(JSON.parse(response.body.toString())).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); }); test("test-utils successful /pair-setup", async () => { @@ -139,7 +147,7 @@ describe("HAPServer", () => { callback(); }); - const pairSetup = new PairSetupClient(port, httpAgent); + const pairSetup = new PairSetupClient(await connectClient(port)); const M6 = await pairSetup.sendPairSetup(accessoryInfoPaired.pincode, clientInfo); @@ -151,10 +159,11 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoPaired); const [port] = await bindServer(server); - const pairSetup = new PairSetupClient(port, httpAgent); + const pairSetup = new PairSetupClient(await connectClient(port)); const response = await pairSetup.sendM1(); - const objectsM2 = tlv.decode(response.data); + expect(response.statusCode).toBe(HAPPairingHTTPCode.OK); + const objectsM2 = tlv.decode(response.body); expect(objectsM2[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); expect(objectsM2[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNAVAILABLE); }); @@ -163,52 +172,42 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - const pairSetup = new PairSetupClient(port, httpAgent); + const pairSetup = new PairSetupClient(await connectClient(port)); const M3 = await pairSetup.prepareM3({ salt: crypto.randomBytes(16), serverPublicKey: crypto.randomBytes(384), }, accessoryInfoUnpaired.pincode); - try { - const response = await pairSetup.sendM3(M3); - fail(`Expected erroneous response, got ${response}`); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPHTTPCode.BAD_REQUEST); + const response = await pairSetup.sendM3(M3); + expect(response.statusCode).toBe(HAPHTTPCode.BAD_REQUEST); - const objectsM4 = tlv.decode(error.response?.data); - expect(objectsM4[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M4); - expect(objectsM4[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); - } + const objectsM4 = tlv.decode(response.body); + expect(objectsM4[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M4); + expect(objectsM4[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); }); test("reject pair-setup with state M5", async () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - const pairSetup = new PairSetupClient(port, httpAgent); + const pairSetup = new PairSetupClient(await connectClient(port)); const M5 = await pairSetup.prepareM5({ sharedSecret: crypto.randomBytes(256) }, clientInfo); - try { - const response = await pairSetup.sendM5(M5); - fail(`Expected erroneous response, got ${response}`); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPHTTPCode.BAD_REQUEST); + const response = await pairSetup.sendM5(M5); + expect(response.statusCode).toBe(HAPHTTPCode.BAD_REQUEST); - const objectsM4 = tlv.decode(error.response?.data); - expect(objectsM4[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M6); - expect(objectsM4[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); - } + const objectsM4 = tlv.decode(response.body); + expect(objectsM4[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M6); + expect(objectsM4[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); }); test("test successful /pair-verify", async () => { server = new HAPServer(accessoryInfoPaired); const [port] = await bindServer(server); - const pairVerify = new PairVerifyClient(port, httpAgent); + const pairVerify = new PairVerifyClient(await connectClient(port)); await pairVerify.sendPairVerify(serverInfoPaired, clientInfo); }); @@ -216,19 +215,20 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - const pairVerify = new PairVerifyClient(port, httpAgent); + const pairVerify = new PairVerifyClient(await connectClient(port)); // M1 const responseM1 = await pairVerify.sendM1(); // M2 - const M2 = pairVerify.parseM2(responseM1.data, serverInfoUnpaired); + const M2 = pairVerify.parseM2(responseM1.body, serverInfoUnpaired); // M3 const M3 = pairVerify.prepareM3(M2, clientInfo); const responseM3 = await pairVerify.sendM3(M3); - const objectsM4 = tlv.decode(responseM3.data); + expect(responseM3.statusCode).toBe(HAPPairingHTTPCode.OK); + const objectsM4 = tlv.decode(responseM3.body); expect(objectsM4[TLVValues.STATE].readUInt8(0)).toBe(PairingStates.M4); expect(objectsM4[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.AUTHENTICATION); @@ -239,23 +239,26 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - const pairSetup = new PairSetupClient(port, httpAgent); + const client = await connectClient(port); + const pairSetup = new PairSetupClient(client); // advance the connection state to M4 via real SRP M1 + M3 const responseM1 = await pairSetup.sendM1(); - const M2 = pairSetup.parseM2(responseM1.data); + const M2 = pairSetup.parseM2(responseM1.body); const M3 = await pairSetup.prepareM3(M2, accessoryInfoUnpaired.pincode); const responseM3 = await pairSetup.sendM3(M3); - pairSetup.parseM4(responseM3.data, M3); + pairSetup.parseM4(responseM3.body, M3); - // craft an M5 with no ENCRYPTED_DATA TLV - const response = await axios.post( - `http://localhost:${port}/pair-setup`, + // craft an M5 with no ENCRYPTED_DATA TLV, riding the same connection whose state the handshake advanced + const response = await client.writeHTTPRequest( + "POST", + "/pair-setup", tlv.encode(TLVValues.STATE, PairingStates.M5), - { httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.OK); - const objects = tlv.decode(response.data); + const objects = tlv.decode(response.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M4); expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.AUTHENTICATION); }); @@ -264,24 +267,27 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - const pairSetup = new PairSetupClient(port, httpAgent); + const client = await connectClient(port); + const pairSetup = new PairSetupClient(client); const responseM1 = await pairSetup.sendM1(); - const M2 = pairSetup.parseM2(responseM1.data); + const M2 = pairSetup.parseM2(responseM1.body); const M3 = await pairSetup.prepareM3(M2, accessoryInfoUnpaired.pincode); const responseM3 = await pairSetup.sendM3(M3); - pairSetup.parseM4(responseM3.data, M3); + pairSetup.parseM4(responseM3.body, M3); // 8 bytes is below the 16-byte minimum; without the guard this would underflow Buffer.alloc() - const response = await axios.post( - `http://localhost:${port}/pair-setup`, + const response = await client.writeHTTPRequest( + "POST", + "/pair-setup", tlv.encode( TLVValues.STATE, PairingStates.M5, TLVValues.ENCRYPTED_DATA, Buffer.alloc(8), ), - { httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.OK); - const objects = tlv.decode(response.data); + const objects = tlv.decode(response.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M4); expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.AUTHENTICATION); }); @@ -290,18 +296,21 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoPaired); const [port] = await bindServer(server); - const pairVerify = new PairVerifyClient(port, httpAgent); + const client = await connectClient(port); + const pairVerify = new PairVerifyClient(client); // advance connection state to M2 via M1 const responseM1 = await pairVerify.sendM1(); - pairVerify.parseM2(responseM1.data, serverInfoPaired); + pairVerify.parseM2(responseM1.body, serverInfoPaired); - const response = await axios.post( - `http://localhost:${port}/pair-verify`, + const response = await client.writeHTTPRequest( + "POST", + "/pair-verify", tlv.encode(TLVValues.STATE, PairingStates.M3), - { httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.OK); - const objects = tlv.decode(response.data); + const objects = tlv.decode(response.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M4); expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.AUTHENTICATION); }); @@ -310,20 +319,23 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoPaired); const [port] = await bindServer(server); - const pairVerify = new PairVerifyClient(port, httpAgent); + const client = await connectClient(port); + const pairVerify = new PairVerifyClient(client); const responseM1 = await pairVerify.sendM1(); - pairVerify.parseM2(responseM1.data, serverInfoPaired); + pairVerify.parseM2(responseM1.body, serverInfoPaired); - const response = await axios.post( - `http://localhost:${port}/pair-verify`, + const response = await client.writeHTTPRequest( + "POST", + "/pair-verify", tlv.encode( TLVValues.STATE, PairingStates.M3, TLVValues.ENCRYPTED_DATA, Buffer.alloc(15), ), - { httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.OK); - const objects = tlv.decode(response.data); + const objects = tlv.decode(response.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M4); expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.AUTHENTICATION); }); @@ -335,18 +347,15 @@ describe("HAPServer", () => { server.allowInsecureRequest = true; const [port] = await bindServer(server); - try { - await axios.put( - `http://localhost:${port}/characteristics`, - { characteristics: [{ aid: 1, iid: 9, value: true }] }, - { httpAgent }, - ); - fail("Expected CONNECTION_AUTHORIZATION_REQUIRED response"); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); - expect(error.response?.data).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); - } + const client = await connectClient(port); + + const response = await client.writeHTTPRequest( + "PUT", + "/characteristics", + Buffer.from(JSON.stringify({ characteristics: [{ aid: 1, iid: 9, value: true }] })), + ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); + expect(JSON.parse(response.body.toString())).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); }); test("PUT /characteristics with wrong-length authorization should not crash timingSafeEqual", async () => { @@ -354,20 +363,18 @@ describe("HAPServer", () => { server.allowInsecureRequest = true; const [port] = await bindServer(server); + const client = await connectClient(port); + // 5 bytes — different length to the 11-byte pincode. Without the // length guard added in 12aea013, crypto.timingSafeEqual throws // RangeError and the connection would be killed. - try { - await axios.put( - `http://localhost:${port}/characteristics`, - { characteristics: [{ aid: 1, iid: 9, value: true }] }, - { httpAgent, headers: { authorization: "short" } }, - ); - fail("Expected CONNECTION_AUTHORIZATION_REQUIRED response"); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); - } + const response = await client.writeHTTPRequest( + "PUT", + "/characteristics", + Buffer.from(JSON.stringify({ characteristics: [{ aid: 1, iid: 9, value: true }] })), + { headers: { authorization: "short" } }, + ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); }); test("PUT /characteristics with same-length wrong pincode should be rejected", async () => { @@ -387,17 +394,15 @@ describe("HAPServer", () => { const [port] = await bindServer(server); const wrongPincode = "999-99-999"; // same length as "031-45-154", different content - try { - await axios.put( - `http://localhost:${port}/characteristics`, - { characteristics: [{ aid: 1, iid: 9, value: true }] }, - { httpAgent, headers: { authorization: wrongPincode } }, - ); - fail("Expected CONNECTION_AUTHORIZATION_REQUIRED response"); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); - } + const client = await connectClient(port); + + const response = await client.writeHTTPRequest( + "PUT", + "/characteristics", + Buffer.from(JSON.stringify({ characteristics: [{ aid: 1, iid: 9, value: true }] })), + { headers: { authorization: wrongPincode } }, + ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); }); test("PUT /characteristics with correct pincode should be authorized", async () => { @@ -418,14 +423,17 @@ describe("HAPServer", () => { callback(undefined, { characteristics: writeRequest.characteristics.map(c => ({ aid: c.aid, iid: c.iid, status: HAPStatus.SUCCESS })) }); }); - const response = await axios.put( - `http://localhost:${port}/characteristics`, - { characteristics: [{ aid: 1, iid: 9, value: true }] }, - { httpAgent, headers: { authorization: info.pincode } }, + const client = await connectClient(port); + + const response = await client.writeHTTPRequest( + "PUT", + "/characteristics", + Buffer.from(JSON.stringify({ characteristics: [{ aid: 1, iid: 9, value: true }] })), + { headers: { authorization: info.pincode } }, ); // a successful insecure PUT returns 204 NO_CONTENT (or 200 if there's a response body) - expect(response.status).toBeGreaterThanOrEqual(200); - expect(response.status).toBeLessThan(300); + expect(response.statusCode).toBeGreaterThanOrEqual(200); + expect(response.statusCode).toBeLessThan(300); }); test("POST /resource with wrong-length authorization should not crash timingSafeEqual", async () => { @@ -433,18 +441,16 @@ describe("HAPServer", () => { server.allowInsecureRequest = true; const [port] = await bindServer(server); - try { - await axios.post( - `http://localhost:${port}/resource`, - { "resource-type": "image", "image-width": 100, "image-height": 100 }, - { httpAgent, headers: { authorization: "short" } }, - ); - fail("Expected CONNECTION_AUTHORIZATION_REQUIRED response"); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); - expect(error.response?.data).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); - } + const client = await connectClient(port); + + const response = await client.writeHTTPRequest( + "POST", + "/resource", + Buffer.from(JSON.stringify({ "resource-type": "image", "image-width": 100, "image-height": 100 })), + { headers: { authorization: "short" } }, + ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); + expect(JSON.parse(response.body.toString())).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); }); }); @@ -481,23 +487,27 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - const pairSetup = new PairSetupClient(port, httpAgent); + const client = await connectClient(port); + const pairSetup = new PairSetupClient(client); const responseM1 = await pairSetup.sendM1(); - const M2 = pairSetup.parseM2(responseM1.data); + const M2 = pairSetup.parseM2(responseM1.body); const M3 = await pairSetup.prepareM3(M2, accessoryInfoUnpaired.pincode); const responseM3 = await pairSetup.sendM3(M3); - pairSetup.parseM4(responseM3.data, M3); + pairSetup.parseM4(responseM3.body, M3); // craft an M5 with bogus ciphertext+tag (passes the >=16 length check - // from 0719059b but fails MAC verification) - await axios.post( - `http://localhost:${port}/pair-setup`, + // from 0719059b but fails MAC verification). Awaiting the response + // sequences the server handler before the captured-log assertions below. + const response = await client.writeHTTPRequest( + "POST", + "/pair-setup", tlv.encode( TLVValues.STATE, PairingStates.M5, TLVValues.ENCRYPTED_DATA, Buffer.alloc(48), // 32-byte payload + 16-byte tag, all zeros ), - { httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.OK); // captured rows: [namespace, formatString, ...formatArgs] const m5Lines = captured.filter(line => typeof line[1] === "string" @@ -519,18 +529,23 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoPaired); const [port] = await bindServer(server); - const pairVerify = new PairVerifyClient(port, httpAgent); + const client = await connectClient(port); + const pairVerify = new PairVerifyClient(client); const responseM1 = await pairVerify.sendM1(); - pairVerify.parseM2(responseM1.data, serverInfoPaired); + pairVerify.parseM2(responseM1.body, serverInfoPaired); - await axios.post( - `http://localhost:${port}/pair-verify`, + // Awaiting the response sequences the server handler before the + // captured-log assertions below. + const response = await client.writeHTTPRequest( + "POST", + "/pair-verify", tlv.encode( TLVValues.STATE, PairingStates.M3, TLVValues.ENCRYPTED_DATA, Buffer.alloc(48), ), - { httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.OK); const m3Lines = captured.filter(line => typeof line[1] === "string" && (line[1] as string).includes("M3: Failed to decrypt and/or verify")); @@ -550,50 +565,40 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - const pairSetup = new PairSetupClient(port, httpAgent); + const pairSetup = new PairSetupClient(await connectClient(port)); // first M1 succeeds → connection state advances to M2 const firstResponse = await pairSetup.sendM1(); - const firstObjects = tlv.decode(firstResponse.data); + const firstObjects = tlv.decode(firstResponse.body); expect(firstObjects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); expect(firstObjects[TLVValues.ERROR_CODE]).toBeUndefined(); // second M1 on the same connection — without the guard this would // restart pair-setup and overwrite the in-progress SRP server - try { - await pairSetup.sendM1(); - fail("Expected BAD_REQUEST response"); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPHTTPCode.BAD_REQUEST); - const objects = tlv.decode(error.response?.data); - // sequence + 1 = M2; UNKNOWN error - expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); - expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); - } + const secondResponse = await pairSetup.sendM1(); + expect(secondResponse.statusCode).toBe(HAPHTTPCode.BAD_REQUEST); + const objects = tlv.decode(secondResponse.body); + // sequence + 1 = M2; UNKNOWN error + expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); + expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); }); test("/pair-setup should still accept M1 on a fresh connection after a previous setup completed", async () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - // first connection: do an M1 and abort - const firstAgent = new Agent({ keepAlive: true }); - const firstClient = new PairSetupClient(port, firstAgent); + // first connection: do an M1 and abort by tearing the connection down + const firstConnection = await connectClient(port); + const firstClient = new PairSetupClient(firstConnection); await firstClient.sendM1(); - firstAgent.destroy(); + firstConnection.destroy(); // second connection: a fresh M1 should still succeed (no state pollution) - const secondAgent = new Agent({ keepAlive: true }); - try { - const secondClient = new PairSetupClient(port, secondAgent); - const response = await secondClient.sendM1(); - const objects = tlv.decode(response.data); - expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); - expect(objects[TLVValues.ERROR_CODE]).toBeUndefined(); - } finally { - secondAgent.destroy(); - } + const secondClient = new PairSetupClient(await connectClient(port)); + const response = await secondClient.sendM1(); + const objects = tlv.decode(response.body); + expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); + expect(objects[TLVValues.ERROR_CODE]).toBeUndefined(); }); }); @@ -602,42 +607,38 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); + const client = await connectClient(port); + // a TLV containing only METHOD (no SEQUENCE_NUM/STATE) — without the // guard this would crash on `tlvData[SEQUENCE_NUM][0]` of `undefined`. - try { - await axios.post( - `http://localhost:${port}/pair-setup`, - tlv.encode(TLVValues.METHOD, PairMethods.PAIR_SETUP), - { httpAgent, responseType: "arraybuffer" }, - ); - fail("Expected BAD_REQUEST response"); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPHTTPCode.BAD_REQUEST); - const objects = tlv.decode(error.response?.data); - expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); - expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); - } + const response = await client.writeHTTPRequest( + "POST", + "/pair-setup", + tlv.encode(TLVValues.METHOD, PairMethods.PAIR_SETUP), + { contentType: HAPMimeTypes.PAIRING_TLV8 }, + ); + expect(response.statusCode).toBe(HAPHTTPCode.BAD_REQUEST); + const objects = tlv.decode(response.body); + expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); + expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); }); test("/pair-verify should reject when SEQUENCE_NUM TLV is missing", async () => { server = new HAPServer(accessoryInfoPaired); const [port] = await bindServer(server); - try { - await axios.post( - `http://localhost:${port}/pair-verify`, - tlv.encode(TLVValues.PUBLIC_KEY, Buffer.alloc(32)), - { httpAgent, responseType: "arraybuffer" }, - ); - fail("Expected BAD_REQUEST response"); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPHTTPCode.BAD_REQUEST); - const objects = tlv.decode(error.response?.data); - expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); - expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); - } + const client = await connectClient(port); + + const response = await client.writeHTTPRequest( + "POST", + "/pair-verify", + tlv.encode(TLVValues.PUBLIC_KEY, Buffer.alloc(32)), + { contentType: HAPMimeTypes.PAIRING_TLV8 }, + ); + expect(response.statusCode).toBe(HAPHTTPCode.BAD_REQUEST); + const objects = tlv.decode(response.body); + expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); + expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); }); }); @@ -646,13 +647,14 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - const pairSetup = new PairSetupClient(port, httpAgent); + const client = await connectClient(port); + const pairSetup = new PairSetupClient(client); const responseM1 = await pairSetup.sendM1(); - const M2 = pairSetup.parseM2(responseM1.data); + const M2 = pairSetup.parseM2(responseM1.body); const M3 = await pairSetup.prepareM3(M2, accessoryInfoUnpaired.pincode); const responseM3 = await pairSetup.sendM3(M3); - const M4 = pairSetup.parseM4(responseM3.data, M3); + const M4 = pairSetup.parseM4(responseM3.body, M3); // build a sub-TLV missing IDENTIFIER (only PUBLIC_KEY + SIGNATURE) const malformedSubTLV = tlv.encode( @@ -671,16 +673,18 @@ describe("HAPServer", () => { sessionKey, Buffer.from("PS-Msg05"), null, malformedSubTLV, ); - const response = await axios.post( - `http://localhost:${port}/pair-setup`, + const response = await client.writeHTTPRequest( + "POST", + "/pair-setup", tlv.encode( TLVValues.STATE, PairingStates.M5, TLVValues.ENCRYPTED_DATA, Buffer.concat([encrypted.ciphertext, encrypted.authTag]), ), - { httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.OK); - const objects = tlv.decode(response.data); + const objects = tlv.decode(response.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M4); expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); }); @@ -689,9 +693,10 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoPaired); const [port] = await bindServer(server); - const pairVerify = new PairVerifyClient(port, httpAgent); + const client = await connectClient(port); + const pairVerify = new PairVerifyClient(client); const responseM1 = await pairVerify.sendM1(); - const M2 = pairVerify.parseM2(responseM1.data, serverInfoPaired); + const M2 = pairVerify.parseM2(responseM1.body, serverInfoPaired); // build a sub-TLV missing PROOF/SIGNATURE (only IDENTIFIER) const malformedSubTLV = tlv.encode( @@ -702,16 +707,18 @@ describe("HAPServer", () => { M2.sessionKey, Buffer.from("PV-Msg03"), null, malformedSubTLV, ); - const response = await axios.post( - `http://localhost:${port}/pair-verify`, + const response = await client.writeHTTPRequest( + "POST", + "/pair-verify", tlv.encode( TLVValues.STATE, PairingStates.M3, TLVValues.ENCRYPTED_DATA, Buffer.concat([encrypted.ciphertext, encrypted.authTag]), ), - { httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); + expect(response.statusCode).toBe(HAPPairingHTTPCode.OK); - const objects = tlv.decode(response.data); + const objects = tlv.decode(response.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M4); expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); }); @@ -729,19 +736,16 @@ describe("HAPServer", () => { port = addressInformation[0]; address = addressInformation[1]; - const pairVerify = new PairVerifyClient(port, httpAgent); - encryption = await pairVerify.sendPairVerify(serverInfoPaired, clientInfo); + // The client owns the connection end-to-end: pair-verify rides it, and the session keys the handshake derives are + // bound server-side to exactly this connection - which is why the encrypted session must continue on it. + client = await connectClient(port, address); - client = new HAPHTTPClient(httpAgent, address, port); - await client.attachSocket(); + const pairVerify = new PairVerifyClient(client); + encryption = await pairVerify.sendPairVerify(serverInfoPaired, clientInfo); client.enableEncryption(encryption); }); - afterEach(() => { - client.releaseSocket(); - }); - test("test /pairings ADD_PAIRING", async () => { const exampleKey = crypto.randomBytes(32); @@ -792,7 +796,7 @@ describe("HAPServer", () => { describe("/pairings required TLV fields (fix 58c24b92)", () => { test("should reject when METHOD is missing", async () => { const malformed = tlv.encode(TLVValues.STATE, PairingStates.M1); - const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, "application/pairing+tlv8"); + const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, { contentType: HAPMimeTypes.PAIRING_TLV8 }); expect(httpResponse.statusCode).toEqual(HAPPairingHTTPCode.OK); const objects = tlv.decode(httpResponse.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); @@ -801,7 +805,7 @@ describe("HAPServer", () => { test("should reject when STATE is missing", async () => { const malformed = tlv.encode(TLVValues.METHOD, PairMethods.LIST_PAIRINGS); - const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, "application/pairing+tlv8"); + const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, { contentType: HAPMimeTypes.PAIRING_TLV8 }); expect(httpResponse.statusCode).toEqual(HAPPairingHTTPCode.OK); const objects = tlv.decode(httpResponse.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); @@ -815,7 +819,7 @@ describe("HAPServer", () => { TLVValues.PUBLIC_KEY, crypto.randomBytes(32), TLVValues.PERMISSIONS, PermissionTypes.ADMIN, ); - const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, "application/pairing+tlv8"); + const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, { contentType: HAPMimeTypes.PAIRING_TLV8 }); const objects = tlv.decode(httpResponse.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); @@ -828,7 +832,7 @@ describe("HAPServer", () => { TLVValues.IDENTIFIER, thirdUsername, TLVValues.PERMISSIONS, PermissionTypes.ADMIN, ); - const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, "application/pairing+tlv8"); + const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, { contentType: HAPMimeTypes.PAIRING_TLV8 }); const objects = tlv.decode(httpResponse.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); @@ -839,7 +843,7 @@ describe("HAPServer", () => { TLVValues.METHOD, PairMethods.REMOVE_PAIRING, TLVValues.STATE, PairingStates.M1, ); - const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, "application/pairing+tlv8"); + const httpResponse = await client.writeHTTPRequest("POST", "/pairings", malformed, { contentType: HAPMimeTypes.PAIRING_TLV8 }); const objects = tlv.decode(httpResponse.body); expect(objects[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2); expect(objects[TLVValues.ERROR_CODE].readUInt8(0)).toEqual(TLVErrorCode.UNKNOWN); @@ -1133,14 +1137,11 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - try { - const response = await axios.post(`http://localhost:${port}/${route}`, { httpAgent }); - fail(`Expected erroneous response, got ${response}`); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); - expect(error.response?.data).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); - } + const client = await connectClient(port); + + const response = await client.writeHTTPRequest("POST", `/${route}`); + expect(response.statusCode).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); + expect(JSON.parse(response.body.toString())).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); }, ); @@ -1150,14 +1151,11 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoPaired); const [port] = await bindServer(server); - try { - const response = await axios.post(`http://localhost:${port}/${route}`, { httpAgent }); - fail(`Expected erroneous response, got ${response}`); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); - expect(error.response?.data).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); - } + const client = await connectClient(port); + + const response = await client.writeHTTPRequest("POST", `/${route}`); + expect(response.statusCode).toBe(HAPPairingHTTPCode.CONNECTION_AUTHORIZATION_REQUIRED); + expect(JSON.parse(response.body.toString())).toEqual({ status: HAPStatus.INSUFFICIENT_PRIVILEGES }); }, ); @@ -1165,14 +1163,11 @@ describe("HAPServer", () => { server = new HAPServer(accessoryInfoUnpaired); const [port] = await bindServer(server); - try { - const response = await axios.post(`http://localhost:${port}/non-existent`, { httpAgent }); - fail(`Expected erroneous response, got ${response}`); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - expect(error.response?.status).toBe(HAPHTTPCode.NOT_FOUND); - expect(error.response?.data).toEqual({ status: HAPStatus.RESOURCE_DOES_NOT_EXIST }); - } + const client = await connectClient(port); + + const response = await client.writeHTTPRequest("POST", "/non-existent"); + expect(response.statusCode).toBe(HAPHTTPCode.NOT_FOUND); + expect(JSON.parse(response.body.toString())).toEqual({ status: HAPStatus.RESOURCE_DOES_NOT_EXIST }); }); }); diff --git a/src/lib/util/eventedhttp.spec.ts b/src/lib/util/eventedhttp.spec.ts index 8a8418764..0a8cdc1fa 100644 --- a/src/lib/util/eventedhttp.spec.ts +++ b/src/lib/util/eventedhttp.spec.ts @@ -1,14 +1,12 @@ -import axios, { AxiosResponse } from "axios"; -import { Agent, IncomingMessage, ServerResponse } from "http"; +import { IncomingMessage, ServerResponse } from "http"; import { HAPHTTPClient } from "../../test-utils/HAPHTTPClient"; import { HAPHTTPCode } from "../HAPServer"; import { EventedHTTPServer, EventedHTTPServerEvent, HAPConnection, HAPConnectionEvent } from "./eventedhttp"; import { awaitEventOnce, PromiseTimeout } from "./promise-utils"; describe("eventedhttp", () => { - let httpAgent: Agent; + let clients: HAPHTTPClient[]; let server: EventedHTTPServer; - let baseUrl: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any const defaultRequestHandler = (connection: any, request: any, response: ServerResponse) => { @@ -16,11 +14,20 @@ describe("eventedhttp", () => { response.end("Hello World", "ascii"); }; + // Creates a connected client and registers it for teardown. One client instance owns one TCP connection, which is + // exactly one server-side HAPConnection. The server binds the unspecified address, which is not a portable connect + // destination, so we dial the loopback address of the bound family instead. + const connectClient = async (): Promise => { + const address = server.address(); + const host = ["0.0.0.0", "::"].includes(address.address) ? ((address.family === "IPv6") ? "::1" : "127.0.0.1") : address.address; + const client = new HAPHTTPClient(host, address.port); + await client.connect(); + clients.push(client); + return client; + }; + beforeEach(async () => { - // used to do long living http connections without own tcp interface - httpAgent = new Agent({ - keepAlive: true, - }); + clients = []; server = new EventedHTTPServer(); // @ts-expect-error: private access @@ -28,12 +35,12 @@ describe("eventedhttp", () => { server.listen(0); await awaitEventOnce(server, EventedHTTPServerEvent.LISTENING); - - const address = server.address(); - baseUrl = `http://0.0.0.0:${address.port}`; }); afterEach(() => { + for (const client of clients) { + client.destroy(); + } server.stop(); server.destroy(); }); @@ -49,8 +56,10 @@ describe("eventedhttp", () => { response.end("Hello World", "ascii"); }); - const result: AxiosResponse = await axios.get(`${baseUrl}/test?query=true`, { httpAgent }); - expect(result.data).toEqual("Hello World"); + const client = await connectClient(); + const result = await client.writeHTTPRequest("GET", "/test?query=true"); + expect(result.statusCode).toBe(HAPHTTPCode.OK); + expect(result.body.toString()).toEqual("Hello World"); const connection = await connectionOpened; // we simulate the connection getting authenticated (e.g. through pair-verify) @@ -62,7 +71,7 @@ describe("eventedhttp", () => { expect(connection.getLocalAddress("ipv4")).toBe("127.0.0.1"); expect(connection.getLocalAddress("ipv6")).toBe("::1"); - httpAgent.destroy(); // disconnect HAPConnection + client.destroy(); // disconnect HAPConnection await connectionClosed; }); @@ -74,20 +83,23 @@ describe("eventedhttp", () => { // that made the request must persist till the response is sent out! const username = "XX:XX:XX:XX:XX:XX"; - const secondAgent = new Agent({ keepAlive: true }); // OPEN CONNECTIONS server.once(EventedHTTPServerEvent.REQUEST, defaultRequestHandler); const connectionOpened0: Promise = awaitEventOnce(server, EventedHTTPServerEvent.CONNECTION_OPENED); - const result0: AxiosResponse = await axios.get(baseUrl, { httpAgent }); - expect(result0.data).toEqual("Hello World"); + const client0 = await connectClient(); const connection0 = await connectionOpened0; + const result0 = await client0.writeHTTPRequest("GET", "/"); + expect(result0.statusCode).toBe(HAPHTTPCode.OK); + expect(result0.body.toString()).toEqual("Hello World"); server.once(EventedHTTPServerEvent.REQUEST, defaultRequestHandler); const connectionOpened1: Promise = awaitEventOnce(server, EventedHTTPServerEvent.CONNECTION_OPENED); - const result1: AxiosResponse = await axios.get(baseUrl, { httpAgent: secondAgent }); - expect(result1.data).toEqual("Hello World"); + const client1 = await connectClient(); const connection1 = await connectionOpened1; + const result1 = await client1.writeHTTPRequest("GET", "/"); + expect(result1.statusCode).toBe(HAPHTTPCode.OK); + expect(result1.body.toString()).toEqual("Hello World"); // AUTHENTICATE CONNECTIONS const authenticatedEvent0: Promise = awaitEventOnce(connection0, HAPConnectionEvent.AUTHENTICATED); @@ -100,7 +112,7 @@ describe("eventedhttp", () => { // we make a request that we don't answer yet! const queuedRequestPromise: Promise<[HAPConnection, IncomingMessage, ServerResponse]> = awaitEventOnce(server, EventedHTTPServerEvent.REQUEST); - const pendingRequest = axios.get(baseUrl, { httpAgent }); // simulate a "unpair" request! + client0.write(client0.formatHTTPRequest("GET", "/")); // simulate a "unpair" request! const queuedResponse = (await queuedRequestPromise)[2]; // do the unpair!! @@ -108,34 +120,31 @@ describe("eventedhttp", () => { EventedHTTPServer.destroyExistingConnectionsAfterUnpair(connection0, username); await expect(connectionClosed).resolves.toBe(connection1); - await PromiseTimeout(200); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((secondAgent as any).totalSocketCount <= 0).toBeTruthy(); // assert that the client side was closed! - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((httpAgent as any).totalSocketCount).toBe(1); // the other socket shouldn't be closed yet + // the other connection's client side must observe the teardown, while the requesting connection stays open until + // its response has been sent out + await client1.waitForClose(); + expect(client0.isClosed).toBe(false); // now finish the http request from above! defaultRequestHandler(undefined, undefined, queuedResponse!); // just reuse the request handler from above! - const pendingRequestResult = await pendingRequest; - expect(pendingRequestResult.data).toBe("Hello World"); + const pendingResponse = await client0.readHTTPResponse(); + expect(pendingResponse.statusCode).toBe(HAPHTTPCode.OK); + expect(pendingResponse.body.toString()).toBe("Hello World"); - await PromiseTimeout(200); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((httpAgent as any).totalSocketCount <= 0).toBeTruthy(); // the other socket shall now be closed! + // once the response is out the server tears the requesting connection down as well + await client0.waitForClose(); }); test("event notifications", async () => { - const address = server.address(); - server.once(EventedHTTPServerEvent.REQUEST, defaultRequestHandler); const connectionOpened: Promise = awaitEventOnce(server, EventedHTTPServerEvent.CONNECTION_OPENED); - const result: AxiosResponse = await axios.get(`${baseUrl}/test?query=true`, { httpAgent }); - expect(result.data).toEqual("Hello World"); + const client = await connectClient(); const connection = await connectionOpened; - const client = new HAPHTTPClient(httpAgent, address.address, address.port); - await client.attachSocket(); // capture the free socket of the http agent! + const result = await client.writeHTTPRequest("GET", "/test?query=true"); + expect(result.statusCode).toBe(HAPHTTPCode.OK); + expect(result.body.toString()).toEqual("Hello World"); connection.enableEventNotifications(1, 1); // we implicitly test below that this event won't be delivered! @@ -151,11 +160,14 @@ describe("eventedhttp", () => { connection.sendEvent(1, 1, "Hello Mars!"); connection.sendEvent(1, 1, "Hello World!"); // should send - // no immediate delivery! - await PromiseTimeout(50); + // Regular events are coalesced: nothing may be delivered before the coalescing window elapses. We wait a fraction of + // that window - comfortably less than it, since a timer never fires early - and assert nothing has arrived yet. + await PromiseTimeout(HAPConnection.EVENT_COALESCING_DELAY / 5); expect(client.receiveBufferCount).toBe(0); - await PromiseTimeout(300); + // Once the coalescing timer fires the batched events flush as a single notification. We await the actual arrival + // rather than a fixed delay so the assertion holds regardless of how the runtime schedules the round-trip. + await client.waitForData(1); expect(client.receiveBufferCount).toBe(1); expect(client.popReceiveBuffer().toString()).toBe("EVENT/1.0 200 OK\r\n" + "Content-Type: application/hap+json\r\n" + @@ -169,9 +181,11 @@ describe("eventedhttp", () => { server.broadcastEvent(1, 1, "Hello Sun!", undefined, false); - // event with immediate delivery shall send currently queued events. Nothing gets out of order! + // An event flagged for immediate delivery flushes the currently queued events along with it. The wait is bounded well + // below the coalescing window, so a regression to timer-based delivery fails loudly instead of passing late with the + // same bytes. connection.sendEvent(1, 1, "Hello Mars!", true); - await PromiseTimeout(10); + await client.waitForData(1, HAPConnection.EVENT_COALESCING_DELAY / 2); expect(client.receiveBufferCount).toBe(1); expect(client.popReceiveBuffer().toString()).toBe("EVENT/1.0 200 OK\r\n" + "Content-Type: application/hap+json\r\n" + @@ -180,15 +194,16 @@ describe("eventedhttp", () => { "{\"characteristics\":[{\"aid\":1,\"iid\":1,\"value\":\"Hello Mars!\"},{\"aid\":1,\"iid\":1,\"value\":\"Hello Sun!\"}]}"); + // The originating connection is excluded from its own broadcast, so it must receive nothing. Exclusion is decided + // synchronously when the broadcast is dispatched, so a brief settle is enough to confirm non-delivery. server.broadcastEvent(1, 1, "Hello Sun!", connection, true); - await PromiseTimeout(10); + await PromiseTimeout(HAPConnection.EVENT_COALESCING_DELAY / 5); expect(client.receiveBufferCount).toBe(0); // NOW we test event delivery when there is ongoing request const testEventDelivery = async (sendEvents: () => Promise, assertResult: () => Promise) => { const queuedRequestPromise: Promise<[HAPConnection, IncomingMessage, ServerResponse]> = awaitEventOnce(server, EventedHTTPServerEvent.REQUEST); - // we can't use axios here, as our special EVENT http message is involved! - + // the request is deliberately left unanswered for now, so we write the raw bytes and defer reading the response client.write(client.formatHTTPRequest("GET", "/")); const queuedResponse: ServerResponse = (await queuedRequestPromise)[2]; @@ -196,7 +211,7 @@ describe("eventedhttp", () => { defaultRequestHandler(undefined, undefined, queuedResponse!); // just reuse the request handler from above! - await PromiseTimeout(20); + // Each assertion awaits exactly the delivery it expects, so no fixed settle delay is needed here. await assertResult(); }; @@ -207,17 +222,11 @@ describe("eventedhttp", () => { connection.sendEvent(1, 1, "Hello Mars!"); }, async () => { - expect(client.receiveBufferCount > 0).toBeTruthy(); - - let eventMessage = ""; - // sometimes the HTTP response message and the EVENT message are combined - // into a single TCP segment, sometimes they get sent separately. - while (client.receiveBufferCount > 0) { - eventMessage = client.popReceiveBuffer().toString(); - } - - expect(eventMessage.includes("EVENT/1.0")).toBeTruthy(); - const event = eventMessage.substring(eventMessage.indexOf("EVENT")); // splicing away the http response! + // The immediate event forces the HTTP response and the EVENT message out together; they may share one TCP segment + // or arrive as two, so we read until the EVENT marker is present rather than assuming a segment count. The read is + // bounded well below the coalescing window so an event that wrongly arrives on the coalescing timer fails loudly. + const received = await client.readUntil("EVENT/1.0", HAPConnection.EVENT_COALESCING_DELAY / 2); + const event = received.substring(received.indexOf("EVENT")); // splicing away the http response! expect(event).toBe("EVENT/1.0 200 OK\r\n" + "Content-Type: application/hap+json\r\n" + "Content-Length: 102\r\n" + @@ -232,11 +241,17 @@ describe("eventedhttp", () => { connection.sendEvent(1, 1, "Hello Sun!"); }, async () => { - expect(client.receiveBufferCount).toBe(1); + // These events are coalesced, so only the HTTP response returns with the request and it must not contain an EVENT. + await client.waitForData(1); expect(client.popReceiveBuffer().toString().includes("EVENT")).toBeFalsy(); - await PromiseTimeout(300); - expect(client.receiveBufferCount).toBe(1); + // Nothing further may arrive before the coalescing window elapses - a flush-with-response regression lands here + // regardless of whether the runtime coalesced it into the response segment or sent it as its own. + await PromiseTimeout(HAPConnection.EVENT_COALESCING_DELAY / 5); + expect(client.receiveBufferCount).toBe(0); + + // After the coalescing window the batched event is flushed as its own notification. + await client.waitForData(1); expect(client.popReceiveBuffer().toString()).toBe("EVENT/1.0 200 OK\r\n" + "Content-Type: application/hap+json\r\n" + "Content-Length: 100\r\n" + @@ -248,20 +263,14 @@ describe("eventedhttp", () => { await testEventDelivery( async () => { connection.sendEvent(1, 1, "Hello Mars!"); - await PromiseTimeout(300); // the timeout runs out while the request is still processed + // Let the coalescing timer fire while the request is still open, exercising the path where queued events are + // flushed together with the response instead of on their own timer. + await PromiseTimeout(HAPConnection.EVENT_COALESCING_DELAY + 50); }, async () => { - expect(client.receiveBufferCount > 0).toBeTruthy(); - - let eventMessage = ""; - // sometimes the HTTP response message and the EVENT message are combined - // into a single TCP segment, sometimes they get sent separately. - while (client.receiveBufferCount > 0) { - eventMessage = client.popReceiveBuffer().toString(); - } - - expect(eventMessage.includes("EVENT/1.0")).toBeTruthy(); - const event = eventMessage.substring(eventMessage.indexOf("EVENT")); // splicing away the http response! + // Response and coalesced event flush together and may or may not share a TCP segment, so read until the marker. + const received = await client.readUntil("EVENT/1.0"); + const event = received.substring(received.indexOf("EVENT")); // splicing away the http response! expect(event).toBe("EVENT/1.0 200 OK\r\n" + "Content-Type: application/hap+json\r\n" + "Content-Length: 61\r\n" + @@ -269,8 +278,6 @@ describe("eventedhttp", () => { "{\"characteristics\":[{\"aid\":1,\"iid\":1,\"value\":\"Hello Mars!\"}]}"); }, ); - - client.releaseSocket(); }); // TODO test events not delivered while in a request! diff --git a/src/lib/util/eventedhttp.ts b/src/lib/util/eventedhttp.ts index 6d423e8d2..d46369ce6 100644 --- a/src/lib/util/eventedhttp.ts +++ b/src/lib/util/eventedhttp.ts @@ -337,6 +337,13 @@ export declare interface HAPConnection { */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class HAPConnection extends EventEmitter { + /** + * Delay in milliseconds before queued (non-immediate) characteristic event notifications are coalesced and flushed on + * the connection. HAP batches regular events so a burst of value changes produces a single notification rather than one + * per change; events flagged for immediate delivery bypass this window entirely. + */ + static readonly EVENT_COALESCING_DELAY = 250; + /** * @private file-private API */ @@ -536,7 +543,7 @@ export class HAPConnection extends EventEmitter { // if there is already a timer running we just add it in the queue. if (!this.eventsTimer) { - this.eventsTimer = setTimeout(this.handleEventsTimeout.bind(this), 250); + this.eventsTimer = setTimeout(this.handleEventsTimeout.bind(this), HAPConnection.EVENT_COALESCING_DELAY); this.eventsTimer.unref(); } } diff --git a/src/test-utils/HAPHTTPClient.ts b/src/test-utils/HAPHTTPClient.ts index 48492d6e5..a57aead88 100644 --- a/src/test-utils/HAPHTTPClient.ts +++ b/src/test-utils/HAPHTTPClient.ts @@ -1,13 +1,12 @@ import assert from "assert"; -import { Agent } from "http"; +import { once } from "events"; import { HeaderObject, HTTPParser } from "http-parser-js"; -import net, { AddressInfo, Socket } from "net"; +import { createConnection, Socket } from "net"; import { HAPMimeTypes, PairingStates, PairMethods, TLVValues } from "../internal-types"; import { HAPHTTPCode, HAPPairingHTTPCode } from "../lib/HAPServer"; import { PairingInformation, PermissionTypes } from "../lib/model/AccessoryInfo"; import { HAPEncryption, HAPUsername } from "../lib/util/eventedhttp"; import * as hapCrypto from "../lib/util/hapCrypto"; -import { PromiseTimeout } from "../lib/util/promise-utils"; import * as tlv from "../lib/util/tlv"; import { AccessoriesResponse, @@ -33,96 +32,134 @@ export interface HTTPResponse { trailers: string[]; } -// Node >= 24.17 (also 22.23 / 26.3) added a "free socket data guard" as part of the fix for -// CVE-2026-48931 (HTTP response queue poisoning). While a keep-alive socket sits idle in the -// http.Agent's free pool, Node swaps its low-level `handle.onread` for a guard that destroys the -// socket the instant any unsolicited data arrives on it. -// -// This test client deliberately reaches into the agent's free pool and reuses that idle socket to -// receive server-pushed `EVENT/1.0` notifications — which the guard treats as poisoning and kills. -// We cannot use a fresh socket instead, because the HAP encryption keys are bound to this exact -// server-side connection. So we neutralise the guard by restoring the socket's default `onread`. -// -// The default read callback (`onStreamRead`) is an internal, un-importable function, but it is the -// same instance on every socket handle. We harvest a reference once from a throwaway loopback pair. -type HandleOnread = (...args: unknown[]) => void; -interface SocketWithHandle { - _handle?: { onread: HandleOnread } | null; -} - -// http.Agent doesn't expose its internal socket pool in its public types. -interface AgentWithFreeSockets { - freeSockets: Record; -} - -let defaultOnread: HandleOnread | undefined; - -async function restoreFreeSocketDataGuard(socket: Socket): Promise { - const handle = (socket as unknown as SocketWithHandle)._handle; - if (!handle) { - return; - } - - if (!defaultOnread) { - defaultOnread = await new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, "127.0.0.1", () => { - const port = (server.address() as AddressInfo).port; - const probe = net.connect(port, "127.0.0.1", () => { - const onread = (probe as unknown as SocketWithHandle)._handle!.onread; - probe.destroy(); - server.close(); - resolve(onread); - }); - probe.on("error", reject); - }); - }); - } - - handle.onread = defaultOnread; +export interface HTTPRequestOptions { + /** + * Content-Type header applied when a request body is present. Defaults to "application/json". + */ + contentType?: string; + /** + * Additional request headers appended verbatim to the fixed header block (e.g. an authorization header). + */ + headers?: Record; } /** - * A http client that wraps around a http agent. + * A HAP HTTP client backed by a raw TCP connection it owns for the connection's entire lifetime - the client-side mirror + * of the server's HAPConnection. Owning the socket end-to-end is what allows a single connection to carry the pairing + * handshakes, the encrypted session traffic whose keys are bound to that exact connection, and unsolicited EVENT messages + * the server pushes. A pooled http.Agent socket cannot serve this role: the agent's free-socket read guard destroys a + * pooled connection the moment the server sends unsolicited data. */ export class HAPHTTPClient { - private readonly agent: Agent; + /** + * Upper bound in milliseconds for awaiting received bytes before failing. Generous relative to the real loopback + * round-trip so it never trips on a healthy connection, while still turning a genuinely absent response into a loud + * timeout instead of a silent empty buffer. + */ + private static readonly RECEIVE_TIMEOUT = 2000; + private readonly address: string; private readonly port: number; private currentSocket?: Socket; private encryption?: HAPEncryption; + private socketClosed = false; + // Settles when the underlying socket has fully closed. Captured at connect() so close observation stays valid for the + // client's entire lifetime, even after destroy() has dropped the socket reference. + private socketClosePromise?: Promise; + private everConnected = false; private currentDataListener?: (data: Buffer) => void; private dataQueue: Buffer[] = []; + // Already-decrypted bytes trailing a parsed HTTP response that shared a TCP segment with subsequent data (e.g. a + // coalesced EVENT message). Consumed by popReceiveBuffer before any further (still encrypted) segments. + private leftoverPlaintext?: Buffer; - constructor(agent: Agent, address: string, port: number) { - this.agent = agent; + constructor(address: string, port: number) { this.address = address; this.port = port; } - async attachSocket(): Promise { - expect(this.currentSocket).toBeUndefined(); - expect(this.currentDataListener).toBeUndefined(); - - // we extract the underlying TCP socket! - const freeSockets = (this.agent as unknown as AgentWithFreeSockets).freeSockets; - expect(Object.values(freeSockets).length).toBe(1); - this.currentSocket = Object.values(freeSockets)[0][0]; - - // undo Node's idle free-socket data guard so server-pushed events reach us (see note above). - await restoreFreeSocketDataGuard(this.currentSocket); + /** + * Opens the TCP connection this client owns for its entire lifetime and installs the receive machinery. Mirrors the + * server-side HAPConnection setup: Nagle is disabled so small request/response writes are not artificially delayed. + */ + async connect(): Promise { + // One client instance models one connection for its entire lifetime - the client-side mirror of the server's + // per-connection HAPConnection - so a client is never reconnected after use. A test needing a fresh connection + // constructs a fresh client, which also keeps all receive state trivially clean. + expect(this.everConnected).toBe(false); + this.everConnected = true; + + const socket = createConnection(this.port, this.address); + socket.setNoDelay(true); + + // A permanent error listener must exist for the socket's lifetime: without one, a late ECONNRESET (e.g. the server + // tearing down first in afterEach) would crash the process. Failures surface loudly through receive timeouts instead. + socket.on("error", () => {}); + this.socketClosePromise = new Promise(resolve => { + socket.once("close", () => { + this.socketClosed = true; + resolve(); + }); + }); this.currentDataListener = data => { - // packets shall fit into a single TCP segment, no need to write a parser! this.dataQueue.push(data); }; - this.currentSocket.on("data", this.currentDataListener); + socket.on("data", this.currentDataListener); + this.currentSocket = socket; + + // once() rejects on a socket "error" while waiting, covering the failed-connect path. + await once(socket, "connect"); + } + + /** + * Destroys the owned connection. Safe to call multiple times and on a never-connected client, so tests can place it in + * finally blocks and afterEach hooks unconditionally. + */ + destroy(): void { + if (this.currentSocket) { + if (this.currentDataListener) { + this.currentSocket.removeListener("data", this.currentDataListener); + this.currentDataListener = undefined; + } + this.currentSocket.destroy(); + this.currentSocket = undefined; + } + } + + /** + * True once the underlying socket has fully closed - regardless of which side initiated it. Lets tests assert + * server-initiated teardown directly on the connection instead of inferring it from client-library internals. + */ + get isClosed(): boolean { + return this.socketClosed; + } + + /** + * Resolves once the underlying socket has fully closed, rejecting after {@link timeoutMs} if it never does. Close + * observation is anchored to the promise captured at {@link connect}, so it is valid at any point in the client's + * lifetime - including immediately after {@link destroy} - regardless of which side initiates the teardown. + */ + async waitForClose(timeoutMs = HAPHTTPClient.RECEIVE_TIMEOUT): Promise { + expect(this.socketClosePromise).toBeDefined(); + + let timeoutId: NodeJS.Timeout | undefined = undefined; + + const timeout = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error("Timed out awaiting the connection to close.")), timeoutMs); + }); + + try { + await Promise.race([this.socketClosePromise, timeout]); + } finally { + clearTimeout(timeoutId); + } } get receiveBufferCount(): number { - return this.dataQueue.length; + return this.dataQueue.length + (this.leftoverPlaintext ? 1 : 0); } enableEncryption(encryption: HAPEncryption): void { @@ -135,6 +172,11 @@ export class HAPHTTPClient { popReceiveBuffer(): Buffer { expect(this.currentSocket).toBeDefined(); + if (this.leftoverPlaintext) { + const buffer = this.leftoverPlaintext; + this.leftoverPlaintext = undefined; + return buffer; + } expect(this.dataQueue.length > 0).toBeTruthy(); const buffer = this.dataQueue.splice(0, 1)[0]; if (this.encryption) { @@ -143,23 +185,121 @@ export class HAPHTTPClient { return buffer; } + /** + * Waits for the next TCP segment to arrive, rejecting once {@link deadline} (an absolute epoch-milliseconds timestamp) + * passes or the connection closes. A closed socket can never deliver the awaited bytes, so failing fast on close beats + * stalling out the timeout and obscuring the real cause. + */ + private awaitIncomingData(deadline: number): Promise { + expect(this.currentSocket).toBeDefined(); + + // A connection that has already closed can never deliver the awaited bytes - reject immediately rather than riding + // out the deadline only to fail with the less specific timeout error. + if (this.socketClosed) { + return Promise.reject(new Error("Connection closed while awaiting incoming data.")); + } + + const socket = this.currentSocket!; + + return new Promise((resolve, reject) => { + let settled = false; + + const cleanups: (() => void)[] = []; + + // Whichever of the three outcomes settles first tears down the other two registrations, so no timer or listener + // outlives the wait. + const settle = (error?: Error) => { + if (settled) { + return; + } + settled = true; + for (const cleanup of cleanups) { + cleanup(); + } + if (error) { + reject(error); + } else { + resolve(); + } + }; + + const timeoutId = setTimeout(() => settle(new Error("Timed out awaiting incoming data on the connection.")), Math.max(0, deadline - Date.now())); + const onData = () => settle(); + const onClose = () => settle(new Error("Connection closed while awaiting incoming data.")); + + cleanups.push(() => clearTimeout(timeoutId)); + cleanups.push(() => socket.removeListener("data", onData)); + cleanups.push(() => socket.removeListener("close", onClose)); + + socket.on("data", onData); + socket.on("close", onClose); + }); + } + + /** + * Resolves once at least {@link count} buffers have arrived - immediately if they already have - and rejects once + * {@link timeoutMs} has elapsed overall or the connection closes while waiting. Callers await the genuine arrival of + * bytes instead of sleeping for a fixed interval and hoping the loopback encrypt/decrypt round-trip has completed by + * then; that is what keeps the suite robust across Node major versions whose event-loop scheduling differs, and it lets + * a genuinely missing response fail loudly instead of silently reading an empty buffer. + */ + async waitForData(count = 1, timeoutMs = HAPHTTPClient.RECEIVE_TIMEOUT): Promise { + const deadline = Date.now() + timeoutMs; + + while (this.receiveBufferCount < count) { + await this.awaitIncomingData(deadline); + } + } + + /** + * Drains and returns every buffered message as one string once the received data contains {@link marker}, awaiting + * further TCP segments until it does (rejecting once {@link timeoutMs} has elapsed overall or the connection closes). + * HomeKit may coalesce an HTTP response and a trailing EVENT message into a single TCP segment or split them across + * two, so matching on content rather than on a fixed segment count keeps event assertions independent of that + * nondeterminism. + */ + async readUntil(marker: string, timeoutMs = HAPHTTPClient.RECEIVE_TIMEOUT): Promise { + const deadline = Date.now() + timeoutMs; + + let received = ""; + while (this.receiveBufferCount > 0) { + received += this.popReceiveBuffer().toString(); + } + while (!received.includes(marker)) { + await this.awaitIncomingData(deadline); + while (this.receiveBufferCount > 0) { + received += this.popReceiveBuffer().toString(); + } + } + return received; + } + formatHTTPRequest( method: "GET" | "POST" | "PUT" | "DELETE", route: string, data?: Buffer, - contentType = "application/json", + options: HTTPRequestOptions = {}, ): Buffer { - expect(!!data || method === "GET").toBeTruthy(); + const contentType = options.contentType ?? "application/json"; + + let extraHeaders = ""; + for (const [name, value] of Object.entries(options.headers ?? {})) { + extraHeaders += name + ": " + value + "\r\n"; + } + + // A body-less non-GET request is legitimate (e.g. POST /identify) and carries an explicit zero Content-Length so the + // server's parser can frame the message without ambiguity. + const bodyHeaders = data + ? "Content-Type: " + contentType + "\r\n" + "Content-Length: " + data.length + "\r\n" + : (method === "GET" ? "" : "Content-Length: 0\r\n"); + const buffer = Buffer.from(`${method} ${route} HTTP/1.1\r\n` + "Accept: application/json, text/plain, */*\r\n" + "User-Agent: test-util\r\n" + "Host: " + this.address + ":" + this.port + "\r\n" + "Connection: keep-alive\r\n" + - (data - ? "Content-Type: " + contentType + "\r\n" + - "Content-Length: " + data.length + "\r\n" - : "" - ) + + extraHeaders + + bodyHeaders + "\r\n"); if (data) { @@ -169,14 +309,10 @@ export class HAPHTTPClient { return buffer; } - async writeHTTPRequest(method: "GET" | "POST" | "PUT" | "DELETE", route: string, data?: Buffer, contentType?: string): Promise { - const httpRequest = this.formatHTTPRequest(method, route, data, contentType); + async writeHTTPRequest(method: "GET" | "POST" | "PUT" | "DELETE", route: string, data?: Buffer, options?: HTTPRequestOptions): Promise { + const httpRequest = this.formatHTTPRequest(method, route, data, options); this.write(httpRequest); - - await PromiseTimeout(20); - - const responseBuffer = this.popReceiveBuffer(); - return this.parseHTTPResponse(responseBuffer); + return this.readHTTPResponse(); } write(data: Buffer): void { @@ -187,15 +323,100 @@ export class HAPHTTPClient { this.currentSocket!.write(data); } - releaseSocket(): void { - if (this.currentSocket && this.currentDataListener) { - this.currentSocket.removeListener("data", this.currentDataListener); - this.currentDataListener = undefined; + /** + * Reads exactly one complete HTTP response off the connection, feeding the parser segment by segment until the message + * completes (rejecting once {@link timeoutMs} has elapsed overall or the connection closes). Parsing incrementally + * removes any assumption about how the runtime segments the response across TCP packets - segmentation is exactly the + * kind of behavior that shifts between Node major versions. Bytes trailing the parsed message (e.g. a coalesced EVENT + * notification) are retained for the next read rather than lost. + */ + async readHTTPResponse(timeoutMs = HAPHTTPClient.RECEIVE_TIMEOUT): Promise { + const deadline = Date.now() + timeoutMs; + const parser = new HTTPParser(HTTPParser.RESPONSE); + + let complete = false; + // The chunk-relative position at which the message completed. The parser does not stop at message end - it + // reinitializes and keeps consuming trailing bytes as the start of a next message within the same execute() call - + // so the boundary must be captured the moment kOnMessageComplete fires, and the callbacks below must go quiet once + // the message of interest is complete so trailing bytes cannot corrupt the parsed result. + let completedAt = 0; + let shouldKeepAlive = false; + let upgrade = false; + let statusCode = 0; + let statusMessage = ""; + let versionMajor = 0; + let versionMinor = 0; + let headers: HeaderObject = []; + let trailers: string[] = []; + const bodyChunks: Buffer[] = []; + + parser[HTTPParser.kOnHeadersComplete] = info => { + if (complete) { + return; + } + shouldKeepAlive = info.shouldKeepAlive; + upgrade = info.upgrade; + statusCode = info.statusCode; + statusMessage = info.statusMessage; + versionMajor = info.versionMajor; + versionMinor = info.versionMinor; + headers = info.headers; + }; + + parser[HTTPParser.kOnBody] = (chunk, offset, length) => { + if (complete) { + return; + } + bodyChunks.push(chunk.subarray(offset, offset + length)); + }; + + // that's the event for trailers! + parser[HTTPParser.kOnHeaders] = t => { + if (complete) { + return; + } + trailers = t; + }; + + parser[HTTPParser.kOnMessageComplete] = () => { + complete = true; + // The parser's chunk-relative read position at completion time is the exact message boundary. The property is + // runtime state the type declarations do not surface, hence the narrow structural cast. + completedAt = (parser as unknown as { offset: number }).offset; + }; + + while (!complete) { + if (this.receiveBufferCount === 0) { + await this.awaitIncomingData(deadline); + } + + const chunk = this.popReceiveBuffer(); + const result = parser.execute(chunk); + + if (complete) { + // Bytes beyond the completed message belong to the next message (e.g. a coalesced EVENT notification): retain + // them for the next read. A parse error the parser hit on those trailing bytes is not this response's concern. + if (completedAt < chunk.length) { + this.leftoverPlaintext = chunk.subarray(completedAt); + } + } else if (typeof result !== "number") { + throw new Error("Failed to parse HTTP response chunk: " + result.message); + } } - expect(this.currentDataListener).toBeUndefined(); + const body = Buffer.concat(bodyChunks); - this.currentSocket = undefined; + return { + shouldKeepAlive, + upgrade, + statusCode, + statusMessage, + versionMajor, + versionMinor, + headers: this.headersArrayToObject(headers), + body, + trailers, + }; } async sendAddPairingRequest(identifier: HAPUsername, publicKey: Buffer, permission: PermissionTypes): Promise { @@ -243,7 +464,7 @@ export class HAPHTTPClient { } private async sendPairingsRequest(requestTLV: Buffer): Promise { - const httpResponse = await this.writeHTTPRequest("POST", "/pairings", requestTLV, HAPMimeTypes.PAIRING_TLV8); + const httpResponse = await this.writeHTTPRequest("POST", "/pairings", requestTLV, { contentType: HAPMimeTypes.PAIRING_TLV8 }); // `/pairings` errors are transported via the tlv8 record expect(httpResponse.statusCode).toEqual(HAPPairingHTTPCode.OK); @@ -310,7 +531,9 @@ export class HAPHTTPClient { } public async sendCharacteristicWrite(writeRequest: CharacteristicsWriteRequest): Promise> { - const httpResponse = await this.writeHTTPRequest("PUT", "/characteristics", Buffer.from(JSON.stringify(writeRequest)), HAPMimeTypes.HAP_JSON); + const httpResponse = await this.writeHTTPRequest( + "PUT", "/characteristics", Buffer.from(JSON.stringify(writeRequest)), { contentType: HAPMimeTypes.HAP_JSON }, + ); if (httpResponse.statusCode !== HAPHTTPCode.NO_CONTENT) { expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON); } @@ -327,7 +550,9 @@ export class HAPHTTPClient { } public async sendPrepareWrite(prepareWrite: PrepareWriteRequest): Promise { - const httpResponse = await this.writeHTTPRequest("PUT", "/prepare", Buffer.from(JSON.stringify(prepareWrite)), HAPMimeTypes.HAP_JSON); + const httpResponse = await this.writeHTTPRequest( + "PUT", "/prepare", Buffer.from(JSON.stringify(prepareWrite)), { contentType: HAPMimeTypes.HAP_JSON }, + ); expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON); if (httpResponse.statusCode !== HAPHTTPCode.OK) { @@ -337,7 +562,9 @@ export class HAPHTTPClient { } public async sendResourceRequest(resourceRequest: ResourceRequest): Promise { - const httpResponse = await this.writeHTTPRequest("POST", "/resource", Buffer.from(JSON.stringify(resourceRequest)), HAPMimeTypes.HAP_JSON); + const httpResponse = await this.writeHTTPRequest( + "POST", "/resource", Buffer.from(JSON.stringify(resourceRequest)), { contentType: HAPMimeTypes.HAP_JSON }, + ); if (httpResponse.statusCode !== HAPHTTPCode.OK) { expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON); @@ -349,70 +576,6 @@ export class HAPHTTPClient { return httpResponse.body; } - parseHTTPResponse(input: Buffer): HTTPResponse { - // taken and adapted from https://github.com/creationix/http-parser-js/blob/88c665381470e27cd428a728447a13dce198f782/standalone-example.js#L73 - const parser = new HTTPParser(HTTPParser.RESPONSE); - - let complete = false; - let shouldKeepAlive = false; - let upgrade = false; - let statusCode = 0; - let statusMessage = ""; - let versionMajor = 0; - let versionMinor = 0; - let headers: HeaderObject = []; - let trailers: string[] = []; - const bodyChunks: Buffer[] = []; - - parser[HTTPParser.kOnHeadersComplete] = info => { - shouldKeepAlive = info.shouldKeepAlive; - upgrade = info.upgrade; - statusCode = info.statusCode; - statusMessage = info.statusMessage; - versionMajor = info.versionMajor; - versionMinor = info.versionMinor; - headers = info.headers; - }; - - parser[HTTPParser.kOnBody] = (chunk, offset, length) => { - bodyChunks.push(chunk.subarray(offset, offset + length)); - }; - - // that's the event for trailers! - parser[HTTPParser.kOnHeaders] = t => { - trailers = t; - }; - - parser[HTTPParser.kOnMessageComplete] = () => { - complete = true; - }; - - // Since we are sending the entire Buffer at once here all callbacks above happen synchronously. - // The parser does not do _anything_ asynchronous. - // However, you can of course call execute() multiple times with multiple chunks, e.g. from a stream. - // But then you have to refactor the entire logic to be async (e.g. resolve a Promise in kOnMessageComplete and add timeout logic). - parser.execute(input); - parser.finish(); - - if (!complete) { - throw new Error("Failed to parse input: " + input.toString()); - } - - const body = Buffer.concat(bodyChunks); - - return { - shouldKeepAlive, - upgrade, - statusCode, - statusMessage, - versionMajor, - versionMinor, - headers: this.headersArrayToObject(headers), - body, - trailers, - }; - } - private headersArrayToObject(headers: HeaderObject): Record { expect(headers.length % 2).toBe(0); diff --git a/src/test-utils/PairSetupClient.ts b/src/test-utils/PairSetupClient.ts index 2a37519ae..2999d4074 100644 --- a/src/test-utils/PairSetupClient.ts +++ b/src/test-utils/PairSetupClient.ts @@ -1,11 +1,11 @@ -import axios, { AxiosResponse } from "axios"; import { SRP, SrpClient } from "fast-srp-hap"; -import { Agent } from "http"; import tweetnacl from "tweetnacl"; -import { PairingStates, PairMethods, TLVValues } from "../internal-types"; +import { HAPMimeTypes, PairingStates, PairMethods, TLVValues } from "../internal-types"; +import { HAPPairingHTTPCode } from "../lib/HAPServer"; import * as hapCrypto from "../lib/util/hapCrypto"; import { EncryptedData } from "../lib/util/hapCrypto"; import * as tlv from "../lib/util/tlv"; +import { HAPHTTPClient, HTTPResponse } from "./HAPHTTPClient"; export interface PairSetupM2 { serverPublicKey: Buffer; @@ -36,39 +36,48 @@ export interface PairSetupClientInfo { privateKey: Buffer } +/** + * Drives the SRP pair-setup handshake over a {@link HAPHTTPClient}'s owned connection. The server tracks the M1-M6 + * progression per HAPConnection, so every message of one handshake must ride the same TCP connection - riding the + * client's socket makes that continuity explicit and guaranteed. + */ export class PairSetupClient { - private readonly port: number; - private readonly httpAgent: Agent; + private readonly client: HAPHTTPClient; - constructor(port: number, httpAgent: Agent) { - this.port = port; - this.httpAgent = httpAgent; + constructor(client: HAPHTTPClient) { + this.client = client; } async sendPairSetup(pincode: string, clientInfo: PairSetupClientInfo): Promise { + // This is the success-path orchestrator, so every hop must come back 200 OK - the TLV payload assertions in the + // parse steps do not pin the HTTP status on their own. const responseM1 = await this.sendM1(); + expect(responseM1.statusCode).toBe(HAPPairingHTTPCode.OK); - const M2 = this.parseM2(responseM1.data); + const M2 = this.parseM2(responseM1.body); const M3 = await this.prepareM3(M2, pincode); const responseM3 = await this.sendM3(M3); + expect(responseM3.statusCode).toBe(HAPPairingHTTPCode.OK); - const M4 = this.parseM4(responseM3.data, M3); + const M4 = this.parseM4(responseM3.body, M3); const M5 = this.prepareM5(M4, clientInfo); const responseM5 = await this.sendM5(M5); + expect(responseM5.statusCode).toBe(HAPPairingHTTPCode.OK); - return this.parseM6(responseM5.data, M4, M5); + return this.parseM6(responseM5.body, M4, M5); } - sendM1(): Promise> { - return axios.post( - `http://localhost:${this.port}/pair-setup`, + sendM1(): Promise { + return this.client.writeHTTPRequest( + "POST", + "/pair-setup", tlv.encode( TLVValues.STATE, PairingStates.M1, TLVValues.METHOD, PairMethods.PAIR_SETUP, ), - { httpAgent: this.httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); } @@ -93,15 +102,16 @@ export class PairSetupClient { }; } - sendM3(m3: PairSetupM3): Promise> { - return axios.post( - `http://localhost:${this.port}/pair-setup`, + sendM3(m3: PairSetupM3): Promise { + return this.client.writeHTTPRequest( + "POST", + "/pair-setup", tlv.encode( TLVValues.STATE, PairingStates.M3, TLVValues.PUBLIC_KEY, m3.srpClient.computeA(), TLVValues.PASSWORD_PROOF, m3.srpClient.computeM1(), ), - { httpAgent: this.httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); } @@ -160,14 +170,15 @@ export class PairSetupClient { }; } - sendM5(m5: PairSetupM5): Promise> { - return axios.post( - `http://localhost:${this.port}/pair-setup`, + sendM5(m5: PairSetupM5): Promise { + return this.client.writeHTTPRequest( + "POST", + "/pair-setup", tlv.encode( TLVValues.STATE, PairingStates.M5, TLVValues.ENCRYPTED_DATA, Buffer.concat([m5.encryptedData.ciphertext, m5.encryptedData.authTag]), ), - { httpAgent: this.httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); } diff --git a/src/test-utils/PairVerifyClient.ts b/src/test-utils/PairVerifyClient.ts index 29b9b0a68..b5aacb594 100644 --- a/src/test-utils/PairVerifyClient.ts +++ b/src/test-utils/PairVerifyClient.ts @@ -1,11 +1,11 @@ -import axios, { AxiosResponse } from "axios"; -import { Agent } from "http"; import tweetnacl, { BoxKeyPair } from "tweetnacl"; -import { PairingStates, TLVValues } from "../internal-types"; +import { HAPMimeTypes, PairingStates, TLVValues } from "../internal-types"; +import { HAPPairingHTTPCode } from "../lib/HAPServer"; import { HAPEncryption } from "../lib/util/eventedhttp"; import * as hapCrypto from "../lib/util/hapCrypto"; import { EncryptedData } from "../lib/util/hapCrypto"; import * as tlv from "../lib/util/tlv"; +import { HAPHTTPClient, HTTPResponse } from "./HAPHTTPClient"; export interface PairVerifyM2 { sharedSecret: Buffer, @@ -32,34 +32,41 @@ export interface PairVerifyClientInfo { privateKey: Buffer; } +/** + * Drives the pair-verify handshake over a {@link HAPHTTPClient}'s owned connection. The session keys derived by the + * handshake are bound server-side to the exact HAPConnection it ran on, so riding the client's socket - the same socket + * that will carry the encrypted session afterwards - is a correctness requirement, not a convenience. + */ export class PairVerifyClient { - private readonly port: number; - private readonly httpAgent: Agent; + private readonly client: HAPHTTPClient; readonly ephemeralKeyPair: BoxKeyPair; - constructor(port: number, httpAgent: Agent, ephemeralKeyPair?: BoxKeyPair) { - this.port = port; - this.httpAgent = httpAgent; + constructor(client: HAPHTTPClient, ephemeralKeyPair?: BoxKeyPair) { + this.client = client; this.ephemeralKeyPair = ephemeralKeyPair ?? hapCrypto.generateCurve25519KeyPair(); } async sendPairVerify(serverInfo: PairVerifyServerInfo, clientInfo: PairVerifyClientInfo & { publicKey: Buffer }): Promise { + // This is the success-path orchestrator, so both hops must come back 200 OK - the TLV payload assertions in the + // parse steps do not pin the HTTP status on their own. // M1 const responseM1 = await this.sendM1(); + expect(responseM1.statusCode).toBe(HAPPairingHTTPCode.OK); // M2 - const M2 = this.parseM2(responseM1.data, serverInfo); + const M2 = this.parseM2(responseM1.body, serverInfo); // M3 const M3 = this.prepareM3(M2, clientInfo); // step 11 & 12 const responseM3 = await this.sendM3(M3); + expect(responseM3.statusCode).toBe(HAPPairingHTTPCode.OK); // M4 - const M4 = this.parseM4(responseM3.data, M2); + const M4 = this.parseM4(responseM3.body, M2); // verify that encryption works! const encryption = new HAPEncryption( @@ -78,14 +85,15 @@ export class PairVerifyClient { return encryption; } - sendM1(): Promise> { - return axios.post( - `http://localhost:${this.port}/pair-verify`, + sendM1(): Promise { + return this.client.writeHTTPRequest( + "POST", + "/pair-verify", tlv.encode( TLVValues.STATE, PairingStates.M1, TLVValues.PUBLIC_KEY, this.ephemeralKeyPair.publicKey, ), - { httpAgent: this.httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); } @@ -183,14 +191,15 @@ export class PairVerifyClient { }; } - sendM3(m3: PairVerifyM3): Promise> { - return axios.post( - `http://localhost:${this.port}/pair-verify`, + sendM3(m3: PairVerifyM3): Promise { + return this.client.writeHTTPRequest( + "POST", + "/pair-verify", tlv.encode( TLVValues.STATE, PairingStates.M3, TLVValues.ENCRYPTED_DATA, Buffer.concat([m3.encryptedData.ciphertext, m3.encryptedData.authTag]), ), - { httpAgent: this.httpAgent, responseType: "arraybuffer" }, + { contentType: HAPMimeTypes.PAIRING_TLV8 }, ); }