Add organisation invitations and one-time-code gateway enrolment

Invitations: token shown once, only its hash stored, 7-day expiry, the
accepting account's email must match. Enrolment codes: 15 minutes,
single use, hashed; POST /auth/enrol returns the farm's existing gateway
token without rotating it and is rate-limited per IP. No email is sent;
the inviter shares the link. Migration 0007, unit tests and
scripts/e2e-onboarding.sh.
This commit is contained in:
2026-10-30 08:13:00 +00:00
parent b431ee4030
commit fc8cf1d509
13 changed files with 1692 additions and 12 deletions
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import {
FailureLimiter,
codeKey,
hashSecret,
newEnrolmentCode,
newInviteToken,
normaliseCode,
} from "../src/onboarding/tokens";
describe("enrolment codes", () => {
test("have the shape that was promised and do not repeat", () => {
const codes = new Set(Array.from({ length: 200 }, newEnrolmentCode));
expect(codes.size).toBe(200);
for (const code of codes) expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
});
test("a code typed carelessly still finds the one that was issued", () => {
const issued = "AB0C1-DEF23";
for (const typed of ["ab0c1-def23", "AB0C1DEF23", " ab0c1 def23 ", "ABOC1-DEF23", "AB0Cl-DEF23", "ABOCI-DEF23"]) {
expect(codeKey(typed)).toBe(codeKey(issued));
}
expect(codeKey("AB0C1-DEF24")).not.toBe(codeKey(issued));
});
test("normalising is idempotent", () => {
expect(normaliseCode(normaliseCode("ab-oc1"))).toBe(normaliseCode("ab-oc1"));
});
});
describe("invitation tokens", () => {
test("are long, URL-safe and unique, and only their hash is kept", () => {
const a = newInviteToken();
expect(a).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(newInviteToken()).not.toBe(a);
expect(hashSecret(a)).toMatch(/^[0-9a-f]{64}$/);
expect(hashSecret(a)).not.toContain(a);
});
});
describe("FailureLimiter", () => {
test("blocks after too many failures and forgets them after the window", () => {
const limiter = new FailureLimiter(3, 1000);
for (let i = 0; i < 3; i++) {
expect(limiter.blocked("ip", i)).toBe(false);
limiter.fail("ip", i);
}
expect(limiter.blocked("ip", 10)).toBe(true);
expect(limiter.blocked("other", 10)).toBe(false);
expect(limiter.blocked("ip", 2000)).toBe(false);
});
});