From e132e6aaf4f56699b03b9aa851924675a49194b3 Mon Sep 17 00:00:00 2001 From: Roy de Kleijn Date: Wed, 17 Jun 2026 09:57:10 +0200 Subject: [PATCH] Added examples --- docs/gui/mock-servers.md | 41 ++- example/README.md | 76 +++++ example/collections/auth-mechanisms.json | 184 ++++++++++++ example/collections/basics.json | 170 +++++++++++ example/collections/data-extraction.json | 95 +++++++ example/example.spector | 13 + example/mocks/example-mock.mock.json | 210 ++++++++++++++ package.json | 2 +- src/main/auth-builder.ts | 148 ++++++++-- src/main/ipc/request-handler.ts | 36 ++- src/main/ipc/runner-handler.ts | 18 +- src/main/mock-server.ts | 99 ++++++- src/main/ntlm.ts | 265 ++++++++++++++++++ .../src/components/RequestBuilder/AuthTab.tsx | 4 +- src/tests/mock-server.test.ts | 77 ++++- src/tests/ntlm-transport.test.ts | 174 ++++++++++++ src/tests/ntlm.test.ts | 108 +++++++ 17 files changed, 1683 insertions(+), 37 deletions(-) create mode 100644 example/README.md create mode 100644 example/collections/auth-mechanisms.json create mode 100644 example/collections/basics.json create mode 100644 example/collections/data-extraction.json create mode 100644 example/example.spector create mode 100644 example/mocks/example-mock.mock.json create mode 100644 src/main/ntlm.ts create mode 100644 src/tests/ntlm-transport.test.ts create mode 100644 src/tests/ntlm.test.ts diff --git a/docs/gui/mock-servers.md b/docs/gui/mock-servers.md index 05e2e7e..47ab0b5 100644 --- a/docs/gui/mock-servers.md +++ b/docs/gui/mock-servers.md @@ -50,7 +50,7 @@ Access values from the incoming request directly in the body: |---|---|---| | `{{request.params.id}}` | URL path parameter `:id` | `"42"` | | `{{request.query.search}}` | Query string parameter `search` | `"laptop"` | -| `{{request.body.email}}` | JSON request body field | `"user@example.com"` | +| `{{request.body.email}}` | Request body field (JSON or XML) | `"user@example.com"` | | `{{request.method}}` | HTTP method | `"POST"` | | `{{request.path}}` | Request URL path | `"/products/42"` | | `{{request.headers.authorization}}` | Request header value | `"Bearer abc…"` | @@ -75,6 +75,43 @@ A `GET /products/7?user=alice` call returns: } ``` +### Reusing the request body (JSON & XML) + +`request.body` is the parsed request body, so you can echo values from the +incoming request straight back into the response. JSON and XML are both parsed +automatically — JSON first, then XML when the `Content-Type` contains `xml` or +the body starts with `<`. Anything else leaves `request.body` as `{}` (the raw +text is always available as `{{request.bodyRaw}}`). + +**JSON** — a `POST /signup` with body `{"email":"ada@example.com"}`: + +```json +{ "createdId": "{{faker.string.uuid()}}", "email": "{{request.body.email}}" } +``` + +**XML** — XML is converted to a plain object that mirrors the document, so +access works the same way. A `POST /order` with body: + +```xml +42Roy +``` + +and response body: + +```xml +{{request.body.order.orderId}}{{request.body.order.customer}} +``` + +returns `42Roy`. Conversion rules: + +- A leaf element becomes its trimmed text — `request.body.order.orderId` → `"42"`. +- Repeated tags become an array — `ab` → `request.body.cart.item[1]` is `"b"`. +- Attributes are exposed as `@name` keys — `` → `request.body.order['@id']`. +- Namespaced tags keep their prefix, so use bracket access: + `request.body['soap:Envelope']['soap:Body']`. +- The root element is preserved, so an `` document is reached via + `request.body.order`. + ### Faker.js Generate realistic random data using [Faker.js](https://fakerjs.dev/): @@ -128,7 +165,7 @@ The script has access to: | `request.path` | `string` | URL path | | `request.params` | `object` | Path parameters extracted from the route pattern | | `request.query` | `object` | Query string parameters | -| `request.body` | `object` | Parsed JSON body (or `{}` if not JSON) | +| `request.body` | `object` | Parsed JSON or XML body (or `{}` if neither) | | `request.bodyRaw` | `string` | Raw request body string | | `request.headers` | `object` | Request headers | diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..69a638e --- /dev/null +++ b/example/README.md @@ -0,0 +1,76 @@ +# API Spector — example workspace + +A self-contained workspace that demonstrates the core features of API Spector +against the **built-in mock server**. No internet, no third-party services. + +## Layout + +``` +example.spector ← open this in the GUI +collections/basics.json ← GET, POST, PUT, PATCH, DELETE, query params, custom headers +collections/data-extraction.json ← capture a value and reuse it in later requests +collections/auth-mechanisms.json ← every supported auth type +mocks/example-mock.mock.json ← one mock server (port 19200) backing all of the above +``` + +## Run it + +**GUI** + +1. Open `example.spector`. +2. In **Mocks**, start *Example API mock* (port **19200**). +3. In **Collections**, run any collection (or a single request). Everything is + wired to `http://127.0.0.1:19200`. + +**CLI** + +```bash +# terminal 1 — start the mock +npx -y @testsmith/api-spector mock --workspace ./example.spector + +# terminal 2 — run the collections +npx -y @testsmith/api-spector run --workspace ./example.spector +``` + +## What's inside + +### `basics` — everyday requests +| Request | Shows | +|---|---| +| GET a resource | path parameter + asserting on JSON fields | +| GET with query parameters | params defined in the Params tab | +| POST — create a resource | sending a JSON body, asserting `201` + echoed fields | +| PUT — replace a resource | full update | +| PATCH — update fields | partial update | +| DELETE a resource | `204 No Content` | +| Set a custom request header | adds `X-Request-Id: {{$uuid}}`; the mock echoes it back | + +### `data-extraction` — chaining requests +| Request | Shows | +|---|---| +| Login (extract token) | post-request script stores the token via `sp.collectionVariables.set('authToken', …)` | +| Use the extracted token | sends `Authorization: Bearer {{authToken}}` captured by the previous request | +| Pre-request script sets a value | a pre-request script computes a value the request then sends as a header | + +Run the whole `data-extraction` folder top-to-bottom — values captured by one +request flow into the next. + +### `auth-mechanisms` — authentication +One request per supported auth type: none, bearer, basic, API key (header & +query), digest, NTLM, and OAuth2 (client credentials). Each asserts a `200` and +that the server reported the request as authenticated. + +> Notes: the mock validates digest/basic/NTLM by shape (the script sandbox has +> no `crypto`/`Buffer`), so those exercise the *flow* rather than re-verifying +> the cryptographic proof. NTLM is not supported through a proxy. + +## Templating cheat-sheet + +The mock response bodies use `{{…}}` tokens, e.g.: + +- `{{request.params.id}}`, `{{request.query.q}}`, `{{request.body.field}}` +- `{{request.headers.authorization}}`, `{{request.bodyRaw}}` +- `{{faker.string.uuid()}}`, `{{dayjs().toISOString()}}` + +Requests use the same tokens plus built-ins like `{{$uuid}}` and `{{$timestamp}}`, +and any variable you set in a script (`{{authToken}}`, `{{traceId}}`, …). diff --git a/example/collections/auth-mechanisms.json b/example/collections/auth-mechanisms.json new file mode 100644 index 0000000..b5223c2 --- /dev/null +++ b/example/collections/auth-mechanisms.json @@ -0,0 +1,184 @@ +{ + "version": "1.0", + "id": "auth-mechanisms", + "name": "Auth mechanisms", + "description": "Tests every supported auth type (none, bearer, basic, apikey, digest, ntlm, oauth2) against the local mock server.", + "rootFolder": { + "id": "root", + "name": "root", + "description": "", + "folders": [ + { + "id": "folder-auth", + "name": "Auth Mechanisms", + "description": "", + "folders": [], + "requestIds": [ + "req-none", + "req-bearer", + "req-basic", + "req-apikey-header", + "req-apikey-query", + "req-digest", + "req-ntlm", + "req-oauth2" + ] + } + ], + "requestIds": [] + }, + "requests": { + "req-none": { + "id": "req-none", + "name": "None (no auth)", + "method": "GET", + "url": "{{BASE_URL}}/none", + "headers": [], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('server reports authenticated', function () { sp.expect(sp.response.json().authenticated).to.equal(true); });", + "protocol": "http" + }, + "req-bearer": { + "id": "req-bearer", + "name": "Bearer token", + "method": "GET", + "url": "{{BASE_URL}}/bearer", + "headers": [], + "params": [], + "auth": { + "type": "bearer", + "token": "mock-bearer-token-123" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('server reports authenticated', function () { sp.expect(sp.response.json().authenticated).to.equal(true); });", + "protocol": "http" + }, + "req-basic": { + "id": "req-basic", + "name": "Basic auth", + "method": "GET", + "url": "{{BASE_URL}}/basic", + "headers": [], + "params": [], + "auth": { + "type": "basic", + "username": "demo", + "password": "p@ssw0rd" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('server reports authenticated', function () { sp.expect(sp.response.json().authenticated).to.equal(true); });", + "protocol": "http" + }, + "req-apikey-header": { + "id": "req-apikey-header", + "name": "API key (header)", + "method": "GET", + "url": "{{BASE_URL}}/apikey", + "headers": [], + "params": [], + "auth": { + "type": "apikey", + "apiKeyIn": "header", + "apiKeyName": "X-API-Key", + "apiKeyValue": "mock-api-key-123" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('server reports authenticated', function () { sp.expect(sp.response.json().authenticated).to.equal(true); });", + "protocol": "http" + }, + "req-apikey-query": { + "id": "req-apikey-query", + "name": "API key (query)", + "method": "GET", + "url": "{{BASE_URL}}/apikey-query", + "headers": [], + "params": [], + "auth": { + "type": "apikey", + "apiKeyIn": "query", + "apiKeyName": "apikey", + "apiKeyValue": "mock-api-key-123" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('server reports authenticated', function () { sp.expect(sp.response.json().authenticated).to.equal(true); });", + "protocol": "http" + }, + "req-digest": { + "id": "req-digest", + "name": "Digest auth", + "method": "GET", + "url": "{{BASE_URL}}/digest", + "headers": [], + "params": [], + "auth": { + "type": "digest", + "username": "demo", + "password": "p@ssw0rd" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('server reports authenticated', function () { sp.expect(sp.response.json().authenticated).to.equal(true); });", + "protocol": "http" + }, + "req-ntlm": { + "id": "req-ntlm", + "name": "NTLM auth", + "method": "GET", + "url": "{{BASE_URL}}/ntlm", + "headers": [], + "params": [], + "auth": { + "type": "ntlm", + "username": "demo", + "password": "p@ssw0rd", + "ntlmDomain": "WORKGROUP", + "ntlmWorkstation": "CLIENT" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('server reports authenticated', function () { sp.expect(sp.response.json().authenticated).to.equal(true); });", + "protocol": "http", + "description": "NTLMv2 handshake against the mock's /ntlm route. The mock can't verify the proof cryptographically, so this exercises the client-side negotiate → challenge → authenticate flow." + }, + "req-oauth2": { + "id": "req-oauth2", + "name": "OAuth2 (client credentials)", + "method": "GET", + "url": "{{BASE_URL}}/oauth/protected", + "headers": [], + "params": [], + "auth": { + "type": "oauth2", + "oauth2Flow": "client_credentials", + "oauth2TokenUrl": "{{BASE_URL}}/oauth/token", + "oauth2ClientId": "demo-client", + "oauth2ClientSecret": "demo-secret", + "oauth2Scopes": "read" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('server reports authenticated', function () { sp.expect(sp.response.json().authenticated).to.equal(true); });", + "protocol": "http" + } + }, + "collectionVariables": { + "BASE_URL": "http://127.0.0.1:19200" + } +} diff --git a/example/collections/basics.json b/example/collections/basics.json new file mode 100644 index 0000000..e7ab5fa --- /dev/null +++ b/example/collections/basics.json @@ -0,0 +1,170 @@ +{ + "version": "1.0", + "id": "basics", + "name": "API basics", + "description": "GET, POST, PUT, PATCH, DELETE, query params, and custom headers.", + "rootFolder": { + "id": "root", + "name": "root", + "description": "", + "folders": [ + { + "id": "folder-basics", + "name": "API basics", + "description": "", + "folders": [], + "requestIds": [ + "get-one", + "get-query", + "post-create", + "put-replace", + "patch-update", + "delete-one", + "set-header" + ] + } + ], + "requestIds": [] + }, + "requests": { + "get-one": { + "id": "get-one", + "name": "GET a resource", + "method": "GET", + "url": "{{BASE_URL}}/products/42", + "headers": [], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('returns the requested id', function () { sp.expect(sp.response.json().id).to.equal(42); });", + "description": "A simple GET with a path parameter.", + "protocol": "http" + }, + "get-query": { + "id": "get-query", + "name": "GET with query parameters", + "method": "GET", + "url": "{{BASE_URL}}/search", + "headers": [], + "params": [ + { + "key": "q", + "value": "keyboard", + "enabled": true + }, + { + "key": "limit", + "value": "5", + "enabled": true + } + ], + "auth": { + "type": "none" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('echoes the q parameter', function () { sp.expect(sp.response.json().query.q).to.equal('keyboard'); });", + "description": "Query-string params are defined in the Params tab.", + "protocol": "http" + }, + "post-create": { + "id": "post-create", + "name": "POST — create a resource", + "method": "POST", + "url": "{{BASE_URL}}/products", + "headers": [], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "json", + "json": "{\"name\":\"Keyboard\",\"price\":49.99}" + }, + "postRequestScript": "sp.test('status is 201', function () { sp.expect(sp.response.code).to.equal(201); });\nsp.test('echoes the posted name', function () { sp.expect(sp.response.json().name).to.equal('Keyboard'); });\nsp.test('assigns a numeric id', function () { sp.expect(sp.response.json().id).to.be.a('number'); });", + "description": "Send a JSON body and assert on the created resource.", + "protocol": "http" + }, + "put-replace": { + "id": "put-replace", + "name": "PUT — replace a resource", + "method": "PUT", + "url": "{{BASE_URL}}/products/42", + "headers": [], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "json", + "json": "{\"name\":\"Keyboard Pro\",\"price\":79}" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('reflects the new name', function () { sp.expect(sp.response.json().name).to.equal('Keyboard Pro'); });", + "protocol": "http" + }, + "patch-update": { + "id": "patch-update", + "name": "PATCH — update fields", + "method": "PATCH", + "url": "{{BASE_URL}}/products/42", + "headers": [], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "json", + "json": "{\"price\":59}" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });", + "protocol": "http" + }, + "delete-one": { + "id": "delete-one", + "name": "DELETE a resource", + "method": "DELETE", + "url": "{{BASE_URL}}/products/42", + "headers": [], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 204', function () { sp.expect(sp.response.code).to.equal(204); });", + "protocol": "http" + }, + "set-header": { + "id": "set-header", + "name": "Set a custom request header", + "method": "GET", + "url": "{{BASE_URL}}/headers", + "headers": [ + { + "key": "X-Request-Id", + "value": "{{$uuid}}", + "enabled": true + } + ], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('custom header was sent', function () { sp.expect(sp.response.json()).to.have.property('x-request-id'); });", + "description": "Adds X-Request-Id using the built-in {{$uuid}} variable; the mock echoes headers back.", + "protocol": "http" + } + }, + "collectionVariables": { + "BASE_URL": "http://127.0.0.1:19200" + } +} diff --git a/example/collections/data-extraction.json b/example/collections/data-extraction.json new file mode 100644 index 0000000..14291ff --- /dev/null +++ b/example/collections/data-extraction.json @@ -0,0 +1,95 @@ +{ + "version": "1.0", + "id": "data-extraction", + "name": "Data extraction & chaining", + "description": "Capture a value from one response and reuse it in later requests.", + "rootFolder": { + "id": "root", + "name": "root", + "description": "", + "folders": [ + { + "id": "folder-data-extraction", + "name": "Data extraction & chaining", + "description": "", + "folders": [], + "requestIds": [ + "login", + "use-token", + "pre-request" + ] + } + ], + "requestIds": [] + }, + "requests": { + "login": { + "id": "login", + "name": "Login (extract token)", + "method": "POST", + "url": "{{BASE_URL}}/login", + "headers": [], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "json", + "json": "{\"username\":\"demo\",\"password\":\"secret\"}" + }, + "postRequestScript": "var data = sp.response.json();\nsp.collectionVariables.set('authToken', data.token);\nsp.collectionVariables.set('userId', String(data.userId));\nsp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('received a token', function () { sp.expect(data.token).to.be.a('string'); });", + "description": "Stores the returned token in a collection variable for the next request.", + "protocol": "http" + }, + "use-token": { + "id": "use-token", + "name": "Use the extracted token", + "method": "GET", + "url": "{{BASE_URL}}/me", + "headers": [ + { + "key": "Authorization", + "value": "Bearer {{authToken}}", + "enabled": true + } + ], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "none" + }, + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('profile is returned', function () { sp.expect(sp.response.json()).to.have.property('email'); });", + "description": "Reuses {{authToken}} captured by the Login request.", + "protocol": "http" + }, + "pre-request": { + "id": "pre-request", + "name": "Pre-request script sets a value", + "method": "GET", + "url": "{{BASE_URL}}/headers", + "headers": [ + { + "key": "X-Trace", + "value": "{{traceId}}", + "enabled": true + } + ], + "params": [], + "auth": { + "type": "none" + }, + "body": { + "mode": "none" + }, + "preRequestScript": "sp.collectionVariables.set('traceId', 'trace-' + Date.now());", + "postRequestScript": "sp.test('status is 200', function () { sp.expect(sp.response.code).to.equal(200); });\nsp.test('trace header was sent', function () { sp.expect(sp.response.json()).to.have.property('x-trace'); });", + "description": "A pre-request script computes a value that the request then sends as a header.", + "protocol": "http" + } + }, + "collectionVariables": { + "BASE_URL": "http://127.0.0.1:19200" + } +} diff --git a/example/example.spector b/example/example.spector new file mode 100644 index 0000000..1c09b10 --- /dev/null +++ b/example/example.spector @@ -0,0 +1,13 @@ +{ + "version": "1.0", + "collections": [ + "collections/basics.json", + "collections/data-extraction.json", + "collections/auth-mechanisms.json" + ], + "environments": [], + "activeEnvironmentId": null, + "mocks": [ + "mocks/example-mock.mock.json" + ] +} diff --git a/example/mocks/example-mock.mock.json b/example/mocks/example-mock.mock.json new file mode 100644 index 0000000..c12375a --- /dev/null +++ b/example/mocks/example-mock.mock.json @@ -0,0 +1,210 @@ +{ + "version": "1.0", + "id": "example-mock", + "name": "Example API mock", + "port": 19200, + "routes": [ + { + "method": "ANY", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "echo", + "path": "/echo", + "description": "Mirrors back whatever auth the client sent (httpbin-style inspection).", + "script": "response.headers['Content-Type']='application/json';response.body=JSON.stringify({method:request.method,authorization:request.headers.authorization||null,apiKeyHeader:request.headers['x-api-key']||null,apiKeyQuery:request.query.apikey||null});" + }, + { + "method": "GET", + "statusCode": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"authenticated\":true,\"scheme\":\"none\"}", + "id": "none", + "path": "/none", + "description": "No auth required — always 200." + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "bearer", + "path": "/bearer", + "description": "Expects Authorization: Bearer mock-bearer-token-123", + "script": "if(request.headers.authorization==='Bearer mock-bearer-token-123'){response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:true,scheme:'bearer'});}else{response.statusCode=401;response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:false,scheme:'bearer',error:'invalid or missing credentials',got:request.headers.authorization||null});}" + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "basic", + "path": "/basic", + "description": "Expects Basic auth for demo:p@ssw0rd", + "script": "if(request.headers.authorization==='Basic ZGVtbzpwQHNzdzByZA=='){response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:true,scheme:'basic',user:'demo'});}else{response.statusCode=401;response.headers['WWW-Authenticate']=\"Basic realm=\\\"mock-realm\\\"\";response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:false,scheme:'basic',error:'invalid or missing credentials',got:request.headers.authorization||null});}" + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "apikey-header", + "path": "/apikey", + "description": "Expects header X-API-Key: mock-api-key-123", + "script": "if(request.headers['x-api-key']==='mock-api-key-123'){response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:true,scheme:'apikey-header'});}else{response.statusCode=401;response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:false,scheme:'apikey-header',error:'invalid or missing credentials',got:request.headers.authorization||null});}" + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "apikey-query", + "path": "/apikey-query", + "description": "Expects query ?apikey=mock-api-key-123", + "script": "if(request.query.apikey==='mock-api-key-123'){response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:true,scheme:'apikey-query'});}else{response.statusCode=401;response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:false,scheme:'apikey-query',error:'invalid or missing credentials',got:request.headers.authorization||null});}" + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "digest", + "path": "/digest", + "description": "Digest challenge-response. First request gets a 401 + WWW-Authenticate; the client recomputes and retries. The hash is not re-verified (no crypto in the sandbox) — this proves the flow runs end to end.", + "script": "if(!request.headers.authorization){response.statusCode=401;response.headers['WWW-Authenticate']='Digest realm=\"mock-realm\", qop=\"auth\", nonce=\"'+faker.string.alphanumeric(32)+'\", opaque=\"'+faker.string.alphanumeric(16)+'\"';response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:false,scheme:'digest',error:'challenge issued'});}else{response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:true,scheme:'digest',sent:request.headers.authorization});}" + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "ntlm", + "path": "/ntlm", + "description": "NTLMv2 handshake. The first leg carries a short Type 1 token and gets a 401 + fixed Type 2 challenge; the client recomputes and resends a long Type 3 token, which we accept. We can't cryptographically verify the proof in the mock sandbox (no crypto/Buffer) — this proves the client-side handshake works end to end.", + "script": "var a=request.headers.authorization||'';if(a.length<100){response.statusCode=401;response.headers['WWW-Authenticate']='NTLM TlRMTVNTUAACAAAAAAAAAAAAAAAFgggAASNFZ4mrze8=';response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:false,scheme:'ntlm',error:'challenge issued'});}else{response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:true,scheme:'ntlm'});}" + }, + { + "method": "POST", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "oauth-token", + "path": "/oauth/token", + "description": "OAuth2 token endpoint (client_credentials / password). Returns a Bearer token.", + "script": "response.headers['Content-Type']='application/json';response.body=JSON.stringify({access_token:'mock-oauth-'+faker.string.uuid(),token_type:'Bearer',expires_in:3600,scope:'read'});" + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "oauth-protected", + "path": "/oauth/protected", + "description": "Protected resource. Accepts any Bearer token minted by /oauth/token (prefix mock-oauth-).", + "script": "if((request.headers.authorization||'').indexOf('Bearer mock-oauth-')===0){response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:true,scheme:'oauth2'});}else{response.statusCode=401;response.headers['Content-Type']='application/json';response.body=JSON.stringify({authenticated:false,scheme:'oauth2',error:'invalid or missing credentials',got:request.headers.authorization||null});}" + }, + { + "method": "GET", + "statusCode": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "[{\"id\":1,\"name\":\"Keyboard\",\"price\":49.99},{\"id\":2,\"name\":\"Mouse\",\"price\":19.99},{\"id\":3,\"name\":\"Monitor\",\"price\":199}]", + "id": "products-list", + "path": "/products", + "description": "List products (static JSON array)." + }, + { + "method": "GET", + "statusCode": 200, + "headers": { + "Content-Type": "application/json" + }, + "body": "{\"id\":{{request.params.id}},\"name\":\"Product {{request.params.id}}\",\"price\":42,\"inStock\":true}", + "id": "product-get", + "path": "/products/:id", + "description": "Fetch one product; echoes the path param into the body." + }, + { + "method": "POST", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "product-create", + "path": "/products", + "description": "Create a product: 201, assigns an id, echoes the posted body.", + "script": "response.statusCode=201;response.headers['Content-Type']='application/json';var b=request.body||{};response.body=JSON.stringify(Object.assign({id:faker.number.int({min:1000,max:9999})},b,{createdAt:dayjs().toISOString()}));" + }, + { + "method": "PUT", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "product-replace", + "path": "/products/:id", + "description": "Replace a product (PUT). Echoes id + body.", + "script": "response.headers['Content-Type']='application/json';var b=request.body||{};response.body=JSON.stringify(Object.assign({id:Number(request.params.id)},b,{updatedAt:dayjs().toISOString()}));" + }, + { + "method": "PATCH", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "product-update", + "path": "/products/:id", + "description": "Partially update a product (PATCH).", + "script": "response.headers['Content-Type']='application/json';var b=request.body||{};response.body=JSON.stringify(Object.assign({id:Number(request.params.id),patched:true},b));" + }, + { + "method": "DELETE", + "statusCode": 204, + "headers": {}, + "body": "", + "id": "product-delete", + "path": "/products/:id", + "description": "Delete a product: 204 No Content." + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "headers-echo", + "path": "/headers", + "description": "Echo back the request headers (lowercased keys).", + "script": "response.headers['Content-Type']='application/json';response.body=JSON.stringify(request.headers);" + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "search", + "path": "/search", + "description": "Echo back the query parameters.", + "script": "response.headers['Content-Type']='application/json';response.body=JSON.stringify({query:request.query});" + }, + { + "method": "POST", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "login", + "path": "/login", + "description": "Issue a session token (used by the data-extraction example).", + "script": "response.headers['Content-Type']='application/json';var u=(request.body&&request.body.username)||null;response.body=JSON.stringify({token:'tok-'+faker.string.uuid(),userId:faker.number.int({min:1,max:999}),user:u});" + }, + { + "method": "GET", + "statusCode": 200, + "headers": {}, + "body": "", + "id": "me", + "path": "/me", + "description": "Protected profile. Requires Authorization: Bearer tok-…", + "script": "var a=request.headers.authorization||'';response.headers['Content-Type']='application/json';if(a.indexOf('Bearer tok-')===0){response.body=JSON.stringify({id:42,name:'Demo User',email:'demo@example.com'});}else{response.statusCode=401;response.body=JSON.stringify({error:'missing or invalid token'});}" + } + ] +} diff --git a/package.json b/package.json index 95ef02a..ad3a667 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@testsmith/api-spector", "productName": "API Spector", - "version": "0.2.9", + "version": "0.3.0", "description": "Local-first API testing tool to inspect, test and mock APIs", "repository": { "type": "git", diff --git a/src/main/auth-builder.ts b/src/main/auth-builder.ts index 83ee75d..b9fa3d0 100644 --- a/src/main/auth-builder.ts +++ b/src/main/auth-builder.ts @@ -2,9 +2,12 @@ // SPDX-License-Identifier: MIT import crypto from 'crypto'; -import type { AuthConfig, DigestAuth, NtlmAuth, Oauth2Auth } from '../shared/types'; +import { STATUS_CODES } from 'http'; +import { readFile } from 'fs/promises'; +import type { AuthConfig, DigestAuth, NtlmAuth, Oauth2Auth, TlsSettings } from '../shared/types'; import { getSecret } from './ipc/secret-handler'; import { interpolate } from './interpolation'; +import { createType1Message, decodeType2Message, createType3Message } from './ntlm'; // ─── Auth header builder ────────────────────────────────────────────────────── @@ -175,25 +178,138 @@ export async function performDigestAuth( return buildDigestAuthHeader(challenge, username, password, method, uri); } -// ─── NTLM helpers ───────────────────────────────────────────────────────────── +// ─── NTLM ─────────────────────────────────────────────────────────────────── /** - * NTLM is a 3-message handshake that requires keeping a persistent TCP - * connection across all three messages. The `httpntlm` npm package handles - * this correctly but is not currently in package.json. + * Minimal response shape the request handler consumes — deliberately matches + * the slice of the undici `fetch` response it reads (status, statusText, + * iterable headers, and a `text()` resolver), so the NTLM branch slots in + * alongside the normal/digest branches without special-casing downstream. + */ +export interface NtlmResponseAdapter { + status: number + statusText: string + headers: { forEach(cb: (value: string, key: string) => void): void } + text(): Promise +} + +export interface NtlmRequestOptions { + url: string + method: string + auth: NtlmAuth + vars: Record + /** Already-resolved request headers (user headers + content-type). No Authorization. */ + baseHeaders: Record + body?: string + tls?: TlsSettings + proxy?: { url?: string } +} + +/** Pull the base64 NTLM token out of a (possibly multi-valued) WWW-Authenticate header. */ +function extractNtlmChallenge(value: string | string[] | undefined): string | null { + if (!value) return null; + const values = Array.isArray(value) ? value : [value]; + for (const v of values) { + for (const part of v.split(',')) { + const m = /^\s*NTLM\s+(.+)\s*$/i.exec(part); + if (m) return m[1].trim(); + } + } + return null; +} + +async function buildNtlmConnectOpts(tls?: TlsSettings): Promise | undefined> { + if (!tls) return undefined; + const connect: Record = {}; + if (tls.rejectUnauthorized !== undefined) connect['rejectUnauthorized'] = tls.rejectUnauthorized; + if (tls.caCertPath) { try { connect['ca'] = await readFile(tls.caCertPath); } catch { /* ignore */ } } + if (tls.clientCertPath) { try { connect['cert'] = await readFile(tls.clientCertPath); } catch { /* ignore */ } } + if (tls.clientKeyPath) { try { connect['key'] = await readFile(tls.clientKeyPath); } catch { /* ignore */ } } + return Object.keys(connect).length ? connect : undefined; +} + +/** + * Perform an NTLM (NTLMv2) authenticated request. + * + * NTLM authenticates the *connection*, not the request, so the three messages + * (negotiate → challenge → authenticate) must travel over one keep-alive + * socket. We use a dedicated single-connection undici `Client` to guarantee + * that — the global fetch pool gives no such guarantee. * - * TODO: add `httpntlm` to dependencies and implement this. - * Until then this function throws so the caller can surface a helpful error. + * 1. send Type 1 (negotiate), expect 401 + `WWW-Authenticate: NTLM ` + * 2. parse the Type 2 challenge + * 3. resend on the SAME connection with Type 3 (authenticate) + the real body */ -export async function performNtlmRequest( - _url: string, - _method: string, - _auth: NtlmAuth, - _vars: Record, -): Promise { - throw new Error( - 'NTLM auth is not yet implemented. Add "httpntlm" to package.json dependencies and implement performNtlmRequest in auth-builder.ts.', - ); +export async function performNtlmRequest(opts: NtlmRequestOptions): Promise { + if (opts.proxy?.url) { + throw new Error('NTLM authentication through a proxy is not supported. Disable the proxy for this request, or target the server directly.'); + } + + const { Client } = await import('undici'); + + // Resolve credentials + let password = opts.auth.password ?? ''; + if (!password && opts.auth.passwordSecretRef) password = (await getSecret(opts.auth.passwordSecretRef)) ?? ''; + password = interpolate(password, opts.vars); + const username = interpolate(opts.auth.username ?? '', opts.vars); + const domain = interpolate(opts.auth.ntlmDomain ?? '', opts.vars); + const workstation = interpolate(opts.auth.ntlmWorkstation ?? '', opts.vars); + + const parsed = new URL(opts.url); + const origin = `${parsed.protocol}//${parsed.host}`; + const path = parsed.pathname + parsed.search; + + const connect = await buildNtlmConnectOpts(opts.tls); + const client = new Client(origin, { pipelining: 1, ...(connect ? { connect } : {}) }); + + const toAdapter = (statusCode: number, headers: Record, bodyText: string): NtlmResponseAdapter => { + const entries: [string, string][] = []; + for (const [k, v] of Object.entries(headers)) { + if (v === undefined) continue; + entries.push([k, Array.isArray(v) ? v.join(', ') : String(v)]); + } + return { + status: statusCode, + statusText: STATUS_CODES[statusCode] ?? '', + headers: { forEach: (cb) => entries.forEach(([k, v]) => cb(v, k)) }, + text: () => Promise.resolve(bodyText), + }; + }; + + try { + // ── Message 1: negotiate ──────────────────────────────────────────────── + const negotiate = await client.request({ + path, + method: opts.method, + headers: { authorization: `NTLM ${createType1Message()}` }, + }); + // Must drain the body before reusing the connection for message 3. + await negotiate.body.text(); + + const challengeToken = extractNtlmChallenge(negotiate.headers['www-authenticate']); + + // Server didn't offer an NTLM challenge — surface whatever it returned. + if (negotiate.statusCode !== 401 || !challengeToken) { + const second = await client.request({ path, method: opts.method, headers: opts.baseHeaders, body: opts.body }); + const bodyText = await second.body.text(); + return toAdapter(second.statusCode, second.headers, bodyText); + } + + // ── Message 3: authenticate (same connection) ──────────────────────────── + const challenge = decodeType2Message(challengeToken); + const type3 = createType3Message({ user: username, password, domain, workstation, challenge }); + + const authed = await client.request({ + path, + method: opts.method, + headers: { ...opts.baseHeaders, authorization: `NTLM ${type3}` }, + body: opts.body, + }); + const bodyText = await authed.body.text(); + return toAdapter(authed.statusCode, authed.headers, bodyText); + } finally { + await client.close().catch(() => { /* best effort */ }); + } } // ─── OAuth 2.0 token fetch ──────────────────────────────────────────────────── diff --git a/src/main/ipc/request-handler.ts b/src/main/ipc/request-handler.ts index 3656bb6..fd280c5 100644 --- a/src/main/ipc/request-handler.ts +++ b/src/main/ipc/request-handler.ts @@ -492,9 +492,8 @@ export function registerRequestHandler(ipc: IpcMain): void { const methodHasBody = !['GET', 'HEAD'].includes(req.method); - // Helper that adds Content-Type defaults and fires the actual request - const doFetch = async (overrideHeaders?: Headers): Promise> => { - const h = overrideHeaders ?? buildHeaders(); + // Apply Content-Type / SOAPAction defaults that depend on the body mode. + const applyBodyHeaders = (h: Headers): Headers => { if (body !== undefined) { if (!h.has('content-type')) { if (req.body.mode === 'json' || req.body.mode === 'graphql') h.set('Content-Type', 'application/json'); @@ -507,10 +506,21 @@ export function registerRequestHandler(ipc: IpcMain): void { h.set('SOAPAction', req.body.soap.soapAction); } } - // Capture what we're actually sending + return h; + }; + + // Snapshot the outgoing request for the UI / history panel. + const captureSent = (h: Headers): Record => { const capturedHeaders: Record = {}; h.forEach((value, key) => { capturedHeaders[key] = value; }); sentRequest = { method: req.method, url: finalUrl, headers: capturedHeaders, body: methodHasBody ? body : undefined }; + return capturedHeaders; + }; + + // Helper that adds Content-Type defaults and fires the actual request + const doFetch = async (overrideHeaders?: Headers): Promise> => { + const h = applyBodyHeaders(overrideHeaders ?? buildHeaders()); + captureSent(h); return fetch(finalUrl, { method: req.method, headers: h, @@ -521,12 +531,20 @@ export function registerRequestHandler(ipc: IpcMain): void { let fetchResp: Awaited>; - // ── NTLM ────────────────────────────────────────────────────────────── + // ── NTLM 3-message handshake over one keep-alive connection ──────────── if (req.auth.type === 'ntlm') { - // performNtlmRequest currently throws with a helpful TODO message - await performNtlmRequest(finalUrl, req.method, req.auth, vars); - // unreachable — silence TS - fetchResp = await doFetch(); + const h = applyBodyHeaders(buildHeaders()); + const capturedHeaders = captureSent(h); + fetchResp = await performNtlmRequest({ + url: finalUrl, + method: req.method, + auth: req.auth, + vars, + baseHeaders: capturedHeaders, + body: methodHasBody ? body : undefined, + tls, + proxy, + }) as unknown as Awaited>; } // ── Digest two-round-trip ────────────────────────────────────────────── else if (req.auth.type === 'digest') { diff --git a/src/main/ipc/runner-handler.ts b/src/main/ipc/runner-handler.ts index e69d707..4a78762 100644 --- a/src/main/ipc/runner-handler.ts +++ b/src/main/ipc/runner-handler.ts @@ -67,6 +67,8 @@ async function executeOne ( localVars: Record, dispatcher: ProxyAgent | Agent | undefined, piiMaskPatterns: string[], + proxy?: RunnerPayload['proxy'], + tls?: RunnerPayload['tls'], ): Promise { // Defensive defaults — AI-generated collections may omit empty arrays if ( !req.headers ) req.headers = []; @@ -172,8 +174,18 @@ async function executeOne ( let fetchResp: Awaited>; if ( req.auth.type === 'ntlm' ) { - await performNtlmRequest( resolvedUrl, req.method, req.auth, vars ); - fetchResp = await doFetch( headers ); // unreachable + const capturedHeaders: Record = {}; + headers.forEach( ( v, k ) => { capturedHeaders[k] = v; } ); + fetchResp = await performNtlmRequest( { + url: resolvedUrl, + method: req.method, + auth: req.auth, + vars, + baseHeaders: capturedHeaders, + body: methodHasBody ? body : undefined, + tls, + proxy, + } ) as unknown as Awaited>; } else if ( req.auth.type === 'digest' ) { const probeFetch = ( url: string, init: Record ) => fetch( url, { @@ -406,6 +418,8 @@ export function registerRunnerHandler ( ipc: IpcMain ): void { { ...runLocalVars, ...( item.dataRow ?? {} ) }, dispatcher, piiMaskPatterns, + proxy, + tls, ); runEnvVars = updatedEnvVars; diff --git a/src/main/mock-server.ts b/src/main/mock-server.ts index 9a1010b..2bc494f 100644 --- a/src/main/mock-server.ts +++ b/src/main/mock-server.ts @@ -5,6 +5,7 @@ import { createServer, type IncomingMessage, type ServerResponse, type Server } import { randomUUID } from 'crypto'; import * as vm from 'vm'; import dayjs from 'dayjs'; +import { DOMParser } from '@xmldom/xmldom'; import type { MockServer, MockRoute, MockHit } from '../shared/types'; import type { faker as FakerType } from '@faker-js/faker'; @@ -88,6 +89,96 @@ function readBody(req: IncomingMessage): Promise { }); } +// ─── XML body parsing ─────────────────────────────────────────────────────── + +/** Minimal subset of the @xmldom/xmldom node shape we walk. */ +interface XmlNode { + nodeType: number + nodeName: string + nodeValue: string | null + childNodes: { length: number; [i: number]: XmlNode } + attributes: { length: number; [i: number]: { nodeName: string; nodeValue: string | null } } | null +} + +const ELEMENT_NODE = 1, TEXT_NODE = 3, CDATA_NODE = 4; + +/** + * Convert an XML element into a plain JS value so `request.body.x` works the + * same as it does for JSON. Rules: + * - leaf element (text only) → its trimmed text string + * - element with children/attrs → object keyed by child tag name + * - repeated child tags → array + * - attributes → `@name` keys + * - mixed text + children → text stored under `#text` + * Namespaced tags keep their prefix (e.g. `soap:Body`); access via bracket + * notation in templates: `request.body['soap:Envelope']`. + */ +function elementToValue(el: XmlNode): unknown { + const obj: Record = {}; + + if (el.attributes) { + for (let i = 0; i < el.attributes.length; i++) { + const a = el.attributes[i]; + obj['@' + a.nodeName] = a.nodeValue ?? ''; + } + } + + let text = ''; + const childEls: XmlNode[] = []; + for (let i = 0; i < el.childNodes.length; i++) { + const c = el.childNodes[i]; + if (c.nodeType === ELEMENT_NODE) childEls.push(c); + else if (c.nodeType === TEXT_NODE || c.nodeType === CDATA_NODE) text += c.nodeValue ?? ''; + } + + for (const child of childEls) { + const name = child.nodeName; + const val = elementToValue(child); + const existing = obj[name]; + if (existing === undefined) obj[name] = val; + else if (Array.isArray(existing)) existing.push(val); + else obj[name] = [existing, val]; + } + + const trimmed = text.trim(); + // Pure leaf: no children and no attributes → just the text. + if (childEls.length === 0 && Object.keys(obj).length === 0) return trimmed; + if (trimmed) obj['#text'] = trimmed; + return obj; +} + +/** Parse an XML string into a plain object, or null if it isn't valid XML. */ +function parseXmlToObject(xml: string): Record | null { + try { + const silent = { warning() {}, error() {}, fatalError() {} }; + const doc = new DOMParser({ errorHandler: silent }) + .parseFromString(xml, 'text/xml') as unknown as { documentElement: XmlNode | null }; + const root = doc.documentElement; + if (!root || root.nodeType !== ELEMENT_NODE) return null; + return { [root.nodeName]: elementToValue(root) }; + } catch { + return null; + } +} + +/** + * Parse a request body into a value for `request.body`. Tries JSON first, then + * XML (by content-type or a leading `<`); falls back to `{}` for anything else. + */ +function parseRequestBody(bodyRaw: string, contentType: string): unknown { + try { + return JSON.parse(bodyRaw); + } catch { /* not JSON — fall through */ } + + const ct = contentType.toLowerCase(); + if (ct.includes('xml') || bodyRaw.trimStart().startsWith('<')) { + const xmlObj = parseXmlToObject(bodyRaw); + if (xmlObj) return xmlObj; + } + + return {}; +} + // ─── Body interpolation ─────────────────────────────────────────────────────── /** @@ -133,10 +224,10 @@ async function handleRequest( res.writeHead(204); res.end(); return; } - // Read request body before matching so scripts can inspect it - const bodyRaw = await readBody(req); - let bodyParsed: unknown = {}; - try { bodyParsed = JSON.parse(bodyRaw); } catch { /* not JSON */ } + // Read request body before matching so scripts can inspect it. + // Parses JSON or XML into `request.body`; raw string stays in `request.bodyRaw`. + const bodyRaw = await readBody(req); + const bodyParsed = parseRequestBody(bodyRaw, req.headers['content-type'] ?? ''); // Always read from liveRoutes so edits apply without restart const routes = liveRoutes.get(serverId) ?? []; diff --git a/src/main/ntlm.ts b/src/main/ntlm.ts new file mode 100644 index 0000000..d14325c --- /dev/null +++ b/src/main/ntlm.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2024-2026 Testsmith.io +// SPDX-License-Identifier: MIT + +// NTLM (NTLMv2) message construction, per [MS-NLMP]. +// +// We implement this ourselves rather than pull in a dependency because: +// - the NT hash needs MD4, which OpenSSL 3 (Node's crypto) no longer exposes, +// so we ship a small pure-JS MD4 below; and +// - we only need NTLMv2, which is HMAC-MD5 based — no DES, no LM hash. +// +// The transport (3-message handshake over one keep-alive socket) lives in +// auth-builder.ts; this module is pure encoding and is unit-tested against the +// worked example in [MS-NLMP] §4.2.4. + +import crypto from 'crypto'; + +const SIGNATURE = Buffer.from('NTLMSSP\0', 'latin1'); + +// NegotiateFlags bits we care about. +const F_UNICODE = 0x00000001; +const F_OEM = 0x00000002; +const F_REQUEST_TARGET = 0x00000004; +const F_NTLM = 0x00000200; +const F_ALWAYS_SIGN = 0x00008000; +const F_EXT_SESSION = 0x00080000; // NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY + +const TYPE1_FLAGS = F_UNICODE | F_OEM | F_REQUEST_TARGET | F_NTLM | F_ALWAYS_SIGN | F_EXT_SESSION; +const TYPE3_FLAGS = F_UNICODE | F_NTLM | F_ALWAYS_SIGN | F_EXT_SESSION; + +// ─── MD4 (pure JS — crypto can't provide it under OpenSSL 3) ─────────────────── + +function rotl(x: number, n: number): number { + return ((x << n) | (x >>> (32 - n))) >>> 0; +} + +export function md4(msg: Buffer): Buffer { + const len = msg.length; + const bitLen = len * 8; + const padLen = (56 - ((len + 1) % 64) + 64) % 64; + const total = len + 1 + padLen + 8; + const buf = Buffer.alloc(total); + msg.copy(buf, 0); + buf[len] = 0x80; + buf.writeUInt32LE(bitLen >>> 0, total - 8); + buf.writeUInt32LE(Math.floor(bitLen / 0x100000000) >>> 0, total - 4); + + let a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476; + const X = new Array(16); + + const F = (x: number, y: number, z: number) => (x & y) | (~x & z); + const G = (x: number, y: number, z: number) => (x & y) | (x & z) | (y & z); + const H = (x: number, y: number, z: number) => x ^ y ^ z; + const FF = (aa: number, bb: number, cc: number, dd: number, k: number, s: number) => + rotl((aa + F(bb, cc, dd) + X[k]) >>> 0, s); + const GG = (aa: number, bb: number, cc: number, dd: number, k: number, s: number) => + rotl((aa + G(bb, cc, dd) + X[k] + 0x5a827999) >>> 0, s); + const HH = (aa: number, bb: number, cc: number, dd: number, k: number, s: number) => + rotl((aa + H(bb, cc, dd) + X[k] + 0x6ed9eba1) >>> 0, s); + + for (let i = 0; i < total; i += 64) { + for (let j = 0; j < 16; j++) X[j] = buf.readUInt32LE(i + j * 4); + const aa = a, bb = b, cc = c, dd = d; + + // Round 1 + for (let k = 0; k < 16; k += 4) { + a = FF(a, b, c, d, k, 3); + d = FF(d, a, b, c, k + 1, 7); + c = FF(c, d, a, b, k + 2, 11); + b = FF(b, c, d, a, k + 3, 19); + } + // Round 2 + for (let k = 0; k < 4; k++) { + a = GG(a, b, c, d, k, 3); + d = GG(d, a, b, c, k + 4, 5); + c = GG(c, d, a, b, k + 8, 9); + b = GG(b, c, d, a, k + 12, 13); + } + // Round 3 + const order = [0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15]; + for (let k = 0; k < 16; k += 4) { + a = HH(a, b, c, d, order[k], 3); + d = HH(d, a, b, c, order[k + 1], 9); + c = HH(c, d, a, b, order[k + 2], 11); + b = HH(b, c, d, a, order[k + 3], 15); + } + + a = (a + aa) >>> 0; + b = (b + bb) >>> 0; + c = (c + cc) >>> 0; + d = (d + dd) >>> 0; + } + + const out = Buffer.alloc(16); + out.writeUInt32LE(a, 0); + out.writeUInt32LE(b, 4); + out.writeUInt32LE(c, 8); + out.writeUInt32LE(d, 12); + return out; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const utf16le = (s: string) => Buffer.from(s, 'utf16le'); +const hmacMd5 = (key: Buffer, data: Buffer) => crypto.createHmac('md5', key).update(data).digest(); + +/** NTOWFv2 = HMAC_MD5(MD4(UNICODE(password)), UNICODE(UPPER(user) + domain)). */ +export function ntowfv2(user: string, domain: string, password: string): Buffer { + const ntHash = md4(utf16le(password)); + return hmacMd5(ntHash, utf16le(user.toUpperCase() + domain)); +} + +/** FILETIME: 100-ns ticks since 1601-01-01, little-endian. */ +function filetime(unixMs: number): Buffer { + const ticks = (BigInt(unixMs) + 11644473600000n) * 10000n; + const buf = Buffer.alloc(8); + buf.writeBigUInt64LE(ticks); + return buf; +} + +// ─── Type 1: NEGOTIATE ────────────────────────────────────────────────────── + +/** Build the Type 1 (negotiate) token, base64-encoded (no "NTLM " prefix). */ +export function createType1Message(): string { + const msg = Buffer.alloc(32); + SIGNATURE.copy(msg, 0); + msg.writeUInt32LE(1, 8); // MessageType + msg.writeUInt32LE(TYPE1_FLAGS, 12); // NegotiateFlags + // Domain (8) and Workstation (8) fields left zeroed; offset points past header. + msg.writeUInt32LE(32, 16); // domain offset + msg.writeUInt32LE(32, 24); // workstation offset + return msg.toString('base64'); +} + +// ─── Type 2: CHALLENGE (parse) ────────────────────────────────────────────── + +export interface Type2Challenge { + serverChallenge: Buffer // 8 bytes + targetInfo: Buffer // AV_PAIR block (may be empty) + flags: number +} + +/** Parse a Type 2 (challenge) token (base64 string or raw bytes). */ +export function decodeType2Message(token: string | Buffer): Type2Challenge { + const buf = Buffer.isBuffer(token) ? token : Buffer.from(token, 'base64'); + if (buf.length < 32 || !buf.subarray(0, 8).equals(SIGNATURE)) { + throw new Error('Invalid NTLM Type 2 message'); + } + const flags = buf.readUInt32LE(20); + const serverChallenge = Buffer.from(buf.subarray(24, 32)); + + let targetInfo = Buffer.alloc(0); + if (buf.length >= 48) { + const tiLen = buf.readUInt16LE(40); + const tiOff = buf.readUInt32LE(44); + if (tiLen > 0 && tiOff + tiLen <= buf.length) { + targetInfo = Buffer.from(buf.subarray(tiOff, tiOff + tiLen)); + } + } + return { serverChallenge, targetInfo, flags }; +} + +// ─── NTLMv2 response computation ────────────────────────────────────────────── + +export interface NtlmV2Response { + ntResponse: Buffer // NTProofStr (16) + blob + lmResponse: Buffer // 24 bytes + ntProof: Buffer // 16 bytes (exposed for testing) +} + +export function computeNtlmV2Response(opts: { + user: string + domain: string + password: string + serverChallenge: Buffer + targetInfo: Buffer + clientChallenge: Buffer // 8 bytes + timestamp: Buffer // 8-byte FILETIME +}): NtlmV2Response { + const responseKey = ntowfv2(opts.user, opts.domain, opts.password); + + // "temp" blob, [MS-NLMP] §3.3.2 / §2.2.2.7 + const blob = Buffer.concat([ + Buffer.from([0x01, 0x01, 0x00, 0x00]), // RespType + HiRespType + reserved + Buffer.from([0x00, 0x00, 0x00, 0x00]), + opts.timestamp, + opts.clientChallenge, + Buffer.from([0x00, 0x00, 0x00, 0x00]), + opts.targetInfo, + Buffer.from([0x00, 0x00, 0x00, 0x00]), + ]); + + const ntProof = hmacMd5(responseKey, Buffer.concat([opts.serverChallenge, blob])); + const ntResponse = Buffer.concat([ntProof, blob]); + + const lmProof = hmacMd5(responseKey, Buffer.concat([opts.serverChallenge, opts.clientChallenge])); + const lmResponse = Buffer.concat([lmProof, opts.clientChallenge]); + + return { ntResponse, lmResponse, ntProof }; +} + +// ─── Type 3: AUTHENTICATE ────────────────────────────────────────────────── + +export interface Type3Options { + user: string + password: string + domain?: string + workstation?: string + challenge: Type2Challenge + /** Test seams — supplied randomly/by clock in production. */ + clientChallenge?: Buffer + timestamp?: number // unix ms +} + +/** Build the Type 3 (authenticate) token, base64-encoded (no "NTLM " prefix). */ +export function createType3Message(opts: Type3Options): string { + const domain = opts.domain ?? ''; + const workstation = opts.workstation ?? ''; + const clientChallenge = opts.clientChallenge ?? crypto.randomBytes(8); + const timestamp = filetime(opts.timestamp ?? Date.now()); + + const { ntResponse, lmResponse } = computeNtlmV2Response({ + user: opts.user, + domain, + password: opts.password, + serverChallenge: opts.challenge.serverChallenge, + targetInfo: opts.challenge.targetInfo, + clientChallenge, + timestamp, + }); + + const domainBuf = utf16le(domain); + const userBuf = utf16le(opts.user); + const wsBuf = utf16le(workstation); + + // Header: signature(8) + type(4) + 6×field(8) + flags(4) = 64 bytes. + const HEADER = 64; + const payload = Buffer.concat([lmResponse, ntResponse, domainBuf, userBuf, wsBuf]); + const msg = Buffer.alloc(HEADER + payload.length); + + SIGNATURE.copy(msg, 0); + msg.writeUInt32LE(3, 8); // MessageType + + let off = HEADER; + const writeField = (pos: number, buf: Buffer) => { + msg.writeUInt16LE(buf.length, pos); // Len + msg.writeUInt16LE(buf.length, pos + 2); // MaxLen + msg.writeUInt32LE(buf.length ? off : HEADER, pos + 4); // BufferOffset + buf.copy(msg, off); + off += buf.length; + }; + + writeField(12, lmResponse); // LmChallengeResponse + writeField(20, ntResponse); // NtChallengeResponse + writeField(28, domainBuf); // DomainName + writeField(36, userBuf); // UserName + writeField(44, wsBuf); // Workstation + // EncryptedRandomSessionKey (52): empty + msg.writeUInt16LE(0, 52); + msg.writeUInt16LE(0, 54); + msg.writeUInt32LE(HEADER, 56); + + msg.writeUInt32LE(TYPE3_FLAGS, 60); // NegotiateFlags + + return msg.toString('base64'); +} diff --git a/src/renderer/src/components/RequestBuilder/AuthTab.tsx b/src/renderer/src/components/RequestBuilder/AuthTab.tsx index 4a25dcd..8c9bcff 100644 --- a/src/renderer/src/components/RequestBuilder/AuthTab.tsx +++ b/src/renderer/src/components/RequestBuilder/AuthTab.tsx @@ -202,8 +202,8 @@ export function AuthTab({ request, onChange }: { request: ApiRequest; onChange: /> -

- NTLM support is pending. Add httpntlm to dependencies to enable it. +

+ Uses NTLMv2 over a single keep-alive connection. Not supported through a proxy.

)} diff --git a/src/tests/mock-server.test.ts b/src/tests/mock-server.test.ts index 8468b31..0698c86 100644 --- a/src/tests/mock-server.test.ts +++ b/src/tests/mock-server.test.ts @@ -16,10 +16,11 @@ async function hit( path: string, method = 'GET', body?: string, + contentType = 'application/json', ): Promise<{ status: number; text: string }> { const resp = await fetch(`http://127.0.0.1:${port}${path}`, { method, - headers: body ? { 'Content-Type': 'application/json' } : undefined, + headers: body ? { 'Content-Type': contentType } : undefined, body, }); return { status: resp.status, text: await resp.text() }; @@ -224,6 +225,80 @@ describe('mock server: body interpolation', () => { const parsed = JSON.parse((await hit(port, '/multi/foo/bar')).text); expect(parsed).toEqual({ a: 'foo', b: 'bar' }); }); + + it('reuses a value from a JSON request body', async () => { + const port = nextPort(); + const mock = makeMock(port, [makeRoute({ + method: 'POST', path: '/echo', + body: '{"email":"{{request.body.email}}"}', + })]); + cleanup.push(mock.id); + await startMock(mock); + const parsed = JSON.parse( + (await hit(port, '/echo', 'POST', '{"email":"a@b.com"}')).text, + ); + expect(parsed.email).toBe('a@b.com'); + }); +}); + +// ─── XML request bodies ───────────────────────────────────────────────────── + +describe('mock server: XML request bodies', () => { + it('reuses a leaf value from an XML body (text/xml)', async () => { + const port = nextPort(); + const mock = makeMock(port, [makeRoute({ + method: 'POST', path: '/order', + headers: { 'Content-Type': 'application/xml' }, + body: '{{request.body.order.orderId}}', + })]); + cleanup.push(mock.id); + await startMock(mock); + const xml = '42Roy'; + const res = await hit(port, '/order', 'POST', xml, 'text/xml'); + expect(res.text).toBe('42'); + }); + + it('parses XML detected by a leading "<" even without an xml content-type', async () => { + const port = nextPort(); + const mock = makeMock(port, [makeRoute({ + method: 'POST', path: '/x', + body: '{{request.body.root.name}}', + })]); + cleanup.push(mock.id); + await startMock(mock); + // content-type defaults to application/json, but the body is clearly XML + const res = await hit(port, '/x', 'POST', 'Ada'); + expect(res.text).toBe('Ada'); + }); + + it('exposes namespaced SOAP elements via bracket access', async () => { + const port = nextPort(); + const mock = makeMock(port, [makeRoute({ + method: 'POST', path: '/soap', + headers: { 'Content-Type': 'text/xml' }, + body: "{{request.body['soap:Envelope']['soap:Body'].GetPrice.item}}", + })]); + cleanup.push(mock.id); + await startMock(mock); + const envelope = + 'widget'; + const res = await hit(port, '/soap', 'POST', envelope, 'text/xml'); + expect(res.text).toBe('widget'); + }); + + it('maps repeated XML tags to an array', async () => { + const port = nextPort(); + const mock = makeMock(port, [makeRoute({ + method: 'POST', path: '/list', + headers: { 'Content-Type': 'application/xml' }, + body: '{{request.body.cart.item[1]}}', + })]); + cleanup.push(mock.id); + await startMock(mock); + const xml = 'ab'; + const res = await hit(port, '/list', 'POST', xml, 'application/xml'); + expect(res.text).toBe('b'); + }); }); // ─── Pre-response scripts ───────────────────────────────────────────────────── diff --git a/src/tests/ntlm-transport.test.ts b/src/tests/ntlm-transport.test.ts new file mode 100644 index 0000000..2894476 --- /dev/null +++ b/src/tests/ntlm-transport.test.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2024-2026 Testsmith.io +// SPDX-License-Identifier: MIT + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'; +import type { Socket } from 'net'; +import crypto from 'crypto'; + +vi.mock('../main/ipc/secret-handler', () => ({ getSecret: vi.fn().mockResolvedValue(null) })); + +import { performNtlmRequest } from '../main/auth-builder'; +import { ntowfv2 } from '../main/ntlm'; + +const SIGNATURE = Buffer.from('NTLMSSP\0', 'latin1'); +const USER = 'alice'; +const DOMAIN = 'CORP'; +const PASSWORD = 'S3cret!'; + +// Minimal Type 2 (challenge) builder for the fake server. +function buildType2(serverChallenge: Buffer, targetInfo: Buffer): string { + const buf = Buffer.alloc(48 + targetInfo.length); + SIGNATURE.copy(buf, 0); + buf.writeUInt32LE(2, 8); // MessageType + buf.writeUInt32LE(0x00088205, 20); // flags (unicode + ntlm + target info) + serverChallenge.copy(buf, 24); + buf.writeUInt16LE(targetInfo.length, 40); + buf.writeUInt16LE(targetInfo.length, 42); + buf.writeUInt32LE(48, 44); + targetInfo.copy(buf, 48); + return buf.toString('base64'); +} + +const hmacMd5 = (key: Buffer, data: Buffer) => crypto.createHmac('md5', key).update(data).digest(); + +// Server-side NTLMv2 verification: recompute NTProofStr from the client's blob. +function verifyType3(type3: Buffer, serverChallenge: Buffer): boolean { + if (!type3.subarray(0, 8).equals(SIGNATURE) || type3.readUInt32LE(8) !== 3) return false; + const ntLen = type3.readUInt16LE(20); + const ntOff = type3.readUInt32LE(24); + const ntResponse = type3.subarray(ntOff, ntOff + ntLen); + const proof = ntResponse.subarray(0, 16); + const blob = ntResponse.subarray(16); + const expected = hmacMd5(ntowfv2(USER, DOMAIN, PASSWORD), Buffer.concat([serverChallenge, blob])); + return proof.equals(expected); +} + +function authType(headerValue: string): 'type1' | 'type3' | null { + const m = /^NTLM\s+(.+)$/i.exec(headerValue ?? ''); + if (!m) return null; + const buf = Buffer.from(m[1], 'base64'); + if (!buf.subarray(0, 8).equals(SIGNATURE)) return null; + return buf.readUInt32LE(8) === 1 ? 'type1' : buf.readUInt32LE(8) === 3 ? 'type3' : null; +} + +describe('performNtlmRequest — end-to-end against a real NTLM server', () => { + let server: Server; + let port: number; + // Challenge is bound to the socket — this is what makes the test also verify + // that message 3 arrives on the SAME connection as message 1. + const challengeBySocket = new WeakMap(); + const targetInfo = Buffer.from('02000800430041000000', 'hex'); // tiny AV_PAIR block + let sawSameSocket = false; + + beforeAll(async () => { + server = createServer((req: IncomingMessage, res: ServerResponse) => { + const auth = req.headers['authorization'] ?? ''; + const kind = authType(auth); + + const finish = () => { + if (kind === 'type1') { + const challenge = crypto.randomBytes(8); + challengeBySocket.set(req.socket, challenge); + res.writeHead(401, { + 'WWW-Authenticate': `NTLM ${buildType2(challenge, targetInfo)}`, + 'Content-Type': 'text/plain', + Connection: 'keep-alive', + }); + res.end('challenge'); + return; + } + if (kind === 'type3') { + const challenge = challengeBySocket.get(req.socket); + if (challenge) sawSameSocket = true; + const token = /^NTLM\s+(.+)$/i.exec(auth)![1]; + const ok = !!challenge && verifyType3(Buffer.from(token, 'base64'), challenge); + if (ok) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ authenticated: true, method: req.method, scheme: 'ntlm' })); + } else { + res.writeHead(401, { 'Content-Type': 'text/plain' }); + res.end('bad credentials'); + } + return; + } + // No NTLM header at all — demand it. + res.writeHead(401, { 'WWW-Authenticate': 'NTLM', 'Content-Type': 'text/plain' }); + res.end('need ntlm'); + }; + + // drain request body, then respond + req.on('data', () => {}); + req.on('end', finish); + }); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as { port: number }).port; + }); + + afterAll(async () => { + await new Promise(resolve => server.close(() => resolve())); + }); + + it('completes the handshake and authenticates (GET)', async () => { + const resp = await performNtlmRequest({ + url: `http://127.0.0.1:${port}/secure`, + method: 'GET', + auth: { type: 'ntlm', username: USER, password: PASSWORD, ntlmDomain: DOMAIN, ntlmWorkstation: 'PC1' }, + vars: {}, + baseHeaders: { accept: 'application/json' }, + }); + + expect(resp.status).toBe(200); + const body = JSON.parse(await resp.text()); + expect(body.authenticated).toBe(true); + expect(body.method).toBe('GET'); + expect(sawSameSocket).toBe(true); // message 3 reused message 1's connection + }); + + it('sends the body on the authenticate leg (POST)', async () => { + const resp = await performNtlmRequest({ + url: `http://127.0.0.1:${port}/secure`, + method: 'POST', + auth: { type: 'ntlm', username: USER, password: PASSWORD, ntlmDomain: DOMAIN }, + vars: {}, + baseHeaders: { 'content-type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }), + }); + expect(resp.status).toBe(200); + expect(JSON.parse(await resp.text()).method).toBe('POST'); + }); + + it('returns 401 for a wrong password', async () => { + const resp = await performNtlmRequest({ + url: `http://127.0.0.1:${port}/secure`, + method: 'GET', + auth: { type: 'ntlm', username: USER, password: 'wrong-password', ntlmDomain: DOMAIN }, + vars: {}, + baseHeaders: {}, + }); + expect(resp.status).toBe(401); + }); + + it('resolves credentials from interpolation vars', async () => { + const resp = await performNtlmRequest({ + url: `http://127.0.0.1:${port}/secure`, + method: 'GET', + auth: { type: 'ntlm', username: '{{u}}', password: '{{p}}', ntlmDomain: '{{d}}' }, + vars: { u: USER, p: PASSWORD, d: DOMAIN }, + baseHeaders: {}, + }); + expect(resp.status).toBe(200); + }); + + it('rejects NTLM-over-proxy with a clear error', async () => { + await expect(performNtlmRequest({ + url: `http://127.0.0.1:${port}/secure`, + method: 'GET', + auth: { type: 'ntlm', username: USER, password: PASSWORD }, + vars: {}, + baseHeaders: {}, + proxy: { url: 'http://localhost:8888' }, + })).rejects.toThrow(/proxy/i); + }); +}); diff --git a/src/tests/ntlm.test.ts b/src/tests/ntlm.test.ts new file mode 100644 index 0000000..2176740 --- /dev/null +++ b/src/tests/ntlm.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2024-2026 Testsmith.io +// SPDX-License-Identifier: MIT + +import { describe, it, expect } from 'vitest'; +import { + md4, + ntowfv2, + computeNtlmV2Response, + createType1Message, + decodeType2Message, + createType3Message, +} from '../main/ntlm'; + +const hex = (b: Buffer) => b.toString('hex'); + +describe('md4', () => { + // RFC 1320 test vectors + it('hashes the empty string', () => { + expect(hex(md4(Buffer.from('')))).toBe('31d6cfe0d16ae931b73c59d7e0c089c0'); + }); + it('hashes "abc"', () => { + expect(hex(md4(Buffer.from('abc')))).toBe('a448017aaf21d8525fc10ae87aa6729d'); + }); + it('hashes the long alphanumeric vector', () => { + const msg = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + expect(hex(md4(Buffer.from(msg)))).toBe('043f8582f241db351ce627e153e7f0e4'); + }); +}); + +// [MS-NLMP] §4.2.4 — the canonical NTLMv2 worked example. +describe('NTLMv2 — [MS-NLMP] §4.2.4 reference vectors', () => { + const user = 'User'; + const domain = 'Domain'; + const password = 'Password'; + const serverChallenge = Buffer.from('0123456789abcdef', 'hex'); + const clientChallenge = Buffer.from('aaaaaaaaaaaaaaaa', 'hex'); + const timestamp = Buffer.alloc(8); // all zeros, per the example + // §4.2.4.1.3 target info (AV_PAIRs: NetBIOS domain "Domain", server "Server") + const targetInfo = Buffer.from( + '02000c0044006f006d00610069006e000100' + + '0c0053006500720076006500720000000000', + 'hex', + ); + + it('NTOWFv2 matches the spec', () => { + expect(hex(ntowfv2(user, domain, password))).toBe('0c868a403bfd7a93a3001ef22ef02e3f'); + }); + + it('NTProofStr matches the spec', () => { + const { ntProof } = computeNtlmV2Response({ + user, domain, password, serverChallenge, targetInfo, clientChallenge, timestamp, + }); + expect(hex(ntProof)).toBe('68cd0ab851e51c96aabc927bebef6a1c'); + }); + + it('NtChallengeResponse = NTProofStr followed by the blob', () => { + const { ntResponse, ntProof } = computeNtlmV2Response({ + user, domain, password, serverChallenge, targetInfo, clientChallenge, timestamp, + }); + expect(ntResponse.subarray(0, 16)).toEqual(ntProof); + // blob begins with 0x01010000 + 4 reserved zero bytes + expect(hex(ntResponse.subarray(16, 24))).toBe('0101000000000000'); + }); +}); + +describe('NTLM message framing', () => { + it('Type 1 is a well-formed NTLMSSP negotiate message', () => { + const buf = Buffer.from(createType1Message(), 'base64'); + expect(buf.subarray(0, 8).toString('latin1')).toBe('NTLMSSP\0'); + expect(buf.readUInt32LE(8)).toBe(1); + }); + + it('round-trips a Type 2 challenge into a parseable Type 3', () => { + // Hand-build a minimal Type 2 challenge with a server challenge + target info. + const targetInfo = Buffer.from('02000c0044006f006d00610069006e000000', 'hex'); + const t2 = Buffer.alloc(48 + targetInfo.length); + Buffer.from('NTLMSSP\0', 'latin1').copy(t2, 0); + t2.writeUInt32LE(2, 8); + Buffer.from('1122334455667788', 'hex').copy(t2, 24); // server challenge + t2.writeUInt16LE(targetInfo.length, 40); + t2.writeUInt16LE(targetInfo.length, 42); + t2.writeUInt32LE(48, 44); + targetInfo.copy(t2, 48); + + const challenge = decodeType2Message(t2.toString('base64')); + expect(hex(challenge.serverChallenge)).toBe('1122334455667788'); + expect(challenge.targetInfo.equals(targetInfo)).toBe(true); + + const t3 = Buffer.from(createType3Message({ + user: 'alice', password: 'secret', domain: 'CORP', workstation: 'PC1', + challenge, + clientChallenge: Buffer.from('aaaaaaaaaaaaaaaa', 'hex'), + timestamp: 0, + }), 'base64'); + + expect(t3.subarray(0, 8).toString('latin1')).toBe('NTLMSSP\0'); + expect(t3.readUInt32LE(8)).toBe(3); + // NtChallengeResponse field present and pointing inside the message + const ntLen = t3.readUInt16LE(20); + const ntOff = t3.readUInt32LE(24); + expect(ntLen).toBeGreaterThan(16); + expect(ntOff + ntLen).toBeLessThanOrEqual(t3.length); + // UserName decodes back as UTF-16LE + const userLen = t3.readUInt16LE(36); + const userOff = t3.readUInt32LE(40); + expect(t3.subarray(userOff, userOff + userLen).toString('utf16le')).toBe('alice'); + }); +});