/** * Tests for the gateway-native invite redemption engine * (verification/invite-redemption.ts): code + token resolution, lifecycle * validation (expiry lazy-marking, use counts, channel match), the * already-member no-consume gate, blocked-row refusal, revoked reactivation, * atomic double-redeem, and the ACL side effect on the gateway DB. * * The gateway DB is real (shared test preload); the assistant DB mirror is * mocked out — it is a best-effort info mirror and not under test here. */ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { sql } from "drizzle-orm"; // The engine's ACL side effect (upsertVerifiedContactChannel) dual-writes an // assistant-DB info mirror over IPC; stub it so tests never touch a socket. // The impls are mutable so tests can simulate a down/failing mirror. Spread // the actual module so untouched exports (assistantDbExec) stay importable // by later-loaded files when suites share a bun process. let assistantDbQueryImpl: () => Promise = async () => []; let assistantDbRunImpl: () => Promise = async () => {}; const actualAssistantDbProxy = await import("../db/assistant-db-proxy.js"); mock.module("../db/assistant-db-proxy.js", () => ({ ...actualAssistantDbProxy, assistantDbQuery: () => assistantDbQueryImpl(), assistantDbRun: () => assistantDbRunImpl(), })); // Capture the best-effort invite_redeemed daemon event (fired by the engine // on every real redeem) instead of dialing the assistant socket. Spread the // actual module so the real IpcHandlerError/IpcTransportError classes (and // untouched exports) stay importable by later-loaded files. let ipcCallAssistantCalls: Array<{ method: string; body: unknown }> = []; const actualAssistantClient = await import("../ipc/assistant-client.js"); mock.module("../ipc/assistant-client.js", () => ({ ...actualAssistantClient, ipcCallAssistant: async (method: string, opts?: { body?: unknown }) => { ipcCallAssistantCalls.push({ method, body: opts?.body }); return {}; }, })); // The ACL helper resolves the existing mirror channel via the typed identity // lookup; mutable so tests can serve a mirror row or simulate a down daemon. type IdentityChannel = { id: string; contactId: string; type: string; address: string; externalChatId: string | null; displayName: string | null; }; let identityLookupImpl: () => Promise = async () => null; const actualContactsInfoClient = await import("../ipc/contacts-info-client.js"); mock.module("../ipc/contacts-info-client.js", () => ({ ...actualContactsInfoClient, lookupContactChannelIdentity: () => identityLookupImpl(), })); await import("./test-preload.js"); const { initGatewayDb, getGatewayDb, resetGatewayDb } = await import( "../db/connection.js" ); const { contacts, contactChannels, ingressInvites } = await import( "../db/schema.js" ); const { ContactStore } = await import("../db/contact-store.js"); const { redeemInviteByCode, redeemInviteByToken } = await import( "../verification/invite-redemption.js" ); const { inviteRow, seedInvite } = await import( "./helpers/contact-fixtures.js" ); const CHANNEL = "telegram"; const CODE = "123456"; const TOKEN = "tok_raw_abc123"; beforeAll(async () => { await initGatewayDb(); }); beforeEach(() => { const db = getGatewayDb(); db.delete(ingressInvites).run(); db.delete(contactChannels).run(); db.delete(contacts).run(); assistantDbQueryImpl = async () => []; assistantDbRunImpl = async () => {}; identityLookupImpl = async () => null; ipcCallAssistantCalls = []; }); function inviteRedeemedEvents() { return ipcCallAssistantCalls.filter((c) => c.method === "invite_redeemed"); } afterAll(() => { resetGatewayDb(); }); function seedContact(id: string, displayName = `name-${id}`): void { const now = Date.now(); getGatewayDb() .insert(contacts) .values({ id, displayName, role: "contact", createdAt: now, updatedAt: now }) .run(); } function seedChannel(args: { id: string; contactId: string; address: string; status: string; }): void { const now = Date.now(); getGatewayDb() .insert(contactChannels) .values({ id: args.id, contactId: args.contactId, type: CHANNEL, address: args.address, externalChatId: "chat-1", status: args.status, policy: "allow", interactionCount: 0, createdAt: now, }) .run(); } function gwChannel(address: string) { return getGatewayDb() .select() .from(contactChannels) .all() .find( (ch) => ch.type === CHANNEL && ch.address.toLowerCase() === address.toLowerCase(), ); } const IDENTITY = { sourceChannel: CHANNEL, externalUserId: "U_SENDER", externalChatId: "chat-sender", displayName: "Sender Name", username: "sender", }; describe("redeemInviteByCode", () => { test("valid code: claims atomically, activates the gateway channel, returns the outcome", async () => { seedContact("c1"); const inviteId = seedInvite(); const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("redeemed"); if (result.status !== "redeemed") throw new Error("unreachable"); expect(result.replyText).toBe("Welcome! You've been granted access."); expect(result.outcome).toMatchObject({ inviteId, contactId: "c1", sourceChannel: CHANNEL, memberExternalUserId: "U_SENDER", memberExternalChatId: "chat-sender", result: "redeemed", }); const invite = inviteRow(inviteId); expect(invite.useCount).toBe(1); expect(invite.status).toBe("redeemed"); expect(invite.redeemedByExternalUserId).toBe("U_SENDER"); const channel = gwChannel("U_SENDER"); expect(channel?.status).toBe("active"); expect(channel?.verifiedVia).toBe("invite"); expect(channel?.contactId).toBe("c1"); // The engine fires the daemon info-mirror event with the outcome verbatim. expect(inviteRedeemedEvents()).toHaveLength(1); expect(inviteRedeemedEvents()[0].body).toEqual(result.outcome); }); test("preserves the target contact's curated displayName in the outcome", async () => { seedContact("c1", "Curated Name"); seedInvite(); const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("redeemed"); if (result.status !== "redeemed") throw new Error("unreachable"); expect(result.outcome.displayName).toBe("Curated Name"); }); test("passes sourceConversationId through opaquely", async () => { seedContact("c1"); seedInvite({ sourceConversationId: "conv-xyz" }); const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("redeemed"); if (result.status !== "redeemed") throw new Error("unreachable"); expect(result.outcome.sourceConversationId).toBe("conv-xyz"); }); test("code matching no invite anywhere → no_match (fall through)", async () => { seedContact("c1"); seedInvite(); const result = await redeemInviteByCode({ code: "999999", ...IDENTITY }); expect(result.status).toBe("no_match"); }); test("code active on another channel → channel_mismatch, no use consumed", async () => { seedContact("c1"); const inviteId = seedInvite({ sourceChannel: "whatsapp" }); const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("failed"); if (result.status !== "failed") throw new Error("unreachable"); expect(result.reason).toBe("channel_mismatch"); expect(result.replyText).toBe("This invite is not valid for this channel."); expect(inviteRow(inviteId).useCount).toBe(0); }); test("expired invite is lazily marked expired, no use consumed", async () => { seedContact("c1"); const inviteId = seedInvite({ expiresAt: Date.now() - 1 }); const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("failed"); if (result.status !== "failed") throw new Error("unreachable"); expect(result.reason).toBe("expired"); expect(result.replyText).toBe("This invite is no longer valid."); const invite = inviteRow(inviteId); expect(invite.status).toBe("expired"); expect(invite.useCount).toBe(0); }); test("exhausted invite → max_uses_reached", async () => { seedContact("c1"); const inviteId = seedInvite(); // Exhaust the single use with a first redemption, then reset the sender's // gateway channel so the second attempt isn't gated as already_member. await redeemInviteByCode({ code: CODE, ...IDENTITY }); getGatewayDb().delete(contactChannels).run(); const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); // The channel-scoped lookup only returns active invites, and the // exhausted row is status "redeemed" — the code no longer resolves. expect(result.status).toBe("no_match"); expect(inviteRow(inviteId).useCount).toBe(1); }); test("already-active member: already_member, NO use consumed", async () => { seedContact("c1"); seedChannel({ id: "ch-1", contactId: "c1", address: "U_SENDER", status: "active", }); const inviteId = seedInvite(); const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("already_member"); if (result.status !== "already_member") throw new Error("unreachable"); expect(result.replyText).toBe("You already have access."); expect(result.outcome.result).toBe("already_member"); expect(inviteRow(inviteId).useCount).toBe(0); expect(inviteRow(inviteId).status).toBe("active"); // Nothing consumed → no daemon info-mirror event. expect(inviteRedeemedEvents()).toHaveLength(0); }); test("already_member for a chatId-only caller: NO use consumed", async () => { seedContact("c1"); seedChannel({ id: "ch-1", contactId: "c1", address: "U_SENDER", status: "active", }); const inviteId = seedInvite(); // The seeded channel's externalChatId is "chat-1"; the sender carries only // the delivery chat id (no actor external id). const result = await redeemInviteByCode({ code: CODE, sourceChannel: CHANNEL, externalChatId: "chat-1", }); expect(result.status).toBe("already_member"); if (result.status !== "already_member") throw new Error("unreachable"); expect(result.outcome.memberExternalUserId).toBe("U_SENDER"); expect(inviteRow(inviteId).useCount).toBe(0); expect(inviteRow(inviteId).status).toBe("active"); }); test("chatId-only lookup with two rows sharing the chatId: the active invite-contact row wins over a stale foreign row → already_member, NO use consumed", async () => { // (type, externalChatId) is non-unique. A stale revoked row under another // contact shares the chat id with the sender's active membership row; the // membership precheck must resolve the invite-contact active row, not // whichever row the DB returns first. seedContact("c1"); seedContact("c2"); seedChannel({ id: "ch-stale", contactId: "c2", address: "U_STALE", status: "revoked", }); seedChannel({ id: "ch-member", contactId: "c1", address: "U_SENDER", status: "active", }); const inviteId = seedInvite(); // targets c1 // Both seeded rows carry externalChatId "chat-1"; the sender is // chatId-only (no actor external id). const result = await redeemInviteByCode({ code: CODE, sourceChannel: CHANNEL, externalChatId: "chat-1", }); expect(result.status).toBe("already_member"); if (result.status !== "already_member") throw new Error("unreachable"); expect(result.outcome.contactId).toBe("c1"); expect(result.outcome.memberExternalUserId).toBe("U_SENDER"); expect(inviteRow(inviteId).useCount).toBe(0); expect(inviteRow(inviteId).status).toBe("active"); }); test("active channel under a DIFFERENT contact is not already_member: redeems bound to the invite's target contact", async () => { // The invite binds the sender to ITS target contact; an existing active // membership under another contact (e.g. the guardian) must not short- // circuit to already_member or block the redemption. seedContact("c1"); seedContact("c2"); seedChannel({ id: "ch-1", contactId: "c2", address: "U_SENDER", status: "active", }); const inviteId = seedInvite(); // targets c1 const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("redeemed"); if (result.status !== "redeemed") throw new Error("unreachable"); expect(result.outcome.contactId).toBe("c1"); expect(inviteRow(inviteId).useCount).toBe(1); }); test("blocked gateway channel is NEVER reactivated: generic failure, no use consumed", async () => { seedContact("c1"); seedChannel({ id: "ch-1", contactId: "c1", address: "U_SENDER", status: "blocked", }); const inviteId = seedInvite(); const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("failed"); if (result.status !== "failed") throw new Error("unreachable"); expect(result.reason).toBe("invalid_token"); expect(result.replyText).toBe("This invite is no longer valid."); expect(inviteRow(inviteId).useCount).toBe(0); expect(gwChannel("U_SENDER")?.status).toBe("blocked"); }); test("revoked gateway channel IS reactivated by an invite (allowRevokedReactivation)", async () => { seedContact("c1"); seedChannel({ id: "ch-1", contactId: "c1", address: "U_SENDER", status: "revoked", }); const inviteId = seedInvite(); const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("redeemed"); expect(inviteRow(inviteId).useCount).toBe(1); expect(gwChannel("U_SENDER")?.status).toBe("active"); expect(gwChannel("U_SENDER")?.verifiedVia).toBe("invite"); }); test("atomic double-redeem: two concurrent redemptions → exactly one success", async () => { seedContact("c1"); const inviteId = seedInvite({ maxUses: 1 }); const results = await Promise.all([ redeemInviteByCode({ code: CODE, ...IDENTITY }), redeemInviteByCode({ code: CODE, sourceChannel: CHANNEL, externalUserId: "U_OTHER", externalChatId: "chat-other", }), ]); // Whichever interleaving occurs, exactly one caller redeems; the loser // either loses the atomic claim (failed) or no longer resolves the // consumed code (no_match). Never two redemptions. const redeemed = results.filter((r) => r.status === "redeemed"); expect(redeemed).toHaveLength(1); expect(inviteRow(inviteId).useCount).toBe(1); }); test("atomic claim: the second of two claims on the same resolved invite is gated out", () => { seedContact("c1"); const inviteId = seedInvite({ maxUses: 1 }); const store = new ContactStore(); // Both callers resolved the same active row (pre-claim state), then race // the status="active"-gated claim: only the first consumes it. const first = store.recordInviteRedemption({ inviteId, redeemedByExternalUserId: "U_SENDER", }); const second = store.recordInviteRedemption({ inviteId, redeemedByExternalUserId: "U_OTHER", }); expect(first.updated).toBe(true); expect(second.updated).toBe(false); expect(inviteRow(inviteId).useCount).toBe(1); expect(inviteRow(inviteId).redeemedByExternalUserId).toBe("U_SENDER"); }); test("no identity at all → missing_identity", async () => { seedContact("c1"); seedInvite(); const result = await redeemInviteByCode({ code: CODE, sourceChannel: CHANNEL, }); expect(result.status).toBe("failed"); if (result.status !== "failed") throw new Error("unreachable"); expect(result.reason).toBe("missing_identity"); }); }); describe("post-claim failure isolation", () => { test("assistant mirror IPC down (lookup + writes throw): still redeemed, gateway ACL active", async () => { seedContact("c1"); const inviteId = seedInvite(); identityLookupImpl = async () => { throw new Error("assistant IPC unavailable"); }; assistantDbRunImpl = async () => { throw new Error("assistant IPC unavailable"); }; const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("redeemed"); if (result.status !== "redeemed") throw new Error("unreachable"); expect(result.replyText).toBe("Welcome! You've been granted access."); expect(inviteRow(inviteId).useCount).toBe(1); const channel = gwChannel("U_SENDER"); expect(channel?.status).toBe("active"); expect(channel?.verifiedVia).toBe("invite"); expect(channel?.contactId).toBe("c1"); }); test("assistant mirror write fails on an existing mirror row: still redeemed, gateway ACL active", async () => { seedContact("c1"); const inviteId = seedInvite(); identityLookupImpl = async () => ({ id: "mirror-ch-1", contactId: "c1", type: CHANNEL, address: "U_SENDER", externalChatId: null, displayName: null, }); assistantDbRunImpl = async () => { throw new Error("mirror UPDATE failed"); }; const result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); expect(result.status).toBe("redeemed"); expect(inviteRow(inviteId).useCount).toBe(1); expect(gwChannel("U_SENDER")?.status).toBe("active"); }); test("post-claim engine throw outside the ACL helper (getContact) still yields redeemed", async () => { seedContact("c1"); const inviteId = seedInvite(); const store = new ContactStore(); store.getContact = () => { throw new Error("gateway read failed"); }; const result = await redeemInviteByCode({ code: CODE, ...IDENTITY, store }); expect(result.status).toBe("redeemed"); expect(inviteRow(inviteId).useCount).toBe(1); expect(gwChannel("U_SENDER")?.status).toBe("active"); }); test("gateway-side ACL upsert throw fails closed: no access, no redeemed reply, no mirror event", async () => { seedContact("c1"); const inviteId = seedInvite(); // Force a genuine gateway-side failure inside the ACL upsert while // leaving reads (membership gate, claim) intact: abort channel writes. const db = getGatewayDb(); db.run( sql`CREATE TRIGGER fail_channel_inserts BEFORE INSERT ON contact_channels BEGIN SELECT RAISE(ABORT, 'gateway write failed'); END`, ); db.run( sql`CREATE TRIGGER fail_channel_updates BEFORE UPDATE ON contact_channels BEGIN SELECT RAISE(ABORT, 'gateway write failed'); END`, ); let result; try { result = await redeemInviteByCode({ code: CODE, ...IDENTITY }); } finally { db.run(sql`DROP TRIGGER fail_channel_inserts`); db.run(sql`DROP TRIGGER fail_channel_updates`); } // The use is consumed (claim committed), but no channel was activated — // telling the sender they're in would be wrong. expect(result.status).toBe("failed"); expect(inviteRow(inviteId).useCount).toBe(1); expect(gwChannel("U_SENDER")).toBeUndefined(); expect(inviteRedeemedEvents()).toHaveLength(0); }); }); describe("redeemInviteByToken", () => { test("valid token redeems and activates the gateway channel", async () => { seedContact("c1"); const inviteId = seedInvite(); const result = await redeemInviteByToken({ token: TOKEN, ...IDENTITY }); expect(result.status).toBe("redeemed"); expect(inviteRow(inviteId).useCount).toBe(1); expect(gwChannel("U_SENDER")?.status).toBe("active"); }); test("unknown token is a definitive invalid invite (no fall-through)", async () => { seedContact("c1"); seedInvite(); const result = await redeemInviteByToken({ token: "not-a-token", ...IDENTITY, }); expect(result.status).toBe("failed"); if (result.status !== "failed") throw new Error("unreachable"); expect(result.reason).toBe("invalid_token"); expect(result.replyText).toBe("This invite is no longer valid."); }); test("revoked invite → revoked reason with the generic invalid reply", async () => { seedContact("c1"); const inviteId = seedInvite(); new ContactStore().revokeInvite(inviteId); const result = await redeemInviteByToken({ token: TOKEN, ...IDENTITY }); expect(result.status).toBe("failed"); if (result.status !== "failed") throw new Error("unreachable"); expect(result.reason).toBe("revoked"); expect(result.replyText).toBe("This invite is no longer valid."); }); test("token minted for another channel → channel_mismatch, no use consumed", async () => { seedContact("c1"); const inviteId = seedInvite({ sourceChannel: "whatsapp" }); const result = await redeemInviteByToken({ token: TOKEN, ...IDENTITY }); expect(result.status).toBe("failed"); if (result.status !== "failed") throw new Error("unreachable"); expect(result.reason).toBe("channel_mismatch"); expect(inviteRow(inviteId).useCount).toBe(0); }); });