Files
notification-wakeup-service/test/db/smsSqlite.test.ts
T

632 lines
20 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { smsAlertAuthorizationDb } from "../../src/db/smsAlertAuthorizationSqlite.js";
import { alertAuthorizationDb } from "../../src/db/alertAuthorizationSqlite.js";
import { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
import { closeDatabase, getDatabase } from "../../src/db/sqlite.js";
const USER = "did:ethr:0xsmsuser";
const OTHER = "did:ethr:0xothersmsuser";
const PHONE = "+15555550123";
const HASH = "phone-hash-abc";
let dir: string;
let previousDataDir: string | undefined;
beforeEach(async () => {
previousDataDir = process.env.NOTIFY_DATA_DIR;
dir = await mkdtemp(path.join(tmpdir(), "sms-db-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
});
afterEach(async () => {
closeDatabase();
if (previousDataDir === undefined) {
delete process.env.NOTIFY_DATA_DIR;
} else {
process.env.NOTIFY_DATA_DIR = previousDataDir;
}
await rm(dir, { recursive: true, force: true });
});
describe("smsRegistrationsDb", () => {
it("round-trips a pending registration", async () => {
const expires = new Date(Date.now() + 600_000).toISOString();
await smsRegistrationsDb.upsertPendingCode({
userId: USER,
phoneE164: PHONE,
codeHash: "hash-1",
codeExpiresAt: expires,
sentAt: new Date().toISOString(),
});
const row = await smsRegistrationsDb.get(USER, PHONE);
assert.equal(row?.userId, USER);
assert.equal(row?.phoneE164, PHONE);
assert.equal(row?.verified, false);
assert.equal(row?.codeHash, "hash-1");
assert.equal(row?.codeExpiresAt, expires);
assert.equal(row?.codeAttempts, 0);
});
it("keeps one row per (user, phone) and resets attempts on a resend", async () => {
const now = new Date().toISOString();
await smsRegistrationsDb.upsertPendingCode({
userId: USER,
phoneE164: PHONE,
codeHash: "hash-1",
codeExpiresAt: now,
sentAt: now,
});
await smsRegistrationsDb.incrementCodeAttempts(USER, PHONE);
await smsRegistrationsDb.upsertPendingCode({
userId: USER,
phoneE164: PHONE,
codeHash: "hash-2",
codeExpiresAt: now,
sentAt: now,
});
const rows = await smsRegistrationsDb.listByUserId(USER);
assert.equal(rows.length, 1);
assert.equal(rows[0].codeHash, "hash-2");
assert.equal(rows[0].codeAttempts, 0);
});
it("rejects a duplicate (user, phone) insert at the unique index", () => {
const connection = getDatabase();
const insert = connection.prepare(
`
INSERT INTO sms_registrations (
id, user_id, phone_e164, verified, code_attempts, created_at, updated_at
) VALUES (?, ?, ?, 0, 0, ?, ?)
`
);
const now = new Date().toISOString();
insert.run("row-1", USER, PHONE, now, now);
assert.throws(
() => insert.run("row-2", USER, PHONE, now, now),
/UNIQUE/
);
});
it("scopes verification, deletion, and counts by DID", async () => {
const now = new Date().toISOString();
for (const user of [USER, OTHER]) {
await smsRegistrationsDb.upsertPendingCode({
userId: user,
phoneE164: PHONE,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
}
await smsRegistrationsDb.markVerified(USER, PHONE);
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, true);
assert.equal((await smsRegistrationsDb.get(OTHER, PHONE))?.verified, false);
assert.equal(await smsRegistrationsDb.countVerifiedForPhone(PHONE), 1);
assert.deepEqual(
await smsRegistrationsDb.listVerifiedDidsForPhone(PHONE),
[USER]
);
assert.equal(await smsRegistrationsDb.delete(USER, PHONE), true);
assert.equal(await smsRegistrationsDb.get(OTHER, PHONE) !== undefined, true);
assert.equal(await smsRegistrationsDb.delete(USER, PHONE), false);
});
it("clears the code and verified flag for every DID on an opt-out", async () => {
const now = new Date().toISOString();
for (const user of [USER, OTHER]) {
await smsRegistrationsDb.upsertPendingCode({
userId: user,
phoneE164: PHONE,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
await smsRegistrationsDb.markVerified(user, PHONE);
}
assert.equal(await smsRegistrationsDb.unverifyAllForPhone(PHONE), 2);
assert.equal(await smsRegistrationsDb.countVerifiedForPhone(PHONE), 0);
});
it("excludes the named DID from the verified count", async () => {
const now = new Date().toISOString();
for (const user of [USER, OTHER]) {
await smsRegistrationsDb.upsertPendingCode({
userId: user,
phoneE164: PHONE,
codeHash: "hash",
codeExpiresAt: now,
sentAt: now,
});
await smsRegistrationsDb.markVerified(user, PHONE);
}
assert.equal(
await smsRegistrationsDb.countVerifiedForPhone(PHONE, USER),
1
);
});
});
describe("smsPhoneLogDb", () => {
it("records an action and reads it back", async () => {
await smsPhoneLogDb.append({
userId: USER,
phoneE164: PHONE,
phoneHash: HASH,
action: "code-sent",
result: "ok",
providerMessageId: "SM123",
});
const rows = await smsPhoneLogDb.listByUserId(USER);
assert.equal(rows.length, 1);
assert.equal(rows[0].action, "code-sent");
assert.equal(rows[0].providerMessageId, "SM123");
assert.equal(rows[0].phoneE164, PHONE);
});
it("counts sends per phone across DIDs", async () => {
for (const user of [USER, OTHER]) {
await smsPhoneLogDb.append({
userId: user,
phoneE164: PHONE,
phoneHash: HASH,
action: "code-sent",
result: "ok",
});
}
const since = new Date(Date.now() - 3_600_000).toISOString();
assert.equal(
await smsPhoneLogDb.countByPhoneHashSince(HASH, ["code-sent"], since),
2
);
assert.equal(
await smsPhoneLogDb.countByUserSince(USER, ["code-sent"], since),
1
);
});
it("ignores rows older than the window", async () => {
await smsPhoneLogDb.append({
userId: USER,
phoneHash: HASH,
action: "code-sent",
result: "ok",
});
const since = new Date(Date.now() + 60_000).toISOString();
assert.equal(
await smsPhoneLogDb.countByPhoneHashSince(HASH, ["code-sent"], since),
0
);
});
it("scrubs the number for one DID and keeps the hash and history", async () => {
for (const user of [USER, OTHER]) {
await smsPhoneLogDb.append({
userId: user,
phoneE164: PHONE,
phoneHash: HASH,
action: "code-sent",
result: "ok",
});
}
assert.equal(await smsPhoneLogDb.scrubPhoneNumber(USER, PHONE), 1);
const mine = await smsPhoneLogDb.listByUserId(USER);
assert.equal(mine.length, 1);
assert.equal(mine[0].phoneE164, undefined);
assert.equal(mine[0].phoneHash, HASH);
assert.equal(mine[0].action, "code-sent");
const theirs = await smsPhoneLogDb.listByUserId(OTHER);
assert.equal(theirs[0].phoneE164, PHONE);
});
});
describe("smsAlertAuthorizationDb", () => {
function batchJwts(day: string) {
return [{ sequence: 1, day, jwt: "jwt-" + day, nbf: 1, exp: 2 }];
}
it("stores into the SMS tables without touching the FCM ones", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 1);
assert.equal(await alertAuthorizationDb.countUnused(USER), 0);
assert.deepEqual(await alertAuthorizationDb.listDistinctUserIds(), []);
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), [
USER,
]);
});
it("replaces unused SMS JWTs and leaves consumed ones", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: [
...batchJwts("2026-09-05"),
{ sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 },
],
});
const first = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(first);
assert.equal(
await smsAlertAuthorizationDb.consumeUnusedJwt({
id: first.id,
userId: USER,
}),
true
);
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-2",
jwts: batchJwts("2026-09-07"),
});
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 1);
const consumed = await smsAlertAuthorizationDb.getJwtById(first.id);
assert.equal(consumed?.status, "consumed");
assert.equal(
await smsAlertAuthorizationDb.getUnusedForDay(USER, "2026-09-06"),
undefined
);
});
it("round-trips a notifyTime and reports none when absent", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
notifyHourUtc: 18,
notifyMinuteUtc: 30,
jwts: batchJwts("2026-09-05"),
});
const _b = (await smsAlertAuthorizationDb.getLatestBatch(USER));
assert.equal(_b?.notifyHourUtc, 18);
assert.equal(_b?.notifyMinuteUtc, 30);
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-2",
jwts: batchJwts("2026-09-06"),
});
const _u = (await smsAlertAuthorizationDb.getLatestBatch(USER));
assert.equal(_u?.notifyHourUtc, undefined);
assert.equal(_u?.notifyMinuteUtc, undefined);
});
it("round-trips a recorded timezone and reports none when absent", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
notifyHourUtc: 0,
notifyMinuteUtc: 30,
timezone: "America/Denver",
jwts: batchJwts("2026-09-05"),
});
const withZone = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(withZone?.timezone, "America/Denver");
assert.equal(withZone?.notifyHourUtc, 0);
assert.equal(withZone?.notifyMinuteUtc, 30);
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-2",
jwts: batchJwts("2026-09-06"),
});
const without = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(without?.timezone, undefined);
assert.equal(without?.notifyHourUtc, undefined);
assert.equal(without?.notifyMinuteUtc, undefined);
});
it("lists only users with an unused JWT for the day asked about", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: OTHER,
batchId: "other-batch",
jwts: batchJwts("2026-09-06"),
});
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "12:00",
}),
[{ userId: USER, due: true }]
);
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-07",
hourMinute: "12:00",
}),
[]
);
});
it("drops a user from the list once that day's JWT is consumed", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
const jwt = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(jwt);
await smsAlertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER });
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "23:59",
}),
[]
);
});
it("flags a user not due until their stored hour, by text comparison", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
notifyHourUtc: 9,
notifyMinuteUtc: 30,
jwts: batchJwts("2026-09-05"),
});
const at = async (hourMinute: string) =>
(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute,
})
)[0]?.due;
assert.equal(await at("00:00"), false);
assert.equal(await at("09:29"), false);
assert.equal(await at("09:30"), true);
// Zero-padded HH:MM sorts chronologically, so 10:00 must beat 09:30.
assert.equal(await at("10:00"), true);
assert.equal(await at("23:59"), true);
});
it("reads the hour from the newest batch, not an older surviving one", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "old",
notifyHourUtc: 23,
notifyMinuteUtc: 0,
jwts: batchJwts("2026-09-05"),
});
// Consume it so the old batch row survives the next replace.
const jwt = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(jwt);
await smsAlertAuthorizationDb.consumeUnusedJwt({ id: jwt.id, userId: USER });
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "new",
notifyHourUtc: 6,
notifyMinuteUtc: 0,
jwts: batchJwts("2026-09-06"),
});
const pending = await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-06",
hourMinute: "07:00",
});
// 07:00 is past the new batch's 06:00 but short of the old batch's 23:00.
assert.deepEqual(pending, [{ userId: USER, due: true }]);
});
it("breaks a created_at tie by insertion order, not by random id", async () => {
const connection = getDatabase();
const sameInstant = "2026-09-05T00:00:00.000Z";
const insert = connection.prepare(
`INSERT INTO sms_alert_authorization_batches
(id, user_id, batch_id, notify_hour_min_utc, timezone, created_at)
VALUES (?, ?, ?, ?, NULL, ?)`
);
// Ids chosen so lexical order disagrees with insertion order both ways.
insert.run("zzz-first", USER, "older", "23:00", sameInstant);
insert.run("aaa-second", USER, "newer", "06:00", sameInstant);
connection
.prepare(
`INSERT INTO sms_alert_authorization_jwts
(id, batch_pk, user_id, batch_id, sequence, day, jwt, nbf, exp,
status, consumed_at, created_at)
VALUES ('j1', 'aaa-second', ?, 'newer', 1, '2026-09-05', 'j', 1, 2,
'unused', NULL, ?)`
)
.run(USER, sameInstant);
assert.equal(
(await smsAlertAuthorizationDb.getLatestBatch(USER))?.batchId,
"newer"
);
// 07:00 is past the newer batch's 06:00 but short of the older one's 23:00.
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "07:00",
}),
[{ userId: USER, due: true }]
);
});
it("treats a batch with no stored hour as always due", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "00:00",
}),
[{ userId: USER, due: true }]
);
});
it("stays due when the stored hour is unreadable, rather than never", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
// Only a hand-edited database reaches this state, but text ordering would
// rank "midnight" above every HH:MM and defer the user permanently.
getDatabase()
.prepare(
`UPDATE sms_alert_authorization_batches SET notify_hour_min_utc = ?
WHERE user_id = ?`
)
.run("midnight", USER);
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "00:00",
}),
[{ userId: USER, due: true }]
);
});
it("reads the SMS tables only, never the FCM ones", async () => {
await alertAuthorizationDb.replaceUnusedBatch({
userId: OTHER,
batchId: "fcm-batch",
jwts: batchJwts("2026-09-05"),
});
assert.deepEqual(
await smsAlertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "12:00",
}),
[]
);
assert.deepEqual(
await alertAuthorizationDb.listPendingForDay({
day: "2026-09-05",
hourMinute: "12:00",
}),
[{ userId: OTHER, due: true }]
);
});
it("deletes every batch and JWT for one user, consumed included", async () => {
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: [
...batchJwts("2026-09-05"),
{ sequence: 2, day: "2026-09-06", jwt: "jwt-b", nbf: 1, exp: 2 },
],
});
const first = await smsAlertAuthorizationDb.getUnusedForDay(
USER,
"2026-09-05"
);
assert.ok(first);
await smsAlertAuthorizationDb.consumeUnusedJwt({
id: first.id,
userId: USER,
});
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: OTHER,
batchId: "sms-batch-other",
jwts: batchJwts("2026-09-05"),
});
const removed = await smsAlertAuthorizationDb.deleteAllForUser(USER);
assert.equal(removed.deletedJwts, 2);
assert.equal(removed.deletedBatches, 1);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
assert.equal(await smsAlertAuthorizationDb.getJwtById(first.id), undefined);
assert.equal(
await smsAlertAuthorizationDb.getLatestBatch(USER),
undefined
);
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), [
OTHER,
]);
});
it("deletes nothing, and does not fail, for an unknown user", async () => {
assert.deepEqual(
await smsAlertAuthorizationDb.deleteAllForUser("did:ethr:0xnobody"),
{ deletedBatches: 0, deletedJwts: 0 }
);
});
it("leaves the FCM inventory alone when the SMS one is deleted", async () => {
await alertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "fcm-batch-1",
jwts: batchJwts("2026-09-05"),
});
await smsAlertAuthorizationDb.replaceUnusedBatch({
userId: USER,
batchId: "sms-batch-1",
jwts: batchJwts("2026-09-05"),
});
await smsAlertAuthorizationDb.deleteAllForUser(USER);
assert.equal(await alertAuthorizationDb.countUnused(USER), 1);
assert.deepEqual(await alertAuthorizationDb.listDistinctUserIds(), [USER]);
});
it("allows only one unused SMS JWT per (user, day)", () => {
const connection = getDatabase();
const now = new Date().toISOString();
connection
.prepare(
`
INSERT INTO sms_alert_authorization_jwts (
id, batch_pk, user_id, batch_id, sequence, day, jwt,
nbf, exp, status, consumed_at, created_at
) VALUES (?, 'pk', ?, 'b', ?, '2026-09-05', 'jwt', 1, 2, 'unused', NULL, ?)
`
)
.run("j1", USER, 1, now);
assert.throws(
() =>
connection
.prepare(
`
INSERT INTO sms_alert_authorization_jwts (
id, batch_pk, user_id, batch_id, sequence, day, jwt,
nbf, exp, status, consumed_at, created_at
) VALUES (?, 'pk', ?, 'b', ?, '2026-09-05', 'jwt', 1, 2, 'unused', NULL, ?)
`
)
.run("j2", USER, 2, now),
/UNIQUE/
);
});
});