test_GuardianClient.js (13470B)
1 /* Any copyright is dedicated to the Public Domain. 2 http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 const { HttpServer, HTTP_404 } = ChromeUtils.importESModule( 7 "resource://testing-common/httpd.sys.mjs" 8 ); 9 const { GuardianClient } = ChromeUtils.importESModule( 10 "moz-src:///browser/components/ipprotection/GuardianClient.sys.mjs" 11 ); 12 13 function makeGuardianServer( 14 arg = { 15 enroll: (_request, _response) => {}, 16 token: (_request, _response) => {}, 17 status: (_request, _response) => {}, 18 } 19 ) { 20 const callbacks = { 21 enroll: (_request, _response) => {}, 22 token: (_request, _response) => {}, 23 status: (_request, _response) => {}, 24 ...arg, 25 }; 26 const server = new HttpServer(); 27 28 server.registerPathHandler("/api/v1/fpn/token", callbacks.token); 29 server.registerPathHandler("/api/v1/fpn/status", callbacks.status); 30 server.registerPathHandler("/api/v1/fpn/auth", callbacks.enroll); 31 server.start(-1); 32 return server; 33 } 34 35 const testGuardianConfig = server => ({ 36 withToken: async cb => cb("test-token"), 37 guardianEndpoint: `http://localhost:${server.identity.primaryPort}`, 38 fxaOrigin: `http://localhost:${server.identity.primaryPort}`, 39 }); 40 41 add_task(async function test_fetchUserInfo() { 42 const ok = data => { 43 return (request, r) => { 44 // Verify the Authorization header is present and correctly formatted 45 const authHeader = request.getHeader("Authorization"); 46 Assert.ok(authHeader, "Authorization header should be present"); 47 Assert.equal( 48 authHeader, 49 "Bearer test-token", 50 "Authorization header should have the correct format" 51 ); 52 53 r.setStatusLine(request.httpVersion, 200, "OK"); 54 r.write(JSON.stringify(data)); 55 }; 56 }; 57 const fail = status => () => { 58 throw status; 59 }; 60 const testcases = [ 61 { 62 name: "It should parse a valid response", 63 sends: ok({ 64 subscribed: true, 65 uid: 42, 66 created_at: "2023-01-01T12:00:00.000Z", 67 limited_bandwidth: false, 68 location_controls: false, 69 autostart: false, 70 website_inclusion: false, 71 }), 72 expects: { 73 status: 200, 74 error: null, 75 validEntitlement: true, 76 entitlement: { 77 subscribed: true, 78 uid: 42, 79 created_at: "2023-01-01T12:00:00.000Z", 80 limited_bandwidth: false, 81 location_controls: false, 82 autostart: false, 83 website_inclusion: false, 84 }, 85 }, 86 }, 87 { 88 name: "Alpha experiment", 89 sends: ok({ 90 autostart: false, 91 created_at: "2023-09-24T12:00:00.000Z", 92 limited_bandwidth: false, 93 location_controls: false, 94 subscribed: true, 95 uid: 12345, 96 website_inclusion: false, 97 type: "alpha", 98 }), 99 expects: { 100 status: 200, 101 error: null, 102 validEntitlement: true, 103 entitlement: { 104 autostart: false, 105 limited_bandwidth: false, 106 location_controls: false, 107 subscribed: true, 108 uid: 12345, 109 website_inclusion: false, 110 created_at: "2023-09-24T12:00:00.000Z", 111 }, 112 }, 113 }, 114 { 115 name: "Beta experiment", 116 sends: ok({ 117 autostart: true, 118 created_at: "2023-09-24T12:30:00.000Z", 119 limited_bandwidth: false, 120 location_controls: false, 121 subscribed: false, 122 uid: 67890, 123 website_inclusion: true, 124 type: "beta", 125 }), 126 expects: { 127 status: 200, 128 error: null, 129 validEntitlement: true, 130 entitlement: { 131 autostart: true, 132 limited_bandwidth: false, 133 location_controls: false, 134 subscribed: false, 135 uid: 67890, 136 website_inclusion: true, 137 created_at: "2023-09-24T12:30:00.000Z", 138 }, 139 }, 140 }, 141 { 142 name: "gamma experiment", 143 sends: ok({ 144 autostart: true, 145 created_at: "2023-09-24T13:00:00.000Z", 146 limited_bandwidth: false, 147 location_controls: true, 148 subscribed: true, 149 uid: 54321, 150 website_inclusion: false, 151 type: "gamma", 152 }), 153 expects: { 154 status: 200, 155 error: null, 156 validEntitlement: true, 157 entitlement: { 158 autostart: true, 159 limited_bandwidth: false, 160 location_controls: true, 161 subscribed: true, 162 uid: 54321, 163 website_inclusion: false, 164 created_at: "2023-09-24T13:00:00.000Z", 165 }, 166 }, 167 }, 168 { 169 name: "Delta experiment", 170 sends: ok({ 171 autostart: true, 172 created_at: "2023-09-24T13:30:00.000Z", 173 limited_bandwidth: true, 174 location_controls: true, 175 subscribed: true, 176 uid: 13579, 177 website_inclusion: true, 178 type: "delta", 179 }), 180 expects: { 181 status: 200, 182 error: null, 183 validEntitlement: true, 184 entitlement: { 185 autostart: true, 186 limited_bandwidth: true, 187 location_controls: true, 188 subscribed: true, 189 uid: 13579, 190 website_inclusion: true, 191 created_at: "2023-09-24T13:30:00.000Z", 192 }, 193 }, 194 }, 195 { 196 name: "It should handle a 404 response", 197 sends: fail(HTTP_404), 198 expects: { 199 status: 404, 200 error: "parse_error", 201 validEntitlement: false, 202 }, 203 }, 204 { 205 name: "It should handle an empty response", 206 sends: ok({}), 207 expects: { 208 status: 200, 209 error: "parse_error", 210 validEntitlement: false, 211 }, 212 }, 213 { 214 name: "It should handle a 200 response with incorrect types", 215 sends: ok({ 216 subscribed: "true", // Incorrect type: should be boolean 217 uid: "42", // Incorrect type: should be number 218 created_at: 1234567890, // Incorrect type: should be string 219 limited_bandwidth: "false", // Incorrect type: should be boolean 220 location_controls: "true", // Incorrect type: should be boolean 221 autostart: "true", // Incorrect type: should be boolean 222 website_inclusion: "false", // Incorrect type: should be boolean 223 }), 224 expects: { 225 status: 200, 226 error: "parse_error", 227 validEntitlement: false, // Should fail validation due to incorrect types 228 }, 229 }, 230 ]; 231 testcases 232 .map(({ name, sends, expects }) => { 233 return async () => { 234 const server = makeGuardianServer({ status: sends }); 235 const client = new GuardianClient(testGuardianConfig(server)); 236 237 const { status, entitlement, error } = await client.fetchUserInfo(); 238 239 if (expects.status !== undefined) { 240 Assert.equal(status, expects.status, `${name}: status should match`); 241 } 242 243 // Check error message if it's expected 244 if (expects.error !== null) { 245 Assert.equal( 246 error, 247 expects.error, 248 `${name}: error should match expected` 249 ); 250 } else { 251 Assert.equal(error, undefined, `${name}: error should be undefined`); 252 } 253 254 if (expects.validEntitlement) { 255 Assert.notEqual( 256 entitlement, 257 null, 258 `${name}: entitlement should not be null` 259 ); 260 for (const key of Object.keys(expects.entitlement)) { 261 // Special case the date case, all others can check equality directly 262 if (key === "created_at") { 263 Assert.equal( 264 new Date(entitlement.created_at).toISOString(), 265 new Date( 266 Date.parse(expects.entitlement.created_at) 267 ).toISOString(), 268 `${name}: entitlement.created_at should match` 269 ); 270 } else { 271 Assert.equal( 272 entitlement[key], 273 expects.entitlement[key], 274 `${name}: entitlement.${key} should match` 275 ); 276 } 277 } 278 } else { 279 Assert.equal( 280 entitlement, 281 null, 282 `${name}: entitlement should be null` 283 ); 284 } 285 286 server.stop(); 287 }; 288 }) 289 .forEach(test => add_task(test)); 290 }); 291 292 add_task(async function test_fetchProxyPass() { 293 const ok = (data, headers = {}) => { 294 return (request, r) => { 295 r.setStatusLine(request.httpVersion, 200, "OK"); 296 // Set default Cache-Control header (needed for ProxyPass) 297 if (!headers["Cache-Control"]) { 298 r.setHeader("Cache-Control", "max-age=3600", false); 299 } 300 // Set any custom headers 301 for (const [name, value] of Object.entries(headers)) { 302 r.setHeader(name, value, false); 303 } 304 r.write(JSON.stringify(data)); 305 }; 306 }; 307 const fail = status => () => { 308 throw status; 309 }; 310 const testcases = [ 311 { 312 name: "It should parse a valid response", 313 sends: ok({ token: createProxyPassToken() }), 314 expects: { 315 status: 200, 316 error: null, 317 validPass: true, 318 }, 319 }, 320 { 321 name: "It should handle a 404 response", 322 sends: fail(HTTP_404), 323 expects: { 324 status: 404, 325 error: "invalid_response", 326 validPass: false, 327 }, 328 }, 329 { 330 name: "It should handle an empty response", 331 sends: ok({}), 332 expects: { 333 status: 200, 334 error: "invalid_response", 335 validPass: false, 336 }, 337 }, 338 { 339 name: "It should handle an invalid token format", 340 sends: ok({ token: "header.body.signature" }), 341 expects: { 342 status: 200, 343 error: "invalid_response", 344 validPass: false, 345 }, 346 }, 347 ]; 348 testcases 349 .map(({ name, sends, expects }) => { 350 return async () => { 351 const server = makeGuardianServer({ token: sends }); 352 const client = new GuardianClient(testGuardianConfig(server)); 353 354 const { status, pass, error } = await client.fetchProxyPass(); 355 356 if (expects.status !== undefined) { 357 Assert.equal(status, expects.status, `${name}: status should match`); 358 } 359 360 // Check error message if it's expected 361 if (expects.error !== null) { 362 Assert.equal( 363 error, 364 expects.error, 365 `${name}: error should match expected` 366 ); 367 } else { 368 Assert.equal(error, undefined, `${name}: error should be undefined`); 369 } 370 371 if (expects.validPass) { 372 Assert.notEqual(pass, null, `${name}: pass should not be null`); 373 Assert.strictEqual( 374 typeof pass.token, 375 "string", 376 `${name}: pass.token should be a string` 377 ); 378 Assert.greater( 379 pass.until.epochMilliseconds, 380 Date.now(), 381 `${name}: pass.until should be in the future` 382 ); 383 Assert.ok(pass.isValid(), `${name}: pass should be valid`); 384 } else { 385 Assert.equal(pass, null, `${name}: pass should be null`); 386 } 387 388 server.stop(); 389 }; 390 }) 391 .forEach(test => add_task(test)); 392 }); 393 394 add_task(async function test_parseGuardianSuccessURL() { 395 const testcases = [ 396 { 397 name: "Valid success URL with code", 398 input: "https://example.com/oauth/success?code=abc123", 399 expects: { ok: true, error: undefined }, 400 }, 401 { 402 name: "Error in URL", 403 input: "https://example.com/oauth/success?error=generic_error", 404 expects: { ok: false, error: "generic_error" }, 405 }, 406 { 407 name: "Missing code in success URL", 408 input: "https://example.com/oauth/success", 409 expects: { ok: false, error: "missing_code" }, 410 }, 411 { 412 name: "Null input", 413 input: null, 414 expects: { ok: false, error: "timeout" }, 415 }, 416 ]; 417 418 testcases.forEach(({ name, input, expects }) => { 419 info(`Running test case: ${name}`); 420 421 const result = GuardianClient._parseGuardianSuccessURL(input); 422 423 Assert.equal(result.ok, expects.ok, `${name}: ok should match`); 424 Assert.equal(result.error, expects.error, `${name}: error should match`); 425 }); 426 }); 427 428 add_task(async function test_proxyPassShouldRotate() { 429 const oneHour = Temporal.Duration.from({ hours: 1 }); 430 const from = Temporal.Instant.from("2025-12-08T12:00:00Z"); // Static point in time 431 // The pass is valid for 1 hour from 'from' 432 const until = from.add(oneHour); 433 const rotationTime = ProxyPass.ROTATION_TIME; 434 435 const testcases = [ 436 { 437 name: "Should not rotate when before rotation time", 438 currentTime: until.subtract(rotationTime).subtract({ seconds: 1 }), 439 expects: { shouldRotate: false }, 440 }, 441 { 442 name: "Should rotate when at rotation time", 443 currentTime: until.subtract(rotationTime), 444 expects: { shouldRotate: true }, 445 }, 446 { 447 name: "Should rotate when after rotation time", 448 currentTime: until.subtract(rotationTime).add({ seconds: 1 }), 449 expects: { shouldRotate: true }, 450 }, 451 { 452 name: "Should rotate when pass is expired", 453 currentTime: until.add({ seconds: 1 }), 454 expects: { shouldRotate: true }, 455 }, 456 ]; 457 458 testcases.forEach(({ name, currentTime, expects }) => { 459 info(`Running test case: ${name}`); 460 const proxyPass = new ProxyPass(createProxyPassToken(from, until)); 461 const result = proxyPass.shouldRotate(currentTime); 462 Assert.equal( 463 result, 464 expects.shouldRotate, 465 `${name}: shouldRotate should match` 466 ); 467 }); 468 });