const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const http = require("node:http"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const { DatabaseSync } = require("node:sqlite"); const push = require("../notify.js"); const cycle = require("../static/cycle.js"); const { b64url } = push; // Decrypts like the receiving browser does (RFC 8291), using its private key. function decrypt(body, receiver, auth) { const salt = body.subarray(0, 16); const keyLength = body.readUInt8(20); const senderKey = body.subarray(21, 21 + keyLength); const shared = receiver.computeSecret(senderKey); const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0"), receiver.getPublicKey(), senderKey]); const ikm = Buffer.from(crypto.hkdfSync("sha256", shared, auth, keyInfo, 32)); const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16)); const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12)); const record = body.subarray(21 + keyLength); const decipher = crypto.createDecipheriv("aes-128-gcm", cek, nonce); decipher.setAuthTag(record.subarray(record.length - 16)); const plain = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]); assert.equal(plain[plain.length - 1], 2, "last-record delimiter"); return plain.subarray(0, plain.length - 1).toString(); } function device() { const receiver = crypto.createECDH("prime256v1"); receiver.generateKeys(); const auth = crypto.randomBytes(16); return { receiver, auth, p256dh: b64url.encode(receiver.getPublicKey()), authText: b64url.encode(auth) }; } function verifyVapid(authorization, publicKey, audience) { const [, token, key] = authorization.match(/^vapid t=([^,]+), k=(.+)$/); assert.equal(key, publicKey); const [header, claims, signature] = token.split("."); const raw = b64url.decode(publicKey); const verifier = crypto.createPublicKey({ key: { kty: "EC", crv: "P-256", x: b64url.encode(raw.subarray(1, 33)), y: b64url.encode(raw.subarray(33)) }, format: "jwk" }); assert.ok(crypto.verify("sha256", Buffer.from(`${header}.${claims}`), { key: verifier, dsaEncoding: "ieee-p1363" }, b64url.decode(signature))); const parsed = JSON.parse(b64url.decode(claims)); assert.equal(parsed.aud, audience); assert.ok(parsed.exp > Date.now() / 1000 && parsed.exp <= Date.now() / 1000 + 24 * 3600, "exp within 24h"); return parsed; } function logPeriods(starts, length = 5) { const days = {}; for (const start of starts) { for (let offset = 0; offset < length; offset += 1) days[cycle.toIso(cycle.toDay(start) + offset)] = { flow: "medium", symptoms: [] }; } return days; } (async () => { // Encryption round trip, including a payload longer than one AES block. { const phone = device(); const message = JSON.stringify({ title: "Period expected soon", body: "x".repeat(300) }); const body = push.encrypt(message, phone.p256dh, phone.authText); assert.equal(body.readUInt32BE(16), 4096); assert.equal(decrypt(body, phone.receiver, phone.auth), message); } // Reminder rules: regular 28-day cycles with the last one starting 1 Oct, // so the next is expected 29 Oct. { const days = logPeriods(["2026-07-09", "2026-08-06", "2026-09-03", "2026-10-01"]); const at = today => push.dueReminder(cycle.summarize(days, today), 2); assert.equal(at("2026-10-26"), null); assert.equal(at("2026-10-27").kind, "soon"); assert.match(at("2026-10-27").body, /in about 2 days, around Thu 29 Oct/); assert.match(at("2026-10-28").body, /tomorrow/); assert.equal(at("2026-10-29"), null, "due today is neither soon nor late"); assert.equal(at("2026-10-30").kind, "late"); assert.match(at("2026-10-30").body, /expected on Thu 29 Oct/); assert.equal(at("2026-10-30").cycle, "2026-10-01"); assert.equal(push.dueReminder(cycle.summarize(days, "2026-10-27"), 1), null, "respects remind days"); assert.equal(push.dueReminder(cycle.summarize({}, "2026-10-27"), 2), null); assert.equal(push.dueReminder(cycle.summarize(days, "2026-10-03"), 7), null, "nothing during a period"); } // End to end: app.py creates the schema, `daily` sends one encrypted push // to a fake push service, and a second run the same day sends nothing. { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "flowtrack-push-")); const database = path.join(dir, "flowtrack.sqlite3"); execFileSync(process.env.PYTHON || "python3", ["-c", `import sys; sys.path.insert(0, ${JSON.stringify(path.join(__dirname, ".."))}); import app; app.Store(${JSON.stringify(database)}).initialize()`]); const received = []; const server = http.createServer((request, response) => { const chunks = []; request.on("data", chunk => chunks.push(chunk)); request.on("end", () => { received.push({ url: request.url, headers: request.headers, body: Buffer.concat(chunks) }); response.writeHead(request.url.endsWith("/gone") ? 410 : 201).end(); }); }); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); const origin = `http://127.0.0.1:${server.address().port}`; const { publicKey } = await push.main(["init", "--database", database]); assert.equal(b64url.decode(publicKey).length, 65); assert.equal((await push.main(["init", "--database", database])).publicKey, publicKey, "keys are stable"); const phone = device(); const db = new DatabaseSync(database); const subscribe = db.prepare("INSERT INTO push_subscriptions (endpoint, p256dh, auth, user, created_at) VALUES (?, ?, ?, 'kayla', 'now')"); subscribe.run(`${origin}/push/phone`, phone.p256dh, phone.authText); subscribe.run(`${origin}/push/gone`, phone.p256dh, phone.authText); const log = db.prepare("INSERT INTO days (day, flow, symptoms, updated_at) VALUES (?, 'medium', '[]', 'now')"); for (const day of Object.keys(logPeriods(["2026-08-06", "2026-09-03", "2026-10-01"]))) log.run(day); const args = ["daily", "--database", database, "--contact", "https://flowtrack.example.org", "--today", "2026-10-27"]; const first = await push.main(args); assert.deepEqual({ due: first.due, sent: first.sent, removed: first.removed }, { due: "soon", sent: 1, removed: 1 }); assert.equal(db.prepare("SELECT COUNT(*) AS n FROM push_subscriptions").get().n, 1, "410 drops the subscription"); const delivered = received.find(request => request.url === "/push/phone"); assert.equal(delivered.headers["content-encoding"], "aes128gcm"); assert.equal(delivered.headers.ttl, "86400"); const claims = verifyVapid(delivered.headers.authorization, publicKey, origin); assert.equal(claims.sub, "https://flowtrack.example.org"); const payload = JSON.parse(decrypt(delivered.body, phone.receiver, phone.auth)); assert.equal(payload.title, "Period expected soon"); const again = await push.main(args); assert.equal(again.alreadySent, true); assert.equal(received.length, 2, "no second send"); const test = await push.main(["test", "--database", database, "--contact", "https://flowtrack.example.org"]); assert.equal(test.sent, 1); assert.equal(JSON.parse(decrypt(received[2].body, phone.receiver, phone.auth)).tag, "test"); db.close(); server.close(); fs.rmSync(dir, { recursive: true }); } console.log("push tests passed"); })().catch(error => { console.error(error); process.exit(1); });