Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion apps/pwa/src/lib/deliver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ async function sendSlack(webhookUrl, a) {
return res.ok;
}

async function sendWebhook(webhookUrl, a) {
if (!webhookUrl) return false;
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
id: a.id,
kind: a.kind,
message: a.message,
script: a.script || null,
url: a.url,
created_at: a.created_at,
}),
});
return res.ok;
}

async function sendTelegram(chatId, a) {
if (!config.telegram.botToken || !chatId) { console.log(`[telegram:stub] ${a.message}`); return false; }
const res = await fetch(`https://api.telegram.org/bot${config.telegram.botToken}/sendMessage`, {
Expand Down Expand Up @@ -94,7 +111,8 @@ export async function fanOut(user, approval, onlyKinds = null) {
else if (c.kind === "slack") ok = await sendSlack(c.target || config.slack.defaultWebhook, approval);
else if (c.kind === "telegram") ok = await sendTelegram(c.target, approval);
else if (c.kind === "push") ok = await sendPush(user, approval);
else { console.log(`[${c.kind}:stub] ${approval.message}`); ok = true; } // sms/webhook
else if (c.kind === "webhook") ok = await sendWebhook(c.target, approval);
else { console.log(`[${c.kind}:stub] ${approval.message}`); ok = true; } // sms
if (ok) notified.push(c.kind);
} catch (e) {
console.error(`deliver ${c.kind} failed:`, e.message);
Expand Down
42 changes: 36 additions & 6 deletions apps/pwa/test/approvals-credits.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ async function boot() {
const s = app.listen(0, "127.0.0.1", () => resolve(s));
});
const { port } = server.address();
const webhookDeliveries = [];
const webhookServer = await new Promise((resolve) => {
const s = http.createServer((req, res) => {
let data = "";
req.on("data", (chunk) => { data += chunk; });
req.on("end", () => {
webhookDeliveries.push({
contentType: req.headers["content-type"],
body: JSON.parse(data),
});
res.writeHead(204).end();
});
});
s.listen(0, "127.0.0.1", () => resolve(s));
});
const webhookUrl = `http://127.0.0.1:${webhookServer.address().port}/hook`;

// A user with the given enabled channels and a starting balance. Returns the
// plaintext API key the CLI would send.
Expand Down Expand Up @@ -94,7 +110,10 @@ async function boot() {
req.end(JSON.stringify({ message }));
});

return { run, all, db, server, seedUser, charges, ingest, balance };
return {
run, all, db, server, webhookServer, webhookUrl, webhookDeliveries,
seedUser, charges, ingest, balance,
};
}

// One shared app/db for the whole file (db.mjs is a module-level singleton —
Expand All @@ -104,7 +123,11 @@ const app = () => (booted ||= boot());

test.after(() => {
if (!booted) return;
booted.then(({ server, db }) => { server.close(); db.close?.(); })
booted.then(({ server, webhookServer, db }) => {
server.close();
webhookServer.close();
db.close?.();
})
.finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } });
});

Expand Down Expand Up @@ -142,9 +165,10 @@ test("api/approvals: concurrent ingests cannot spend the same credits twice", {
});

test("api/approvals: a second sequential ingest falls back to free channels", { skip: !deps && "apps/pwa deps not installed" }, async () => {
const { seedUser, ingest, balance } = await app();
const { seedUser, ingest, balance, webhookUrl, webhookDeliveries } = await app();

const key = await seedUser("u-seq", { credits: 12, channels: [["sms", "+15550000"], ["webhook", "https://example.test/hook"]] });
webhookDeliveries.length = 0;
const key = await seedUser("u-seq", { credits: 12, channels: [["sms", "+15550000"], ["webhook", webhookUrl]] });
const first = await ingest(key, "first");
const second = await ingest(key, "second");

Expand All @@ -154,6 +178,9 @@ test("api/approvals: a second sequential ingest falls back to free channels", {
assert.equal(second.body.charged, 0);
assert.match(second.body.warning, /insufficient credits/);
assert.equal(await balance("u-seq"), 0);
assert.deepEqual(webhookDeliveries.map((delivery) => delivery.body.message), ["first", "second"]);
assert.ok(webhookDeliveries.every((delivery) => delivery.contentType === "application/json"));
assert.ok(webhookDeliveries.every((delivery) => delivery.body.url.includes("/approve/")));
});

test("api/approvals: a channel that fails to deliver is not charged for", { skip: !deps && "apps/pwa deps not installed" }, async () => {
Expand All @@ -171,14 +198,17 @@ test("api/approvals: a channel that fails to deliver is not charged for", { skip
});

test("api/approvals: a free-only account is never charged", { skip: !deps && "apps/pwa deps not installed" }, async () => {
const { seedUser, charges, ingest, balance } = await app();
const { seedUser, charges, ingest, balance, webhookUrl, webhookDeliveries } = await app();

const key = await seedUser("u-free", { credits: 0, channels: [["webhook", "https://example.test/hook"]] });
webhookDeliveries.length = 0;
const key = await seedUser("u-free", { credits: 0, channels: [["webhook", webhookUrl]] });
const res = await ingest(key, "free ping");

assert.deepEqual(res.body.delivered, ["webhook"]);
assert.equal(res.body.charged, 0);
assert.equal(res.body.warning, undefined);
assert.deepEqual(await charges("u-free"), []); // no zero-value ledger noise
assert.equal(await balance("u-free"), 0);
assert.equal(webhookDeliveries.length, 1);
assert.equal(webhookDeliveries[0].body.message, "free ping");
});
Loading