1import assert from "node:assert/strict";2import test from "node:test";34import {5 buildSecurityRequestEvent,6 recordEnumerationProbe,7} from "../src/observability.ts";89function eventFor(path, status, method = "GET") {10 const url = new URL(path, "https://n.zip");11 return buildSecurityRequestEvent(12 new Request(url, { method }),13 url,14 new Response(null, { status }),15 );16}1718function siteEventFor(path, status, method = "GET") {19 const url = new URL(path, "https://0123.n.zip");20 return buildSecurityRequestEvent(21 new Request(url, { method }),22 url,23 new Response(null, { status }),24 "0123",25 );26}2728test("four-hex address probes include derived vault and site locations", () => {29 assert.deepEqual(eventFor("/0123", 404), {30 event: "security.request",31 sample_rate: 0.01,32 path_class: "address",33 path: "/0123",34 method: "GET",35 status: 404,36 result: "not_found",37 address: "0123",38 vault_slot: 0,39 site_slot: 0x123,40 });41});4243test("scanner-shaped invalid paths are classified without an address", () => {44 assert.deepEqual(eventFor("/.env", 404), {45 event: "security.request",46 sample_rate: 0.01,47 path_class: "invalid",48 path: "/.env",49 method: "GET",50 status: 404,51 result: "not_found",52 });53});5455test("successful assets and service paths do not produce security events", () => {56 assert.equal(eventFor("/0123/app.js", 200), null);57 assert.equal(eventFor("/", 200), null);58 assert.equal(eventFor("/favicon.ico", 200), null);59});6061test("missing assets and unlock attempts retain their request class", () => {62 assert.equal(eventFor("/0123/missing.js", 404)?.path_class, "address_asset");63 assert.equal(eventFor("/0123/__unlock", 429, "POST")?.path_class, "unlock");64 assert.equal(eventFor("/0123/__unlock", 429, "POST")?.result, "rate_limited");65});6667test("site-origin probes retain the hostname address", () => {68 assert.equal(siteEventFor("/", 404)?.path_class, "address");69 assert.equal(siteEventFor("/__unlock", 429, "POST")?.path_class, "unlock");70 assert.equal(siteEventFor("/missing.js", 404)?.address, "0123");71 assert.equal(siteEventFor("/app.js", 200), null);72});7374class FakeRateLimit {75 calls = [];7677 constructor(allow) {78 this.allow = allow;79 }8081 async limit(options) {82 this.calls.push(options);83 return { success: this.allow(this.calls.length) };84 }85}8687class FakeStatement {88 constructor(db, sql) {89 this.db = db;90 this.sql = sql;91 this.params = [];92 }9394 bind(...params) {95 this.params = params;96 return this;97 }9899 async run() {100 this.db.writes.push({ sql: this.sql, params: this.params });101 return { success: true };102 }103}104105class FakeD1 {106 writes = [];107108 prepare(sql) {109 return new FakeStatement(this, sql);110 }111}112113function recordingEnv({ observe = () => true, signal = () => true } = {}) {114 const DB = new FakeD1();115 const RL_OBSERVE = new FakeRateLimit(observe);116 const RL_SIGNAL = new FakeRateLimit(signal);117 return {118 DB,119 RL_OBSERVE,120 RL_SIGNAL,121 NZIP_TOKEN: "test-scanner-digest-secret",122 };123}124125function record(path, status, env, ip = "192.0.2.10") {126 const url = new URL(path, "https://n.zip");127 return recordEnumerationProbe(128 new Request(url, { headers: { "cf-connecting-ip": ip } }),129 env,130 url,131 new Response(null, { status }),132 );133}134135test("ordinary probes use the observation persistence quota", async () => {136 const env = recordingEnv();137138 await record("/0123", 404, env);139140 assert.equal(env.RL_OBSERVE.calls.length, 1);141 assert.equal(env.RL_SIGNAL.calls.length, 0);142 assert.equal(env.DB.writes.length, 1);143 assert.match(env.DB.writes[0].sql, /security_probes/);144});145146test("denied observation quota performs no D1 writes", async () => {147 const env = recordingEnv({ observe: () => false });148149 await record("/0123", 404, env);150151 assert.equal(env.RL_OBSERVE.calls.length, 1);152 assert.equal(env.RL_SIGNAL.calls.length, 0);153 assert.equal(env.DB.writes.length, 0);154});155156test("429 confirmations use the signal quota instead of bypassing persistence limits", async () => {157 const env = recordingEnv();158159 await record("/0123", 429, env);160161 assert.equal(env.RL_OBSERVE.calls.length, 0);162 assert.equal(env.RL_SIGNAL.calls.length, 1);163 assert.equal(env.DB.writes.length, 2);164 assert.match(env.DB.writes[0].sql, /security_probes/);165 assert.match(env.DB.writes[1].sql, /security_signals/);166 assert.match(env.DB.writes[1].sql, /'rate_limited'/);167 assert.equal(env.RL_SIGNAL.calls[0].key.length, 16);168});169170test("denied signal quota performs no D1 writes for 429 responses", async () => {171 const env = recordingEnv({ signal: () => false });172173 await record("/0123", 429, env);174175 assert.equal(env.RL_OBSERVE.calls.length, 0);176 assert.equal(env.RL_SIGNAL.calls.length, 1);177 assert.equal(env.DB.writes.length, 0);178});179180test("a flood of 429 responses is bounded to one permitted persistence pair", async () => {181 const env = recordingEnv({ signal: (callNumber) => callNumber === 1 });182183 await Promise.all(184 Array.from({ length: 250 }, () => record("/0123", 429, env)),185 );186187 assert.equal(env.RL_OBSERVE.calls.length, 0);188 assert.equal(env.RL_SIGNAL.calls.length, 250);189 assert.equal(env.DB.writes.length, 2);190 assert.equal(191 env.DB.writes.filter(({ sql }) => sql.includes("security_probes")).length,192 1,193 );194 assert.equal(195 env.DB.writes.filter(({ sql }) => sql.includes("security_signals")).length,196 1,197 );198 assert.equal(new Set(env.RL_SIGNAL.calls.map(({ key }) => key)).size, 1);199});