Skip to content
363 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { readFileSync } from "node:fs";
3import { test } from "node:test";
4
5import { ACCOUNT_SETTINGS } from "./account-settings.ts";
6import { CONTACT } from "./legal.ts";
7import {
8 G1T_INVITES,
9 HAVE_AN_INVITE,
10 bringIntoChoices,
11 filterInvitations,
12 filterMembers,
13 invitationCounts,
14 invitationFilter,
15 invitationFilterOf,
16 invitationTone,
17 inviteDraft,
18 inviteKind,
19 memberFilters,
20 invitePageCopy,
21 invitesPage,
22 peoplePages,
23 workspaceInviteCopy,
24 INVITES_CONTACT,
25 cleanCode,
26 cleanProof,
27 invitePath,
28 inviteSignUpCopy,
29 inviteFor,
30 inviteLink,
31 inviteState,
32 landingFor,
33 looksAutomated,
34 moreInvitesMailto,
35 remainingLine,
36 sharedDomainsHint,
37 sharedInviteLine,
38 sharedInviteLink,
39 signUpCopy,
40 suggestUsername,
41 welcomeCookie,
42 clearWelcome,
43 welcomes,
44} from "./invites.ts";
45
46const CODE = "g1t-k7m2-q9xd-4hpw-abcd-0123-4567-89ef-ghjk";
47
48test("the Invitations page sorts every state under one filter, and counts them", () => {
49 const statuses = ["pending", "awaiting_confirmation", "awaiting_answer", "redeemed", "declined", "expired", "revoked"] as const;
50 const invites = statuses.map((status) => ({ status }));
51 assert.equal(invitationFilterOf("pending"), "pending");
52 assert.equal(invitationFilterOf("awaiting_confirmation"), "pending");
53 assert.equal(invitationFilterOf("awaiting_answer"), "pending");
54 assert.equal(invitationFilterOf("redeemed"), "accepted");
55 assert.equal(invitationFilterOf("declined"), "declined");
56 assert.equal(invitationFilterOf("expired"), "expired");
57 assert.equal(invitationFilterOf("revoked"), "revoked");
58 assert.deepEqual(invitationCounts(invites), { all: 7, pending: 3, accepted: 1, declined: 1, expired: 1, revoked: 1 });
59 assert.equal(filterInvitations(invites, "all").length, 7);
60 assert.deepEqual(filterInvitations(invites, "pending").map((invite) => invite.status), ["pending", "awaiting_confirmation", "awaiting_answer"]);
61 assert.deepEqual(filterInvitations(invites, "accepted").map((invite) => invite.status), ["redeemed"]);
62 // The query names a filter, or means All.
63 assert.equal(invitationFilter("pending"), "pending");
64 assert.equal(invitationFilter("failed"), "all");
65 assert.equal(invitationFilter(null), "all");
66 // Waiting is amber, joined green, a no red, and over is quiet.
67 assert.equal(invitationTone("awaiting_answer"), "warn");
68 assert.equal(invitationTone("redeemed"), "success");
69 assert.equal(invitationTone("revoked"), "danger");
70 assert.equal(invitationTone("expired"), "neutral");
71});
72
73test("the Members page filters by role, two-factor, team and words", () => {
74 const members = [
75 { username: "ada", name: "Ada Lovelace", role: "owner" as const, two_factor: true },
76 { username: "grace", name: "Grace Hopper", role: "member" as const, two_factor: false },
77 { username: "linus", name: null, role: "member" as const, two_factor: true },
78 ];
79 const teams = { ada: [{ slug: "core" }], grace: [{ slug: "web" }, { slug: "core" }] };
80 assert.deepEqual(memberFilters(""), { role: "all", twoFactor: "all", team: null, query: "" });
81 assert.deepEqual(memberFilters("?role=owner&2fa=off&team=Core&q=%20ada%20"), { role: "owner", twoFactor: "off", team: "core", query: "ada" });
82 assert.deepEqual(memberFilters("?role=admin&2fa=maybe"), { role: "all", twoFactor: "all", team: null, query: "" });
83 const names = (filters: string) => filterMembers(members, memberFilters(filters), teams).map((member) => member.username);
84 assert.deepEqual(names(""), ["ada", "grace", "linus"]);
85 assert.deepEqual(names("?role=member"), ["grace", "linus"]);
86 assert.deepEqual(names("?2fa=off"), ["grace"]);
87 assert.deepEqual(names("?team=core"), ["ada", "grace"]);
88 assert.deepEqual(names("?q=hopper"), ["grace"]);
89 assert.deepEqual(names("?q=@LIN"), ["linus"]);
90 assert.deepEqual(names("?role=owner&team=web"), []);
91});
92
93test("while invite-only, nobody is offered a plain sign-up", () => {
94 // Sign up everywhere; only the sign-up page says registration takes an invite.
95 assert.deepEqual(signUpCopy(), { primary: "Sign up", secondary: null });
96 assert.equal(HAVE_AN_INVITE, "/register#invite");
97});
98
99test("asking for more invites goes to support with the [g1t Invites] subject", () => {
100 assert.equal(INVITES_CONTACT, CONTACT.support);
101 assert.equal(moreInvitesMailto(), "mailto:hey@flagon.io?subject=%5Bg1t%20Invites%5D%20More%20invites");
102 assert.equal(
103 moreInvitesMailto("acme"),
104 "mailto:hey@flagon.io?subject=%5Bg1t%20Invites%5D%20More%20invites%20for%20acme",
105 );
106});
107
108test("an invite link is on g1t.sh unless told otherwise", () => {
109 assert.equal(inviteLink(CODE), `https://g1t.sh/invite/${CODE}`);
110 assert.equal(inviteLink(CODE, "http://localhost:8787/"), `http://localhost:8787/invite/${CODE}`);
111});
112
113const PROOF = "4f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c84f9c2a7e0b13d5c8";
114
115test("an invite email's proof is kept only when it looks like one, and goes along to the invite's page", () => {
116 assert.equal(cleanProof(PROOF), PROOF);
117 assert.equal(cleanProof(` ${PROOF.toUpperCase()} `), PROOF);
118 assert.equal(cleanProof("not-a-proof"), null);
119 assert.equal(cleanProof("abc"), null);
120 assert.equal(cleanProof("a".repeat(500)), null);
121 assert.equal(cleanProof(null), null);
122 assert.equal(invitePath(CODE, PROOF), `/invite/${CODE}?proof=${PROOF}`);
123 assert.equal(invitePath(CODE, null), `/invite/${CODE}`);
124 assert.equal(invitePath(CODE), `/invite/${CODE}`);
125});
126
127test("signing up from the invite email says the address is confirmed already; otherwise the code step applies", () => {
128 const base = { address: "ada@example.com", emailProven: false, workspace: { name: "Flagon, Inc." }, repository: null };
129 const proven = inviteSignUpCopy({ ...base, emailProven: true });
130 assert.equal(proven.intro, "You can join Flagon, Inc. as soon as you create it: accept the invitation then.");
131 assert.match(proven.confirmed ?? "", /^ada@example\.com is confirmed: you came here from the invite we emailed to it/);
132 assert.match(proven.hint, /confirmed already/);
133 assert.doesNotMatch(proven.hint, /code/);
134
135 // No proof (a code typed in, or a link passed on): nothing new is said.
136 const plain = inviteSignUpCopy(base);
137 assert.equal(plain.intro, "You can join Flagon, Inc. as soon as you confirm your email: accept the invitation then.");
138 assert.equal(plain.confirmed, null);
139 assert.equal(plain.hint, "Your invite was sent here. We email it a code to confirm it before you start.");
140
141 // An invite for anyone with the code has no address to prove.
142 const open = inviteSignUpCopy({ ...base, address: null, emailProven: true, workspace: null });
143 assert.equal(open.confirmed, null);
144 assert.equal(open.intro, "It takes a minute.");
145 assert.equal(open.hint, "We email it a code to confirm it before you start.");
146
147 const repo = inviteSignUpCopy({ ...base, workspace: null, repository: { name: "flagon-io/g1t" }, emailProven: true });
148 assert.equal(repo.intro, "You get flagon-io/g1t as soon as you create it.");
149});
150
151test("a pasted link or code is tidied to the code", () => {
152 assert.equal(cleanCode(CODE), CODE);
153 assert.equal(cleanCode(` ${CODE} `), CODE);
154 assert.equal(cleanCode(`https://g1t.sh/invite/${CODE}`), CODE);
155 assert.equal(cleanCode(`https://g1t.sh/register?invite=${CODE}&next=%2F`), CODE);
156 assert.equal(cleanCode("g1t-k7m2 q9xd"), "g1t-k7m2q9xd");
157 assert.equal(cleanCode(null), "");
158 assert.equal(cleanCode("x".repeat(500)).length, 80);
159});
160
161test("each invite says where it stands and whom it is for", () => {
162 const base = { redeemedBy: null, email: null, workspace: null };
163 assert.deepEqual(inviteState({ ...base, status: "pending" }), { label: "Pending", tone: "pending" });
164 assert.deepEqual(inviteState({ ...base, status: "redeemed", redeemedBy: "ada" }), { label: "Joined as @ada", tone: "done" });
165 assert.deepEqual(inviteState({ ...base, status: "awaiting_confirmation", redeemedBy: "ada" }), {
166 label: "@ada is confirming their email",
167 tone: "pending",
168 });
169 assert.deepEqual(inviteState({ ...base, status: "expired" }), { label: "Expired", tone: "dead" });
170 assert.deepEqual(inviteState({ ...base, status: "revoked" }), { label: "Revoked", tone: "dead" });
171 assert.equal(inviteFor({ ...base, status: "pending" }), "Anyone with the link");
172 assert.equal(inviteFor({ ...base, status: "pending", email: "ada@example.com", workspace: "acme" }), "ada@example.com");
173 assert.equal(inviteFor({ ...base, status: "pending", invitee: "daweazl", workspace: "flagon-io" }), "@daweazl");
174 // Which kind each is, beside whom it is for.
175 assert.deepEqual(inviteKind({ workspace: null }), { kind: "g1t", label: "Invite to g1t" });
176 assert.deepEqual(inviteKind({ workspace: "flagon-io" }), { kind: "workspace", label: "Invite to join flagon-io" });
177 assert.deepEqual(inviteState({ ...base, status: "awaiting_answer", redeemedBy: "daweazl" }), { label: "Waiting for @daweazl to accept", tone: "pending" });
178 assert.deepEqual(inviteState({ ...base, status: "declined", invitee: "daweazl" }), { label: "@daweazl declined", tone: "dead" });
179});
180
181test("what is left reads plainly", () => {
182 assert.equal(remainingLine({ limit: 5, used: 2, remaining: 3 }), "3 of 5 invites left");
183 assert.equal(remainingLine({ limit: 1, used: 0, remaining: 1 }), "1 of 1 invite left");
184 assert.equal(remainingLine({ limit: 5, used: 5, remaining: 0 }), "You have used all 5 of your invites");
185 assert.equal(remainingLine({ limit: null, used: 40, remaining: null }), "No limit on your invites");
186});
187
188test("bots that fill the hidden field or answer instantly are turned away", () => {
189 const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null });
190 const now = 1_000_000;
191 assert.equal(looksAutomated(form({ website: "http://spam.example" }), now), true);
192 assert.equal(looksAutomated(form({ started: String(now - 200) }), now), true);
193 assert.equal(looksAutomated(form({ started: String(now - 10_000) }), now), false);
194 assert.equal(looksAutomated(form({}), now), false);
195 assert.equal(looksAutomated(form({ website: " " }), now), false);
196});
197
198test("a username is suggested from the invited address", () => {
199 assert.equal(suggestUsername("ada.lovelace@example.com"), "ada-lovelace");
200 assert.equal(suggestUsername("Margaret_Hamilton+g1t@example.com"), "margaret-hamilton");
201 assert.equal(suggestUsername("--x--@example.com"), "x");
202 assert.equal(suggestUsername(`${"a".repeat(38)}.b@example.com`), "a".repeat(38));
203 assert.equal(suggestUsername("...@example.com"), "");
204 assert.equal(suggestUsername(null), "");
205});
206
207test("an invite lands in its workspace, else its repository", () => {
208 assert.equal(landingFor({ workspace: { slug: "Flagon-IO" }, repository: null }), "flagon-io");
209 assert.equal(landingFor({ workspace: null, repository: { name: "flagon-io/g1t" } }), "flagon-io/g1t");
210 assert.equal(landingFor({ workspace: null, repository: null }), null);
211});
212
213test("the welcome is for one place, and ends", () => {
214 const set = welcomeCookie("flagon-io/g1t", true);
215 assert.match(set, /^g1t_welcome=flagon-io%2Fg1t; Path=\/; Max-Age=300; HttpOnly; SameSite=Lax; Secure$/);
216 const header = `a=1; ${set.split(";")[0]}; b=2`;
217 assert.equal(welcomes(header, "flagon-io/g1t"), true);
218 assert.equal(welcomes(header, "flagon-io"), false);
219 assert.equal(welcomes("g1t_welcome=flagon-io", "Flagon-IO"), true);
220 assert.equal(welcomes("g1t_welcome=%E0%A4%A", "flagon-io"), false);
221 assert.equal(welcomes("g1t_welcome=..%2F..%2Fx", "../../x"), false);
222 assert.equal(welcomes(null, "flagon-io"), false);
223 assert.match(clearWelcome(false), /^g1t_welcome=; Path=\/; Max-Age=0; HttpOnly; SameSite=Lax$/);
224});
225
226test("a shared invite link names its group above the sign-up form", () => {
227 assert.equal(sharedInviteLine("Cloudflare judges"), "Invited as part of Cloudflare judges");
228 assert.equal(sharedInviteLine(" Hacker News readers "), "Invited as part of Hacker News readers");
229 // A one-person invite has no group, and says nothing of the kind.
230 assert.equal(sharedInviteLine(null), null);
231 assert.equal(sharedInviteLine(undefined), null);
232 assert.equal(sharedInviteLine(" "), null);
233});
234
235test("a shared invite link is sign-up with its code filled in", () => {
236 assert.equal(sharedInviteLink(CODE), `https://g1t.sh/register?invite=${CODE}`);
237 assert.equal(sharedInviteLink(CODE, "http://localhost:5173/"), `http://localhost:5173/register?invite=${CODE}`);
238 // The register page reads the code back out of its own link.
239 assert.equal(cleanCode(sharedInviteLink(CODE)), CODE);
240});
241
242test("a shared link limited to domains says which, on the email field", () => {
243 assert.equal(sharedDomainsHint([]), undefined);
244 assert.equal(sharedDomainsHint(null), undefined);
245 assert.equal(sharedDomainsHint(["cloudflare.com"]), "This invite is for addresses at cloudflare.com. Use yours there.");
246 assert.equal(
247 sharedDomainsHint(["a.com", "b.com", "c.com"]),
248 "This invite is for addresses at a.com, b.com or c.com. Use yours there.",
249 );
250});
251
252test("an invite to g1t can also invite its person to a workspace you own that can add people, never chosen for you", () => {
253 const memberships = [
254 { slug: "flagon-io", name: "Flagon, Inc.", role: "owner" as const },
255 { slug: "side", name: "side", role: "owner" as const },
256 { slug: "friends", name: "Friends", role: "member" as const },
257 ];
258 // Free ones and ones you only belong to are not offered; nothing is chosen, not even the current one.
259 const here = bringIntoChoices(memberships, ["side"], "flagon-io");
260 assert.deepEqual(here, { options: [{ slug: "flagon-io", name: "Flagon, Inc." }], note: null });
261 assert.equal("chosen" in here, false);
262 // In a free workspace: not offered, and the form says why.
263 assert.match(bringIntoChoices(memberships, ["side"], "side").note ?? "", /side is on the free plan, so it cannot add people/);
264 // In one you are only a member of.
265 assert.match(bringIntoChoices(memberships, [], "friends").note ?? "", /Only the owners of friends/);
266 assert.deepEqual(bringIntoChoices([], [], null), { options: [], note: null });
267});
268
269/** A submitted form, as `inviteDraft` reads it. */
270const form = (fields: Record<string, string>) => ({ get: (name: string) => fields[name] ?? null });
271
272test("an invite to g1t sends no workspace unless its box is ticked", () => {
273 // Off by default: a form without the box sends no `join`, even with a workspace left in it.
274 assert.deepEqual(inviteDraft(form({ intent: "create-invite", email: " ada@example.com ", charge: "mine" })), {
275 email: "ada@example.com",
276 workspace: null,
277 });
278 const untickedButFilled = inviteDraft(form({ email: "", join: "flagon-io", join_role: "owner" }));
279 assert.equal("join" in untickedButFilled, false);
280 assert.equal("joinRole" in untickedButFilled, false);
281 assert.equal(untickedButFilled.email, null);
282 // Ticked: the workspace and its role go with it.
283 assert.deepEqual(inviteDraft(form({ also_join: "on", join: "flagon-io", join_role: "owner", charge: "flagon-io" })), {
284 email: null,
285 workspace: "flagon-io",
286 join: "flagon-io",
287 joinRole: "owner",
288 });
289 // Ticked with no workspace chosen: still none. Any role but owner is member.
290 assert.equal("join" in inviteDraft(form({ also_join: "on", join: "" })), false);
291 assert.equal(inviteDraft(form({ also_join: "on", join: "acme", join_role: "admin" })).joinRole, "member");
292});
293
294test("the invites form keeps the workspace behind an unticked box", () => {
295 const section = readFileSync(new URL("../components/invites-section.tsx", import.meta.url), "utf8");
296 assert.match(section, /useState\(false\)/);
297 assert.match(section, /name="also_join"/);
298 // The workspace and role fields are drawn only once the box is ticked, so nothing else is sent.
299 assert.match(section, /\{alsoJoin && \(\s*<div[^]*?name="join"[^]*?name="join_role"/);
300 // No workspace is chosen for them: the select starts on its placeholder, and is required.
301 assert.match(section, /name="join"\s*required\s*placeholder="Choose a workspace"/);
302 assert.doesNotMatch(section, /bringInto\.chosen|Bring them into/);
303});
304
305test("invites to g1t are made only while sign-up takes one; after that only the list stays", () => {
306 assert.deepEqual(invitesPage("invite", 0), { form: true, listed: true });
307 assert.deepEqual(invitesPage(null, 0), { form: true, listed: true });
308 // Open: no form; the menus list the page only with invites to look back on.
309 assert.deepEqual(invitesPage("open", 0), { form: false, listed: false });
310 assert.deepEqual(invitesPage("open", 3), { form: false, listed: true });
311 assert.match(G1T_INVITES.open, /^Anyone can sign up for g1t now/);
312 assert.match(G1T_INVITES.open, /workspace's People page/);
313});
314
315test("the two invites say which they are", () => {
316 // Settings → Invites: an account, and no workspace.
317 assert.equal(G1T_INVITES.heading, "Invite people to g1t");
318 assert.equal(ACCOUNT_SETTINGS.invites.heading, G1T_INVITES.heading);
319 assert.equal(ACCOUNT_SETTINGS.invites.title, G1T_INVITES.nav);
320 assert.equal(ACCOUNT_SETTINGS.invites.about, G1T_INVITES.about);
321 assert.match(G1T_INVITES.about, /lets one person make an account\. It does not add them to any workspace/);
322 assert.equal(G1T_INVITES.alsoJoin, "Also invite them to a workspace");
323 // A workspace's People page: an invitation to accept or decline, which signs up whoever has no account.
324 const closed = workspaceInviteCopy("Flagon, Inc.", true);
325 assert.equal(closed.heading, "Invite to Flagon, Inc.");
326 assert.match(closed.hint, /invitation to join Flagon, Inc\..*join only if they accept/);
327 assert.match(closed.hint, /If they do not have a g1t account yet, the invitation also lets them sign up/);
328 assert.equal(closed.elsewhere, "To invite someone to g1t without adding them to Flagon, Inc., use Settings → Invites.");
329 // Once anyone can sign up, there is no invite to g1t to point to.
330 const open = workspaceInviteCopy("Flagon, Inc.", false);
331 assert.equal(open.elsewhere, null);
332 assert.doesNotMatch(open.hint, /one of yours/);
333 // Settings → Invites points to the People pages of the workspaces you own, the current one first.
334 assert.deepEqual(
335 peoplePages(
336 [
337 { slug: "side", name: null, role: "owner" },
338 { slug: "Flagon-io", name: "Flagon, Inc.", role: "owner" },
339 { slug: "friends", name: "Friends", role: "member" },
340 ],
341 "flagon-io",
342 ),
343 [
344 { slug: "flagon-io", name: "Flagon, Inc.", to: "/flagon-io/-/members" },
345 { slug: "side", name: "side", to: "/side/-/members" },
346 ],
347 );
348});
349
350test("an invite's page names the invite it is", () => {
351 const base = { kind: "account" as const, invitedBy: { username: "syntaqx" }, workspace: null, repository: null, hasAccount: false };
352 const g1t = invitePageCopy(base, false);
353 assert.equal(`${g1t.before}${g1t.place ?? ""}${g1t.after}`, "@syntaqx invited you to g1t");
354 assert.match(g1t.about, /lets you make an account\. It does not add you to anyone's workspace/);
355 const join = invitePageCopy({ ...base, workspace: { name: "Flagon, Inc." } }, false);
356 assert.equal(`${join.before}${join.place}${join.after}`, "@syntaqx invited you to join Flagon, Inc. on g1t");
357 assert.equal(join.place, "Flagon, Inc.");
358 assert.match(join.about, /invitation to join Flagon, Inc\., which you accept or decline/);
359 assert.match(join.about, /You do not have a g1t account yet, so it also lets you make one/);
360 // Someone with an account, or signed in, just accepts.
361 assert.match(invitePageCopy({ ...base, kind: "workspace", workspace: { name: "Flagon, Inc." }, hasAccount: true }, false).about, /Accepting joins you to Flagon, Inc\./);
362 assert.equal(invitePageCopy({ ...base, invitedBy: null }, false).before, "The g1t team invited you to g1t");
363});