Push notifications: period soon/late reminders, test button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 09:43:41 +01:00
parent ce66b857db
commit bb19bc0aeb
12 changed files with 872 additions and 21 deletions

View file

@ -1,5 +1,7 @@
import base64
import json
import sqlite3
import sys
import tempfile
import threading
import unittest
@ -138,6 +140,21 @@ class ServerTests(unittest.TestCase):
}})
self.assertEqual((status, result), (200, {"added": 2}))
def test_push_is_off_without_a_helper(self):
_, state = self.request("GET", "/api/state", user="kayla")
self.assertFalse(state["push"]["available"])
self.assertEqual(self.request("POST", "/api/push/test", user="kayla")[0], 503)
_, state = self.request("GET", "/api/state", user="fred")
self.assertIsNone(state["push"])
def test_remind_days_setting(self):
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"remindDays": 3})[1], {"shareDetails": False, "remindDays": 3})
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"remindDays": 9})[0], 400)
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"remindDays": True})[0], 400)
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"other": 1})[0], 400)
_, state = self.request("GET", "/api/state", user="kayla")
self.assertEqual(state["push"]["remindDays"], 3)
def test_static_files(self):
status, body = self.request("GET", "/")
self.assertEqual(status, 200)
@ -146,5 +163,62 @@ class ServerTests(unittest.TestCase):
self.assertEqual(self.request("GET", "/manifest.webmanifest")[0], 200)
FAKE_NOTIFY = """#!{python}
import json, sqlite3, sys
command, database = sys.argv[1], sys.argv[3]
if command == "init":
with sqlite3.connect(database) as db:
db.execute("INSERT OR IGNORE INTO settings VALUES ('vapid_public', 'PUBLIC')")
print(json.dumps({{"publicKey": "PUBLIC"}}))
else:
print(json.dumps({{"sent": 1, "failed": 0, "removed": 0, "errors": [], "contact": sys.argv[5]}}))
"""
APPLE_SUBSCRIPTION = {
"endpoint": "https://web.push.apple.com/QGuQyavXutnMH",
"keys": {"p256dh": base64.urlsafe_b64encode(b"\x04" + b"k" * 64).decode().rstrip("="), "auth": base64.urlsafe_b64encode(b"a" * 16).decode().rstrip("=")},
}
class PushServerTests(ServerTests):
def setUp(self):
super().setUp()
script = Path(self.temp.name) / "notify"
script.write_text(FAKE_NOTIFY.format(python=sys.executable))
script.chmod(0o755)
self.server.notify = str(script)
self.server.push_contact = "https://flowtrack.example.org"
self.server.run_notify("init")
# Every other server test also runs again with push switched on.
test_push_is_off_without_a_helper = None
def test_push_state(self):
_, state = self.request("GET", "/api/state", user="kayla")
self.assertEqual(state["push"], {"available": True, "publicKey": "PUBLIC", "remindDays": 2, "devices": 0})
def test_subscribe_test_and_unsubscribe(self):
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=APPLE_SUBSCRIPTION), (200, {"devices": 1}))
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=APPLE_SUBSCRIPTION), (200, {"devices": 1}))
status, result = self.request("POST", "/api/push/test", user="kayla")
self.assertEqual((status, result["sent"], result["contact"]), (200, 1, "https://flowtrack.example.org"))
self.assertEqual(self.request("POST", "/api/push/test", user="fred")[0], 403)
self.assertEqual(self.request("POST", "/api/push/unsubscribe", user="kayla", body={"endpoint": APPLE_SUBSCRIPTION["endpoint"]}), (200, {"devices": 0}))
def test_only_real_push_services(self):
for endpoint in (
"https://127.0.0.1/push",
"http://web.push.apple.com/x",
"https://evilpush.apple.com.example.org/x",
"https://router.nordhammer.it/api",
):
body = {**APPLE_SUBSCRIPTION, "endpoint": endpoint}
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=body)[0], 400, endpoint)
bad_keys = {**APPLE_SUBSCRIPTION, "keys": {"p256dh": "short", "auth": APPLE_SUBSCRIPTION["keys"]["auth"]}}
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=bad_keys)[0], 400)
self.assertEqual(self.request("POST", "/api/push/subscribe", user="fred", body=APPLE_SUBSCRIPTION)[0], 403)
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body={**APPLE_SUBSCRIPTION, "endpoint": "https://fcm.googleapis.com/fcm/send/abc"})[0], 200)
if __name__ == "__main__":
unittest.main()

152
tests/test_push.js Normal file
View file

@ -0,0 +1,152 @@
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);
});