| 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).filter((name) => name.endsWith(".sql")).sort()) { |
| 32 | db.exec(readFileSync(join(dir, file), "utf8")); |
| 33 | } |
| 34 | return db; |
| 35 | } |
| 36 | |
| 37 | const LIMIT = 20; |
| 38 | const LATER = "2099-01-01T00:00:00.000Z"; |
| 39 | const EARLIER = "2000-01-01T00:00:00.000Z"; |
| 40 | let next = 0; |
| 41 | |
| 42 | /** Makes an invite to g1t for `inviter` the way `create_invite` does: within the allowance. */ |
| 43 | function invite(db, inviter, email, { limit = LIMIT, expiresAt = LATER } = {}) { |
| 44 | const id = `inv_${++next}`; |
| 45 | const row = db |
| 46 | .prepare(SQL.INSERT_WITHIN_ALLOWANCE) |
| 47 | .get(id, `hash_${id}`, "g1t-abcd", null, email, "account", null, inviter, null, "user", EARLIER, expiresAt, null, null, inviter, limit); |
| 48 | return row?.id ?? null; |
| 49 | } |
| 50 | |
| 51 | const used = (db, inviter) => db.prepare(SQL.USED).get(inviter).n; |
| 52 | |
| 53 | test("the statements are all there", () => { |
| 54 | for (const name of ["INSERT", "INSERT_WITHIN_ALLOWANCE", "USED", "GRANTED", "PENDING_FOR", "REVOKE", "SPEND_CODE", "INSERT_USER_WITH_CODE"]) { |
| 55 | assert.ok(SQL[name], `${name} not found`); |
| 56 | } |
| 57 | }); |
| 58 | |
| 59 | test("twenty invites each: the twenty-first is refused in the statement that writes", () => { |
| 60 | const db = migrated(); |
| 61 | for (let n = 1; n <= LIMIT; n++) assert.ok(invite(db, "usr_bo", `friend${n}@example.com`), `invite ${n}`); |
| 62 | assert.equal(used(db, "usr_bo"), LIMIT); |
| 63 | assert.equal(invite(db, "usr_bo", "one-too-many@example.com"), null); |
| 64 | // Somebody else's are their own. |
| 65 | assert.ok(invite(db, "usr_cy", "friend1@example.com")); |
| 66 | // Invitations into a workspace never count. |
| 67 | db.prepare(SQL.INSERT).run("inv_ws", "hash_ws", "g1t-wxyz", null, "x@example.com", "account", "wsp_1", "usr_bo", null, "none", EARLIER, LATER, null, "member"); |
| 68 | assert.equal(used(db, "usr_bo"), LIMIT); |
| 69 | }); |
| 70 | |
| 71 | test("revoking an unused invite gives it back; an expired one never counted", () => { |
| 72 | const db = migrated(); |
| 73 | const ids = []; |
| 74 | for (let n = 1; n <= LIMIT; n++) ids.push(invite(db, "usr_bo", `friend${n}@example.com`)); |
| 75 | const revoked = db.prepare(SQL.REVOKE).get("usr_bo", ids[0]); |
| 76 | assert.equal(revoked?.id, ids[0]); |
| 77 | assert.equal(used(db, "usr_bo"), LIMIT - 1); |
| 78 | assert.ok(invite(db, "usr_bo", "another@example.com")); |
| 79 | // Someone else cannot revoke it. |
| 80 | assert.equal(db.prepare(SQL.REVOKE).get("usr_eve", ids[1]), undefined); |
| 81 | // An expired, never used invite is not counted. |
| 82 | const db2 = migrated(); |
| 83 | invite(db2, "usr_bo", "late@example.com", { expiresAt: EARLIER }); |
| 84 | assert.equal(used(db2, "usr_bo"), 0); |
| 85 | }); |
| 86 | |
| 87 | test("a used invite is spent: revoking it does nothing and it still counts", () => { |
| 88 | const db = migrated(); |
| 89 | const id = invite(db, "usr_bo", "ada@example.com"); |
| 90 | db.prepare(SQL.SPEND_CODE).run("usr_ada", id); |
| 91 | db.prepare("UPDATE invites SET applied_at = ? WHERE id = ?").run(EARLIER, id); |
| 92 | assert.equal(db.prepare(SQL.REVOKE).get("usr_bo", id), undefined); |
| 93 | assert.equal(used(db, "usr_bo"), 1); |
| 94 | // Revoked while its account was still confirming: stopped, and still spent. |
| 95 | const waiting = invite(db, "usr_bo", "cy@example.com"); |
| 96 | db.prepare(SQL.SPEND_CODE).run("usr_cy", waiting); |
| 97 | assert.equal(db.prepare(SQL.REVOKE).get("usr_bo", waiting)?.id, waiting); |
| 98 | assert.equal(used(db, "usr_bo"), 2); |
| 99 | }); |
| 100 | |
| 101 | test("a pending invite for the same address is found; a revoked one is not", () => { |
| 102 | const db = migrated(); |
| 103 | const id = invite(db, "usr_bo", "ada@example.com"); |
| 104 | assert.equal(db.prepare(SQL.PENDING_FOR).get("usr_bo", "ada@example.com").n, 1); |
| 105 | assert.equal(db.prepare(SQL.PENDING_FOR).get("usr_cy", "ada@example.com").n, 0); |
| 106 | db.prepare(SQL.REVOKE).get("usr_bo", id); |
| 107 | assert.equal(db.prepare(SQL.PENDING_FOR).get("usr_bo", "ada@example.com").n, 0); |
| 108 | }); |
| 109 | |
| 110 | test("staff grants add to the twenty and can take some back", () => { |
| 111 | const db = migrated(); |
| 112 | const grant = db.prepare("INSERT INTO invite_grants (id, target_kind, target_id, amount, note, granted_by, created_at) VALUES (?, 'user', ?, ?, ?, ?, ?)"); |
| 113 | grant.run("igr_1", "usr_bo", 10, "launch partner", "staff@example.com", EARLIER); |
| 114 | grant.run("igr_2", "usr_bo", -3, null, "staff@example.com", EARLIER); |
| 115 | grant.run("igr_3", "usr_cy", 50, null, "staff@example.com", EARLIER); |
| 116 | assert.equal(db.prepare(SQL.GRANTED).get("usr_bo").n, 7); |
| 117 | assert.equal(db.prepare(SQL.GRANTED).get("usr_dee").n, 0); |
| 118 | }); |
| 119 | |
| 120 | test("signing up from the link spends the invite once and records who invited whom", () => { |
| 121 | const db = migrated(); |
| 122 | const id = invite(db, "usr_bo", "ada@example.com"); |
| 123 | db.prepare(SQL.SPEND_CODE).run("usr_ada", id); |
| 124 | db.prepare(SQL.INSERT_USER_WITH_CODE).run("usr_ada", "ada", "ada@example.com", "hash", 1, id); |
| 125 | const user = db.prepare("SELECT username, email_verified_at FROM users WHERE id = 'usr_ada'").get(); |
| 126 | assert.equal(user.username, "ada"); |
| 127 | // From the email's own link: the address starts confirmed. |
| 128 | assert.ok(user.email_verified_at); |
| 129 | const row = db.prepare("SELECT inviter_id, redeemed_by, redeemed_at, sealed_code FROM invites WHERE id = ?").get(id); |
| 130 | assert.equal(row.inviter_id, "usr_bo"); |
| 131 | assert.equal(row.redeemed_by, "usr_ada"); |
| 132 | assert.ok(row.redeemed_at); |
| 133 | // A second sign-up with the same code spends nothing and makes no account. |
| 134 | db.prepare(SQL.SPEND_CODE).run("usr_eve", id); |
| 135 | db.prepare(SQL.INSERT_USER_WITH_CODE).run("usr_eve", "eve", "eve@example.com", "hash", 0, id); |
| 136 | assert.equal(db.prepare("SELECT count(*) AS n FROM users WHERE id = 'usr_eve'").get().n, 0); |
| 137 | assert.equal(db.prepare("SELECT redeemed_by FROM invites WHERE id = ?").get(id).redeemed_by, "usr_ada"); |
| 138 | // Without the proof the address waits to be confirmed like any other. |
| 139 | const other = invite(db, "usr_bo", "dee@example.com"); |
| 140 | db.prepare(SQL.SPEND_CODE).run("usr_dee", other); |
| 141 | db.prepare(SQL.INSERT_USER_WITH_CODE).run("usr_dee", "dee", "dee@example.com", "hash", 0, other); |
| 142 | assert.equal(db.prepare("SELECT email_verified_at FROM users WHERE id = 'usr_dee'").get().email_verified_at, null); |
| 143 | }); |