Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

/* Any copyright is dedicated to the Public Domain.
"use strict";
const { HttpServer, HTTP_404 } = ChromeUtils.importESModule(
);
const { GuardianClient } = ChromeUtils.importESModule(
"moz-src:///toolkit/components/ipprotection/GuardianClient.sys.mjs"
);
const { JsonSchemaValidator } = ChromeUtils.importESModule(
"resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs"
);
do_get_profile();
function makeGuardianServer(
arg = {
enroll: (_request, _response) => {},
token: (_request, _response) => {},
status: (_request, _response) => {},
}
) {
const callbacks = {
enroll: (_request, _response) => {},
token: (_request, _response) => {},
status: (_request, _response) => {},
...arg,
};
const server = new HttpServer();
server.registerPathHandler("/api/v1/fpn/token", callbacks.token);
server.registerPathHandler("/api/v1/fpn/status", callbacks.status);
server.registerPathHandler("/api/v1/fpn/auth", callbacks.enroll);
server.start(-1);
return {
server,
[Symbol.dispose]: () => {
server.stop(() => {});
},
};
}
function makeStallHandler() {
const stalledResponses = [];
return {
handler: (_request, response) => {
response.processAsync();
stalledResponses.push(response);
},
[Symbol.dispose]: () => {
stalledResponses.forEach(r => {
try {
r.finish();
} catch (e) {}
});
stalledResponses.length = 0;
},
};
}
const testGuardianConfig = serverWrapper => ({
getToken: async () => {
return {
token: "test-token",
[Symbol.dispose]: () => {},
};
},
guardianEndpoint: `http://localhost:${serverWrapper.server.identity.primaryPort}`,
fxaOrigin: `http://localhost:${serverWrapper.server.identity.primaryPort}`,
});
add_task(async function test_fetchUserInfo() {
const ok = data => {
return (request, r) => {
// Verify the Authorization header is present and correctly formatted
const authHeader = request.getHeader("Authorization");
Assert.ok(authHeader, "Authorization header should be present");
Assert.equal(
authHeader,
"Bearer test-token",
"Authorization header should have the correct format"
);
r.setStatusLine(request.httpVersion, 200, "OK");
r.write(JSON.stringify(data));
};
};
const fail = status => () => {
throw status;
};
const DEFAULT_OK_RESPONSE = {
subscribed: true,
uid: 42,
maxBytes: "1073741824",
};
const DEFAULT_EXPECTED_VALUES = {
subscribed: true,
uid: 42,
maxBytes: BigInt(1073741824),
};
const testcases = [
{
name: "It should parse a valid response",
sends: ok({
...DEFAULT_OK_RESPONSE,
}),
expects: {
status: 200,
error: null,
validEntitlement: true,
entitlement: {
...DEFAULT_EXPECTED_VALUES,
},
},
},
{
name: "It should handle a 404 response",
sends: fail(HTTP_404),
expects: {
status: 404,
error: "parse_error",
validEntitlement: false,
},
},
{
name: "It should handle an empty response",
sends: ok({}),
expects: {
status: 200,
error: "parse_error",
validEntitlement: false,
},
},
{
name: "It should handle a 200 response with incorrect types",
sends: ok({
subscribed: "true", // Incorrect type: should be boolean
uid: "42", // Incorrect type: should be number
maxBytes: 1234567890, // Incorrect type: should be string
}),
expects: {
status: 200,
error: "parse_error",
validEntitlement: false, // Should fail validation due to incorrect types
},
},
];
testcases
.map(({ name, sends, expects }) => {
return async () => {
using serverWrapper = makeGuardianServer({ status: sends });
const client = new GuardianClient(testGuardianConfig(serverWrapper));
const { status, entitlement, error } = await client.fetchUserInfo();
if (expects.status !== undefined) {
Assert.equal(status, expects.status, `${name}: status should match`);
}
// Check error message if it's expected
if (expects.error !== null) {
Assert.equal(
error,
expects.error,
`${name}: error should match expected`
);
} else {
Assert.equal(error, undefined, `${name}: error should be undefined`);
}
if (expects.validEntitlement) {
Assert.notEqual(
entitlement,
null,
`${name}: entitlement should not be null`
);
for (const key of Object.keys(expects.entitlement)) {
Assert.equal(
entitlement[key],
expects.entitlement[key],
`${name}: entitlement.${key} should match`
);
}
} else {
Assert.equal(
entitlement,
null,
`${name}: entitlement should be null`
);
}
};
})
.forEach(test => add_task(test));
});
add_task(async function test_fetchProxyPass() {
const ok = (data, headers = {}) => {
return (request, r) => {
r.setStatusLine(request.httpVersion, 200, "OK");
// Set default Cache-Control header (needed for ProxyPass)
if (!headers["Cache-Control"]) {
r.setHeader("Cache-Control", "max-age=3600", false);
}
// Set default quota headers
if (!("X-Quota-Limit" in headers)) {
r.setHeader("X-Quota-Limit", "5368709120", false);
}
if (!("X-Quota-Remaining" in headers)) {
r.setHeader("X-Quota-Remaining", "4294967296", false);
}
if (!("X-Quota-Reset" in headers)) {
r.setHeader("X-Quota-Reset", "2026-02-01T00:00:00.000Z", false);
}
// Set any custom headers (undefined values will skip setting)
for (const [name, value] of Object.entries(headers)) {
if (value !== undefined) {
r.setHeader(name, value, false);
}
}
r.write(JSON.stringify(data));
};
};
const fail = status => () => {
throw status;
};
const testcases = [
{
name: "It should parse a valid response with usage headers",
sends: ok({ token: createProxyPassToken() }),
expects: {
status: 200,
error: null,
validPass: true,
validUsage: true,
usage: {
max: BigInt("5368709120"),
remaining: BigInt("4294967296"),
},
},
},
{
name: "It should handle missing usage headers gracefully",
sends: ok(
{ token: createProxyPassToken() },
{
"X-Quota-Limit": undefined,
"X-Quota-Remaining": undefined,
"X-Quota-Reset": undefined,
}
),
expects: {
status: 200,
error: null,
validPass: true,
validUsage: false,
},
},
{
name: "It should handle a 404 response",
sends: fail(HTTP_404),
expects: {
status: 404,
error: "invalid_response",
validPass: false,
validUsage: false,
},
},
{
name: "It should handle an empty response",
sends: ok({}),
expects: {
status: 200,
error: "invalid_response",
validPass: false,
validUsage: true,
},
},
{
name: "It should handle an invalid token format",
sends: ok({ token: "header.body.signature" }),
expects: {
status: 200,
error: "invalid_response",
validPass: false,
validUsage: true,
},
},
];
testcases
.map(({ name, sends, expects }) => {
return async () => {
using serverWrapper = makeGuardianServer({ token: sends });
const client = new GuardianClient(testGuardianConfig(serverWrapper));
const { status, pass, error, usage } = await client.fetchProxyPass();
if (expects.status !== undefined) {
Assert.equal(status, expects.status, `${name}: status should match`);
}
// Check error message if it's expected
if (expects.error !== null) {
Assert.equal(
error,
expects.error,
`${name}: error should match expected`
);
} else {
Assert.equal(error, undefined, `${name}: error should be undefined`);
}
if (expects.validPass) {
Assert.notEqual(pass, null, `${name}: pass should not be null`);
Assert.strictEqual(
typeof pass.token,
"string",
`${name}: pass.token should be a string`
);
Assert.greater(
pass.until.epochMilliseconds,
Date.now(),
`${name}: pass.until should be in the future`
);
Assert.ok(pass.isValid(), `${name}: pass should be valid`);
} else {
Assert.equal(pass, null, `${name}: pass should be null`);
}
if (expects.validUsage) {
Assert.notEqual(usage, null, `${name}: usage should not be null`);
if (expects.usage) {
Assert.equal(
usage.max,
expects.usage.max,
`${name}: usage.max should match`
);
Assert.equal(
usage.remaining,
expects.usage.remaining,
`${name}: usage.remaining should match`
);
}
Assert.ok(
usage.reset && typeof usage.reset.epochMilliseconds === "number",
`${name}: usage.reset should be Temporal.Instant`
);
} else if (expects.validUsage === false) {
Assert.equal(usage, null, `${name}: usage should be null`);
}
};
})
.forEach(test => add_task(test));
});
add_task(async function test_ProxyUsage_fromResponse() {
const testcases = [
{
name: "Valid quota headers",
headers: {
"X-Quota-Limit": "5368709120",
"X-Quota-Remaining": "4294967296",
"X-Quota-Reset": "2026-02-01T00:00:00.000Z",
},
expects: {
validUsage: true,
max: BigInt("5368709120"),
remaining: BigInt("4294967296"),
},
},
{
name: "Zero remaining (quota exceeded)",
headers: {
"X-Quota-Limit": "5368709120",
"X-Quota-Remaining": "0",
"X-Quota-Reset": "2026-02-01T00:00:00.000Z",
},
expects: {
validUsage: true,
max: BigInt("5368709120"),
remaining: BigInt("0"),
},
},
{
name: "Missing X-Quota-Limit header",
headers: {
"X-Quota-Remaining": "1000",
"X-Quota-Reset": "2026-02-01T00:00:00.000Z",
},
expects: { validUsage: false },
},
{
name: "Missing X-Quota-Remaining header",
headers: {
"X-Quota-Limit": "5000",
"X-Quota-Reset": "2026-02-01T00:00:00.000Z",
},
expects: { validUsage: false },
},
{
name: "Missing X-Quota-Reset header",
headers: {
"X-Quota-Limit": "5000",
"X-Quota-Remaining": "1000",
},
expects: { validUsage: false },
},
{
name: "Invalid ISO timestamp",
headers: {
"X-Quota-Limit": "5000",
"X-Quota-Remaining": "1000",
"X-Quota-Reset": "not-a-date",
},
expects: { validUsage: false },
},
{
name: "Invalid BigInt value",
headers: {
"X-Quota-Limit": "not-a-number",
"X-Quota-Remaining": "1000",
"X-Quota-Reset": "2026-02-01T00:00:00.000Z",
},
expects: { validUsage: false },
},
];
testcases.forEach(({ name, headers, expects }) => {
info(`Running test case: ${name}`);
const mockHeaders = new Map(Object.entries(headers));
const mockResponse = {
headers: {
get(key) {
return mockHeaders.get(key) || null;
},
},
};
if (expects.validUsage) {
const usage = ProxyUsage.fromResponse(mockResponse);
Assert.notEqual(usage, null, `${name}: usage should not be null`);
Assert.equal(usage.max, expects.max, `${name}: max should match`);
Assert.equal(
usage.remaining,
expects.remaining,
`${name}: remaining should match`
);
Assert.ok(
usage.reset && typeof usage.reset.epochMilliseconds === "number",
`${name}: reset should be Temporal.Instant`
);
return;
}
Assert.throws(
() => ProxyUsage.fromResponse(mockResponse),
/Missing required header|invalid|must be non-negative|cannot exceed max|can't parse instant/i,
`${name}: should throw error for invalid data`
);
});
});
add_task(async function test_fetchProxyPass_quotaExceeded() {
const quota429 = (headers = {}) => {
return (request, r) => {
r.setStatusLine(request.httpVersion, 429, "Too Many Requests");
for (const [name, value] of Object.entries(headers)) {
if (value !== undefined) {
r.setHeader(name, value, false);
}
}
r.write(JSON.stringify({ error: "quota_exceeded" }));
};
};
const testcases = [
{
name: "429 with usage headers and Retry-After",
sends: quota429({
"X-Quota-Limit": "5368709120",
"X-Quota-Remaining": "0",
"X-Quota-Reset": "2026-02-01T00:00:00.000Z",
"Retry-After": "Sat, 01 Feb 2026 00:00:00 GMT",
}),
expects: {
status: 429,
error: "quota_exceeded",
validPass: false,
validUsage: true,
usage: {
max: BigInt("5368709120"),
remaining: BigInt("0"),
},
retryAfter: "Sat, 01 Feb 2026 00:00:00 GMT",
},
},
{
name: "429 without usage headers returns quota_exceeded with null usage",
sends: quota429({
"Retry-After": "3600",
}),
expects: {
status: 429,
error: "quota_exceeded",
validPass: false,
usage: null,
retryAfter: "3600",
},
},
{
name: "429 without Retry-After",
sends: quota429({
"X-Quota-Limit": "5368709120",
"X-Quota-Remaining": "0",
"X-Quota-Reset": "2026-02-01T00:00:00.000Z",
}),
expects: {
status: 429,
error: "quota_exceeded",
validPass: false,
validUsage: true,
usage: {
max: BigInt("5368709120"),
remaining: BigInt("0"),
},
retryAfter: null,
},
},
];
testcases
.map(({ name, sends, expects }) => {
return async () => {
using serverWrapper = makeGuardianServer({ token: sends });
const client = new GuardianClient(testGuardianConfig(serverWrapper));
const { status, pass, error, usage, retryAfter } =
await client.fetchProxyPass();
Assert.equal(status, expects.status, `${name}: status should match`);
Assert.equal(error, expects.error, `${name}: error should match`);
if (expects.validPass) {
Assert.notEqual(pass, null, `${name}: pass should not be null`);
} else {
Assert.equal(pass, undefined, `${name}: pass should be undefined`);
}
if (expects.validUsage) {
Assert.notEqual(usage, null, `${name}: usage should not be null`);
Assert.equal(
usage.max,
expects.usage.max,
`${name}: usage.max should match`
);
Assert.equal(
usage.remaining,
expects.usage.remaining,
`${name}: usage.remaining should match`
);
Assert.ok(
usage.reset && typeof usage.reset.epochMilliseconds === "number",
`${name}: usage.reset should be Temporal.Instant`
);
} else if (expects.usage === null) {
Assert.equal(usage, null, `${name}: usage should be null`);
}
if (expects.retryAfter !== undefined) {
Assert.equal(
retryAfter,
expects.retryAfter,
`${name}: retryAfter should match`
);
}
};
})
.forEach(test => add_task(test));
});
add_task(async function test_fetchProxyUsage() {
const ok = (headers = {}) => {
return (request, r) => {
r.setStatusLine(request.httpVersion, 200, "OK");
const defaults = {
"X-Quota-Limit": "5368709120",
"X-Quota-Remaining": "4294967296",
"X-Quota-Reset": "2026-02-01T00:00:00.000Z",
};
const merged = { ...defaults, ...headers };
for (const [name, value] of Object.entries(merged)) {
if (value !== undefined) {
r.setHeader(name, value, false);
}
}
};
};
const noHeaders = () => {
return (request, r) => {
r.setStatusLine(request.httpVersion, 200, "OK");
};
};
const testcases = [
{
name: "Valid usage headers",
sends: ok(),
expects: {
usage: {
max: BigInt("5368709120"),
remaining: BigInt("4294967296"),
},
},
},
{
name: "Missing usage headers returns null",
sends: noHeaders(),
expects: {
usage: null,
},
},
{
name: "Zero remaining quota",
sends: ok({ "X-Quota-Remaining": "0" }),
expects: {
usage: {
max: BigInt("5368709120"),
remaining: BigInt("0"),
},
},
},
];
testcases
.map(({ name, sends, expects }) => {
return async () => {
using serverWrapper = makeGuardianServer({ token: sends });
const client = new GuardianClient(testGuardianConfig(serverWrapper));
const usage = await client.fetchProxyUsage();
if (expects.usage === null) {
Assert.equal(usage, null, `${name}: usage should be null`);
} else {
Assert.notEqual(usage, null, `${name}: usage should not be null`);
Assert.equal(
usage.max,
expects.usage.max,
`${name}: usage.max should match`
);
Assert.equal(
usage.remaining,
expects.usage.remaining,
`${name}: usage.remaining should match`
);
Assert.ok(
usage.reset && typeof usage.reset.epochMilliseconds === "number",
`${name}: usage.reset should be Temporal.Instant`
);
}
};
})
.forEach(test => add_task(test));
});
add_task(async function test_parseGuardianSuccessURL() {
const testcases = [
{
name: "Valid success URL with code",
expects: { ok: true, error: undefined },
},
{
name: "Error in URL",
expects: { ok: false, error: "generic_error" },
},
{
name: "Missing code in success URL",
expects: { ok: false, error: "missing_code" },
},
{
name: "Null input",
input: null,
expects: { ok: false, error: "timeout" },
},
];
testcases.forEach(({ name, input, expects }) => {
info(`Running test case: ${name}`);
const result = GuardianClient._parseGuardianSuccessURL(input);
Assert.equal(result.ok, expects.ok, `${name}: ok should match`);
Assert.equal(result.error, expects.error, `${name}: error should match`);
});
});
add_task(async function test_proxyPassShouldRotate() {
const oneHour = Temporal.Duration.from({ hours: 1 });
const from = Temporal.Instant.from("2025-12-08T12:00:00Z"); // Static point in time
// The pass is valid for 1 hour from 'from'
const until = from.add(oneHour);
const rotationTime = ProxyPass.ROTATION_TIME;
const testcases = [
{
name: "Should not rotate when before rotation time",
currentTime: until.subtract(rotationTime).subtract({ seconds: 1 }),
expects: { shouldRotate: false },
},
{
name: "Should rotate when at rotation time",
currentTime: until.subtract(rotationTime),
expects: { shouldRotate: true },
},
{
name: "Should rotate when after rotation time",
currentTime: until.subtract(rotationTime).add({ seconds: 1 }),
expects: { shouldRotate: true },
},
{
name: "Should rotate when pass is expired",
currentTime: until.add({ seconds: 1 }),
expects: { shouldRotate: true },
},
];
testcases.forEach(({ name, currentTime, expects }) => {
info(`Running test case: ${name}`);
const proxyPass = new ProxyPass(createProxyPassToken(from, until));
const result = proxyPass.shouldRotate(currentTime);
Assert.equal(
result,
expects.shouldRotate,
`${name}: shouldRotate should match`
);
});
});
add_task(async function test_entitlement_toString_schema_validation() {
const entitlement = new Entitlement({
autostart: true,
created_at: "2024-01-15T10:30:00.000Z",
limited_bandwidth: false,
location_controls: true,
subscribed: true,
uid: 12345,
website_inclusion: false,
maxBytes: "1000000000",
});
const serialized = entitlement.toString();
Assert.ok(serialized, "toString() should return a non-empty string");
const parsed = JSON.parse(serialized);
Assert.ok(parsed, "toString() output should be valid JSON");
const result = JsonSchemaValidator.validate(parsed, Entitlement.schema);
Assert.ok(
result.valid,
`toString() output should match schema. Errors: ${JSON.stringify(
result.errors
)}`
);
const recreated = new Entitlement(parsed);
Assert.ok(recreated, "Should be able to create Entitlement from parsed data");
for (const key of Object.keys(entitlement)) {
const expected = entitlement[key];
const actual = recreated[key];
if (typeof expected === "bigint") {
Assert.equal(
actual.toString(),
expected.toString(),
`${key} matches after round-trip`
);
} else {
Assert.equal(actual, expected, `${key} matches after round-trip`);
}
}
});
add_task(async function test_ProxyUsage_serialization() {
const originalUsage = new ProxyUsage(
"1000000000",
"750000000",
"2026-02-01T00:00:00Z"
);
const serialized = JSON.stringify({
max: originalUsage.max.toString(),
remaining: originalUsage.remaining.toString(),
reset: originalUsage.reset.toString(),
});
Assert.greater(serialized.length, 0, "Serialization produces output");
const data = JSON.parse(serialized);
const deserializedUsage = new ProxyUsage(
data.max,
data.remaining,
data.reset
);
Assert.equal(
deserializedUsage.max.toString(),
originalUsage.max.toString(),
"max preserved through serialization"
);
Assert.equal(
deserializedUsage.remaining.toString(),
originalUsage.remaining.toString(),
"remaining preserved through serialization"
);
Assert.equal(
deserializedUsage.reset.toString(),
originalUsage.reset.toString(),
"reset preserved through serialization"
);
});
add_task(async function test_fetchProxyPass_abort() {
using tokenHandler = makeStallHandler();
using serverWrapper = makeGuardianServer({ token: tokenHandler.handler });
const client = new GuardianClient(testGuardianConfig(serverWrapper));
const controller = new AbortController();
const promise = client.fetchProxyPass(controller.signal);
do_timeout(10, () => controller.abort());
await Assert.rejects(
promise,
err => err.name === "AbortError",
"Should reject with abort error"
);
});
add_task(async function test_fetchUserInfo_abort() {
using statusHandler = makeStallHandler();
using serverWrapper = makeGuardianServer({ status: statusHandler.handler });
const client = new GuardianClient(testGuardianConfig(serverWrapper));
const controller = new AbortController();
const promise = client.fetchUserInfo(controller.signal);
do_timeout(10, () => controller.abort());
await Assert.rejects(
promise,
err => err.name === "AbortError",
"Should reject with abort error"
);
});
add_task(async function test_fetchProxyUsage_abort() {
using tokenHandler = makeStallHandler();
using serverWrapper = makeGuardianServer({ token: tokenHandler.handler });
const client = new GuardianClient(testGuardianConfig(serverWrapper));
const controller = new AbortController();
const promise = client.fetchProxyUsage(controller.signal);
do_timeout(10, () => controller.abort());
await Assert.rejects(
promise,
err => err.name === "AbortError",
"Should reject with abort error"
);
});
add_task(async function test_abort_before_fetch() {
using serverWrapper = makeGuardianServer({
token: () => {
Assert.ok(
false,
"Should not make network request with pre-aborted signal"
);
},
});
const client = new GuardianClient(testGuardianConfig(serverWrapper));
const controller = new AbortController();
controller.abort();
await Assert.rejects(
client.fetchProxyPass(controller.signal),
err => err.name === "AbortError",
"Should reject immediately with pre-aborted signal"
);
});
add_task(async function test_gConfig_getToken_abort() {
const sandbox = sinon.createSandbox();
try {
const { getFxAccountsSingleton } = ChromeUtils.importESModule(
"resource://gre/modules/FxAccounts.sys.mjs"
);
const fxAccounts = getFxAccountsSingleton();
sandbox.stub(fxAccounts, "getOAuthToken").returns(new Promise(() => {}));
const client = new GuardianClient();
const controller = new AbortController();
const promise = client.getToken(controller.signal);
do_timeout(10, () => controller.abort());
await Assert.rejects(
promise,
() => true,
"Should reject when abort signal fires during OAuth token fetch"
);
} finally {
sandbox.restore();
}
});