Skip to content

Commit 6731178

Browse files
committed
test(scanner): use fixtures
1 parent f3e9fcc commit 6731178

File tree

8 files changed

+673
-28
lines changed

8 files changed

+673
-28
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"start": "NODE_EXTRA_CA_CERTS=node_modules/node_extra_ca_certs_mozilla_bundle/ca_bundle/ca_intermediate_root_bundle.pem node src/api/index.js",
1313
"dev": "NODE_EXTRA_CA_CERTS=node_modules/node_extra_ca_certs_mozilla_bundle/ca_bundle/ca_intermediate_root_bundle.pem nodemon src/api/index.js",
1414
"test": "CONFIG_FILE=conf/config-test.json mocha",
15+
"capture-fixtures": "node scripts/capture-fixtures.js",
1516
"tsc": "tsc -p jsconfig.json",
1617
"updateHsts": "node src/retrieve-hsts.js",
1718
"updateTldList": "node src/retrieve-tld-list.js",

scripts/capture-fixtures.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { retrieve } from "../src/retriever/retriever.js";
4+
import { scan } from "../src/scanner/index.js";
5+
import { Site } from "../src/site.js";
6+
7+
/**
8+
* Serializes an axios response object to a plain JSON-serializable object
9+
* @param {import("../src/types.js").Response|null} response
10+
* @returns {object|null}
11+
*/
12+
function serializeResponse(response) {
13+
if (!response) {
14+
return null;
15+
}
16+
17+
return {
18+
headers: Object.fromEntries(response.headers || {}),
19+
status: response.status,
20+
statusText: response.statusText,
21+
verified: response.verified,
22+
data: response.data || "",
23+
httpEquiv: response.httpEquiv ? Object.fromEntries(response.httpEquiv) : {},
24+
};
25+
}
26+
27+
/**
28+
* Serializes redirect chain to plain objects
29+
* @param {Array<{url: URL, status: number}>} redirects
30+
* @returns {Array<{url: string, status: number}>}
31+
*/
32+
function serializeRedirects(redirects) {
33+
if (!redirects || !Array.isArray(redirects)) {
34+
return [];
35+
}
36+
return redirects.map((r) => ({
37+
url: r.url.href,
38+
status: r.status,
39+
}));
40+
}
41+
42+
/**
43+
* Captures HTTP responses for a domain and saves as fixture
44+
* @param {string} domain
45+
*/
46+
async function captureFixture(domain) {
47+
console.log(`\nCapturing fixture for ${domain}...`);
48+
49+
const site = Site.fromSiteString(domain);
50+
51+
// Retrieve full HTTP response data
52+
console.log(` Retrieving HTTP responses...`);
53+
const requests = await retrieve(site);
54+
55+
// Run scan to get results (for metadata/documentation)
56+
console.log(` Running security scan...`);
57+
const scanResult = await scan(site);
58+
59+
// Build fixture object
60+
const fixture = {
61+
capturedAt: new Date().toISOString(),
62+
site: {
63+
hostname: site.hostname,
64+
port: site.port || null,
65+
path: site.path || null,
66+
},
67+
responses: {
68+
auto: serializeResponse(requests.responses.auto),
69+
http: serializeResponse(requests.responses.http),
70+
https: serializeResponse(requests.responses.https),
71+
cors: serializeResponse(requests.responses.cors),
72+
httpRedirects: serializeRedirects(requests.responses.httpRedirects),
73+
httpsRedirects: serializeRedirects(requests.responses.httpsRedirects),
74+
},
75+
resources: {
76+
path: requests.resources.path || "",
77+
},
78+
session: {
79+
url: requests.session?.url.href,
80+
},
81+
metadata: {
82+
scanResult: {
83+
grade: scanResult.scan.grade,
84+
score: scanResult.scan.score,
85+
testsPassed: scanResult.scan.testsPassed,
86+
testsFailed: scanResult.scan.testsFailed,
87+
},
88+
},
89+
};
90+
91+
// Write to fixture file
92+
const fixtureName = domain.replace(/\./g, "-");
93+
const fixturePath = path.join("test", "fixtures", `${fixtureName}.json`);
94+
95+
console.log(` Writing to ${fixturePath}...`);
96+
fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8");
97+
98+
console.log(
99+
` ✓ Captured: Grade ${scanResult.scan.grade}, Score ${scanResult.scan.score}`
100+
);
101+
}
102+
103+
/**
104+
* Main capture script
105+
*/
106+
async function main() {
107+
console.log("=== Capturing HTTP Observatory Test Fixtures ===");
108+
109+
const domains = ["mozilla.org", "observatory.mozilla.org"];
110+
111+
for (const domain of domains) {
112+
try {
113+
await captureFixture(domain);
114+
} catch (error) {
115+
console.error(
116+
` ✗ Error capturing ${domain}:`,
117+
Error.isError(error) ? error.message : error
118+
);
119+
process.exit(1);
120+
}
121+
}
122+
123+
console.log("\n✓ All fixtures captured successfully!");
124+
console.log(
125+
"\nNext steps:\n 1. Review changes: git diff test/fixtures/\n 2. Update test assertions if needed\n 3. Run tests: npm test test/scanner.test.js"
126+
);
127+
}
128+
129+
main();

src/scanner/index.js

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,18 @@ import { ALL_TESTS } from "../constants.js";
1818
*/
1919

2020
/**
21-
* @param {Site} site
22-
* @param {import("../types.js").ScanOptions} [options]
23-
* @returns {Promise<ScanResult>}
21+
* Analyzes a Requests object and returns scan results
22+
* @param {import("../types.js").Requests} requests
23+
* @returns {ScanResult}
2424
*/
25-
export async function scan(site, options) {
26-
let r = await retrieve(site, options);
27-
if (!r.responses.auto) {
25+
export function analyzeScan(requests) {
26+
if (!requests.responses.auto) {
2827
// We cannot connect at all, abort the test.
2928
throw new Error("The site seems to be down.");
3029
}
3130

3231
// We allow 2xx, 3xx, 401 and 403 status codes
33-
const { status } = r.responses.auto;
32+
const { status } = requests.responses.auto;
3433
if (status < 200 || (status >= 400 && ![401, 403].includes(status))) {
3534
throw new Error(
3635
`Site did respond with an unexpected HTTP status code ${status}.`
@@ -40,18 +39,17 @@ export async function scan(site, options) {
4039
// Run all the tests on the result
4140
/** @type {Output[]} */
4241
const results = ALL_TESTS.map((test) => {
43-
return test(r);
42+
return test(requests);
4443
});
4544

4645
/** @type {StringMap} */
47-
const responseHeaders = Object.entries(r.responses.auto.headers).reduce(
48-
(acc, [key, value]) => {
49-
acc[key] = value;
50-
return acc;
51-
},
52-
/** @type {StringMap} */ ({})
53-
);
54-
const statusCode = r.responses.auto.status;
46+
const responseHeaders = Object.entries(
47+
requests.responses.auto.headers
48+
).reduce((acc, [key, value]) => {
49+
acc[key] = value;
50+
return acc;
51+
}, /** @type {StringMap} */ ({}));
52+
const statusCode = requests.responses.auto.status;
5553

5654
let testsPassed = 0;
5755
let scoreWithExtraCredit = 100;
@@ -98,3 +96,13 @@ export async function scan(site, options) {
9896
tests,
9997
};
10098
}
99+
100+
/**
101+
* @param {Site} site
102+
* @param {import("../types.js").ScanOptions} [options]
103+
* @returns {Promise<ScanResult>}
104+
*/
105+
export async function scan(site, options) {
106+
const r = await retrieve(site, options);
107+
return analyzeScan(r);
108+
}

test/fixtures/README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Test Fixtures
2+
3+
These fixtures contain captured HTTP responses for scanner integration tests. Using fixtures prevents test breakage when external websites change their security configuration.
4+
5+
## Purpose
6+
7+
The scanner tests validate that our security analysis correctly evaluates real-world websites. However, these websites can change their security headers at any time, causing tests to fail even though our code is working correctly. Fixtures solve this by capturing a snapshot of HTTP responses that we can test against consistently.
8+
9+
## Updating Fixtures
10+
11+
When you want to update test expectations to reflect current website configurations:
12+
13+
1. Run the capture script:
14+
15+
```bash
16+
npm run capture-fixtures
17+
```
18+
19+
(This runs `scripts/capture-fixtures.js`)
20+
21+
2. Review the changes:
22+
23+
```bash
24+
git diff test/fixtures/
25+
```
26+
27+
3. Update corresponding test assertions in [test/scanner.test.js](../scanner.test.js) if needed
28+
29+
4. Commit both fixture changes and test assertion changes together:
30+
31+
```bash
32+
git add test/fixtures/ test/scanner.test.js
33+
git commit -m "test: update scanner fixtures and expectations"
34+
```
35+
36+
## Files
37+
38+
- `mozilla-org.json` - Response data for mozilla.org scanner tests
39+
- `observatory-mozilla-org.json` - Response data for observatory.mozilla.org scanner tests
40+
41+
## Fixture Contents
42+
43+
Each fixture file contains:
44+
45+
- **Site information**: hostname, port, path
46+
- **HTTP responses**: Headers, status codes, body content for HTTP and HTTPS requests
47+
- **Redirect chains**: Complete redirect history for both protocols
48+
- **CORS preflight**: OPTIONS request response
49+
- **TLS verification**: Whether certificates were successfully verified
50+
- **Session URL**: Base URL used for the scanning session
51+
- **Metadata**: Timestamp and scan results at time of capture (for reference)
52+
53+
## When to Update
54+
55+
Update fixtures when:
56+
57+
- Intentionally updating test expectations to match current site behavior
58+
- Adding new test cases that require different response scenarios
59+
- Upgrading dependencies (like axios) that change response structure
60+
61+
## History
62+
63+
Fixtures are versioned in git, so you can see the history of website security configuration changes over time using `git log -- test/fixtures/`.

test/fixtures/mozilla-org.json

Lines changed: 188 additions & 0 deletions
Large diffs are not rendered by default.

test/fixtures/observatory-mozilla-org.json

Lines changed: 180 additions & 0 deletions
Large diffs are not rendered by default.

test/helpers.js

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import fs from "node:fs";
2+
import path from "node:path";
23

34
import { AxiosHeaders } from "axios";
45

56
import { Requests } from "../src/types.js";
67
import { Session } from "../src/retriever/session.js";
7-
import path from "node:path";
88
import { parseHttpEquivHeaders } from "../src/retriever/utils.js";
99
import { Site } from "../src/site.js";
10+
import { analyzeScan } from "../src/scanner/index.js";
1011

1112
/**
1213
*
@@ -111,3 +112,79 @@ export function emptyRequests(httpEquivFile = null) {
111112
}
112113
return req;
113114
}
115+
116+
/**
117+
* Reconstructs an axios response from serialized fixture data
118+
* @param {object} data - Serialized response data
119+
* @returns {import("../src/types.js").Response | null}
120+
*/
121+
function reconstructResponse(data) {
122+
if (!data) {
123+
return null;
124+
}
125+
126+
return {
127+
headers: new AxiosHeaders(data.headers),
128+
status: data.status,
129+
statusText: data.statusText,
130+
verified: data.verified,
131+
data: data.data || "",
132+
config: { headers: new AxiosHeaders() },
133+
request: { headers: new AxiosHeaders() },
134+
httpEquiv: data.httpEquiv
135+
? new Map(Object.entries(data.httpEquiv))
136+
: new Map(),
137+
};
138+
}
139+
140+
/**
141+
* Loads a fixture and creates a Requests object from it
142+
* @param {string} fixtureName - Name of fixture file (without .json extension)
143+
* @returns {Requests}
144+
*/
145+
export function fixtureRequests(fixtureName) {
146+
const fixturePath = path.join("test", "fixtures", `${fixtureName}.json`);
147+
const fixtureData = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
148+
149+
const site = Site.fromSiteString(fixtureData.site.hostname);
150+
if (fixtureData.site.port) {
151+
site.port = fixtureData.site.port;
152+
}
153+
if (fixtureData.site.path) {
154+
site.path = fixtureData.site.path;
155+
}
156+
157+
const req = new Requests(site);
158+
159+
// Reconstruct responses from fixture data
160+
req.responses.auto = reconstructResponse(fixtureData.responses.auto);
161+
req.responses.http = reconstructResponse(fixtureData.responses.http);
162+
req.responses.https = reconstructResponse(fixtureData.responses.https);
163+
req.responses.cors = reconstructResponse(fixtureData.responses.cors);
164+
165+
// Reconstruct redirect chains
166+
req.responses.httpRedirects = (fixtureData.responses.httpRedirects || []).map(
167+
(r) => ({ url: new URL(r.url), status: r.status })
168+
);
169+
req.responses.httpsRedirects = (
170+
fixtureData.responses.httpsRedirects || []
171+
).map((r) => ({ url: new URL(r.url), status: r.status }));
172+
173+
// Set resources
174+
req.resources.path = fixtureData.resources.path || "";
175+
176+
// Create session
177+
req.session = new Session(new URL(fixtureData.session.url));
178+
179+
return req;
180+
}
181+
182+
/**
183+
* Runs scan logic on a pre-loaded Requests object (for fixture-based testing)
184+
* This is a simple wrapper around analyzeScan() from the scanner module
185+
* @param {Requests} requests - Pre-loaded requests object (e.g., from fixtureRequests)
186+
* @returns {import("../src/types.js").ScanResult}
187+
*/
188+
export function scanWithRequests(requests) {
189+
return analyzeScan(requests);
190+
}

0 commit comments

Comments
 (0)