| 1 | // node --test "scripts/deploy/*.test.mjs" (npm run test:deploy) |
| 2 | // |
| 3 | // Invite a friend's statements, run as identity runs them: read out of |
| 4 | // services/identity/src (the constants there are written out in full) and |
| 5 | // run on SQLite, which D1 is, against identity's migrations applied in |
| 6 | // order. Twenty invites each; a revoked one comes back, a used one is |
| 7 | // spent; signing up from the link spends it and records who invited whom. |
| 8 | import assert from "node:assert/strict"; |
| 9 | import { readFileSync, readdirSync } from "node:fs"; |
| 10 | import { join } from "node:path"; |
| 11 | import { DatabaseSync } from "node:sqlite"; |
| 12 | import { test } from "node:test"; |
| 13 | |
| 14 | import { ROOT } from "./stack.mjs"; |
| 15 | |
| 16 | const SRC = join(ROOT, "services", "identity", "src"); |
| 17 | |
| 18 | /** Every `const NAME: &str = r#"…"#;` in `file`, by name. */ |
| 19 | function statements(file) { |
| 20 | const text = readFileSync(join(SRC, file), "utf8"); |
| 21 | const found = {}; |
| 22 | for (const match of text.matchAll(/const (\w+): &str =\s*r#"([\s\S]*?)"#;/g)) found[match[1]] = match[2]; |
| 23 | return found; |
| 24 | } |
| 25 | |
| 26 | const SQL = { ...statements("invites.rs"), ...statements(join("invites", "friends.rs")) }; |
| 27 | |
| 28 | function migrated() { |
| 29 | const db = new DatabaseSync(":memory:"); |
| 30 | const dir = join(ROOT, "services", "identity", "migrations"); |
| 31 | for (const file of readdirSync(dir) |
| 32 | .filter((name) => name.endsWith(".sql")) |
| 33 | .sort()) { |
| 34 | db.exec(readFileSync(join(dir, file), "utf8")); |
| 35 | } |
| 36 | return db; |
| 37 | } |
| 38 | |
| 39 | const LIMIT = 20; |
| 40 | const LATER = "2099-01-01T00:00:00.000Z"; |
| 41 | const EARLIER = "2000-01-01T00:00:00.000Z"; |
| 42 | let next = 0; |
| 43 | |
| 44 | /** Makes an invite to g1t for `inviter` the way `create_invite` does: within the allowance. */ |
| 45 | function invite(db, inviter, email, { limit = LIMIT, expiresAt = LATER } = {}) { |
| 46 | const id = `inv_${++next}`; |
| 47 | const row = db |
| 48 | .prepare(SQL.INSERT_WITHIN_ALLOWANCE) |
| 49 | .get(id, `hash_${id}`, "g1t-abcd", null, email, "account", null, inviter, null, "user", EARLIER, expiresAt, null, null, inviter, limit); |
| 50 | return row?.id ?? null; |
| 51 | } |
| 52 | |
| 53 | const used = (db, inviter) => db.prepare(SQL.USED).get(inviter).n; |
| 54 | |
| 55 | test("the statements are all there", () => { |
| 56 | for (const name of ["INSERT", "INSERT_WITHIN_ALLOWANCE", "USED", "GRANTED", "PENDING_FOR", "REVOKE", "SPEND_CODE", "INSERT_USER_WITH_CODE"]) { |
| 57 | assert.ok(SQL[name], `${name} not found`); |
| 58 | } |
| 59 | }); |
| 60 | |
| 61 | test("twenty invites each: the twenty-first is refused in the statement that writes", () => { |
| 62 | const db = migrated(); |
| 63 | for (let n = 1; n <= LIMIT; n++) assert.ok(invite(db, "usr_bo", `friend${n}@example.com`), `invite ${n}`); |
| 64 | assert.equal(used(db, "usr_bo"), LIMIT); |
| 65 | assert.equal(invite(db, "usr_bo", "one-too-many@example.com"), null); |
| 66 | // Somebody else's are their own. |
| 67 | assert.ok(invite(db, "usr_cy", "friend1@example.com")); |
| 68 | // Invitations into a workspace never count. |
| 69 | db.prepare(SQL.INSERT).run( |
| 70 | "inv_ws", |
| 71 | "hash_ws", |
| 72 | "g1t-wxyz", |
| 73 | null, |
| 74 | "x@example.com", |
| 75 | "account", |
| 76 | "wsp_1", |
| 77 | "usr_bo", |
| 78 | null, |
| 79 | "none", |
| 80 | EARLIER, |
| 81 | LATER, |
| 82 | null, |
| 83 | "member", |
| 84 | ); |
| 85 | assert.equal(used(db, "usr_bo"), LIMIT); |
| 86 | }); |
| 87 | |
| 88 | test("revoking an unused invite gives it back; an expired one never counted", () => { |
| 89 | const db = migrated(); |
| 90 | const ids = []; |
| 91 | for (let n = 1; n <= LIMIT; n++) ids.push(invite(db, "usr_bo", `friend${n}@example.com`)); |
| 92 | const revoked = db.prepare(SQL.REVOKE).get("usr_bo", ids[0]); |
| 93 | assert.equal(revoked?.id, ids[0]); |
| 94 | assert.equal(used(db, "usr_bo"), LIMIT - 1); |
| 95 | assert.ok(invite(db, "usr_bo", "another@example.com")); |
| 96 | // Someone else cannot revoke it. |
| 97 | assert.equal(db.prepare(SQL.REVOKE).get("usr_eve", ids[1]), undefined); |
| 98 | // An expired, never used invite is not counted. |
| 99 | const db2 = migrated(); |
| 100 | invite(db2, "usr_bo", "late@example.com", { expiresAt: EARLIER }); |
| 101 | assert.equal(used(db2, "usr_bo"), 0); |
| 102 | }); |
| 103 | |
| 104 | test("a used invite is spent: revoking it does nothing and it still counts", () => { |
| 105 | const db = migrated(); |
| 106 | const id = invite(db, "usr_bo", "ada@example.com"); |
| 107 | db.prepare(SQL.SPEND_CODE).run("usr_ada", id); |
| 108 | db.prepare("UPDATE invites SET applied_at = ? WHERE id = ?").run(EARLIER, id); |
| 109 | assert.equal(db.prepare(SQL.REVOKE).get("usr_bo", id), undefined); |
| 110 | assert.equal(used(db, "usr_bo"), 1); |
| 111 | // Revoked while its account was still confirming: stopped, and still spent. |
| 112 | const waiting = invite(db, "usr_bo", "cy@example.com"); |
| 113 | db.prepare(SQL.SPEND_CODE).run("usr_cy", waiting); |
| 114 | assert.equal(db.prepare(SQL.REVOKE).get("usr_bo", waiting)?.id, waiting); |
| 115 | assert.equal(used(db, "usr_bo"), 2); |
| 116 | }); |
| 117 | |
| 118 | test("a pending invite for the same address is found; a revoked one is not", () => { |
| 119 | const db = migrated(); |
| 120 | const id = invite(db, "usr_bo", "ada@example.com"); |
| 121 | assert.equal(db.prepare(SQL.PENDING_FOR).get("usr_bo", "ada@example.com").n, 1); |
| 122 | assert.equal(db.prepare(SQL.PENDING_FOR).get("usr_cy", "ada@example.com").n, 0); |
| 123 | db.prepare(SQL.REVOKE).get("usr_bo", id); |
| 124 | assert.equal(db.prepare(SQL.PENDING_FOR).get("usr_bo", "ada@example.com").n, 0); |
| 125 | }); |
| 126 | |
| 127 | test("staff grants add to the twenty and can take some back", () => { |
| 128 | const db = migrated(); |
| 129 | const grant = db.prepare("INSERT INTO invite_grants (id, target_kind, target_id, amount, note, granted_by, created_at) VALUES (?, 'user', ?, ?, ?, ?, ?)"); |
| 130 | grant.run("igr_1", "usr_bo", 10, "launch partner", "staff@example.com", EARLIER); |
| 131 | grant.run("igr_2", "usr_bo", -3, null, "staff@example.com", EARLIER); |
| 132 | grant.run("igr_3", "usr_cy", 50, null, "staff@example.com", EARLIER); |
| 133 | assert.equal(db.prepare(SQL.GRANTED).get("usr_bo").n, 7); |
| 134 | assert.equal(db.prepare(SQL.GRANTED).get("usr_dee").n, 0); |
| 135 | }); |
| 136 | |
| 137 | test("signing up from the link spends the invite once and records who invited whom", () => { |
| 138 | const db = migrated(); |
| 139 | const id = invite(db, "usr_bo", "ada@example.com"); |
| 140 | db.prepare(SQL.SPEND_CODE).run("usr_ada", id); |
| 141 | db.prepare(SQL.INSERT_USER_WITH_CODE).run("usr_ada", "ada", "ada@example.com", "hash", 1, id); |
| 142 | const user = db.prepare("SELECT username, email_verified_at FROM users WHERE id = 'usr_ada'").get(); |
| 143 | assert.equal(user.username, "ada"); |
| 144 | // From the email's own link: the address starts confirmed. |
| 145 | assert.ok(user.email_verified_at); |
| 146 | const row = db.prepare("SELECT inviter_id, redeemed_by, redeemed_at, sealed_code FROM invites WHERE id = ?").get(id); |
| 147 | assert.equal(row.inviter_id, "usr_bo"); |
| 148 | assert.equal(row.redeemed_by, "usr_ada"); |
| 149 | assert.ok(row.redeemed_at); |
| 150 | // A second sign-up with the same code spends nothing and makes no account. |
| 151 | db.prepare(SQL.SPEND_CODE).run("usr_eve", id); |
| 152 | db.prepare(SQL.INSERT_USER_WITH_CODE).run("usr_eve", "eve", "eve@example.com", "hash", 0, id); |
| 153 | assert.equal(db.prepare("SELECT count(*) AS n FROM users WHERE id = 'usr_eve'").get().n, 0); |
| 154 | assert.equal(db.prepare("SELECT redeemed_by FROM invites WHERE id = ?").get(id).redeemed_by, "usr_ada"); |
| 155 | // Without the proof the address waits to be confirmed like any other. |
| 156 | const other = invite(db, "usr_bo", "dee@example.com"); |
| 157 | db.prepare(SQL.SPEND_CODE).run("usr_dee", other); |
| 158 | db.prepare(SQL.INSERT_USER_WITH_CODE).run("usr_dee", "dee", "dee@example.com", "hash", 0, other); |
| 159 | assert.equal(db.prepare("SELECT email_verified_at FROM users WHERE id = 'usr_dee'").get().email_verified_at, null); |
| 160 | }); |