317 lines
15 KiB
TypeScript
317 lines
15 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ManualLanguage } from "@tactical-media-broadcast-hub/core";
|
|
import { MANUAL_LANGUAGES, PARTICIPATION_MANUAL } from "@tactical-media-broadcast-hub/core";
|
|
import { onboardingSequence } from "./onboarding-content.js";
|
|
import { MatrixProvisioner, CLOSED_ROOM_ENCRYPTION, type MatrixAdminPort, type ProvisionerCall } from "./matrix-provisioner.js";
|
|
import { FOUNDER_POWER_LEVEL, MEMBER_POWER_LEVEL, type ProvisionCollectiveInput } from "./collectives.service.js";
|
|
import { loadProvisionerConfig, MatrixHttpClient, MatrixHttpError } from "./provisioner.config.js";
|
|
|
|
/**
|
|
* Provisioner unit tests against a fake MatrixAdminPort. These assert the ROOM
|
|
* SET SHAPE the design mandates (§"Rooms per collective"): open public+federated
|
|
* and never encrypted; closed invite-only with m.room.encryption present at
|
|
* creation with algorithm m.megolm.v1.aes-sha2; onboarding present, unencrypted,
|
|
* carrying the manual ES-first; founder pinned to PL 100.
|
|
*/
|
|
|
|
interface RecordedRoom {
|
|
roomId: string;
|
|
body: Record<string, unknown>;
|
|
}
|
|
|
|
/** Swap-fake for the Matrix port: no network, deterministic room ids. */
|
|
class FakeMatrix implements MatrixAdminPort {
|
|
readonly userId = "@provisioner:chat.example.com";
|
|
rooms: RecordedRoom[] = [];
|
|
invites: Array<{ roomId: string; userId: string }> = [];
|
|
messages: Array<{ roomId: string; txnId: string; content: Record<string, unknown> }> = [];
|
|
stateEvents: Array<{ roomId: string; eventType: string; stateKey: string; content: Record<string, unknown> }> = [];
|
|
failCreateRoom = false;
|
|
sequence = 0;
|
|
|
|
private nextId(): string {
|
|
this.sequence += 1;
|
|
return `!room${this.sequence}:chat.example.com`;
|
|
}
|
|
|
|
async createRoom(body: Record<string, unknown>): Promise<{ roomId: string }> {
|
|
if (this.failCreateRoom) throw new Error("connection refused");
|
|
const roomId = this.nextId();
|
|
this.rooms.push({ roomId, body });
|
|
return { roomId };
|
|
}
|
|
|
|
async sendStateEvent(roomId: string, eventType: string, stateKey: string, content: Record<string, unknown>): Promise<{ eventId: string }> {
|
|
this.stateEvents.push({ roomId, eventType, stateKey, content });
|
|
return { eventId: `$evt${this.stateEvents.length}` };
|
|
}
|
|
|
|
async invite(roomId: string, userId: string): Promise<void> {
|
|
this.invites.push({ roomId, userId });
|
|
}
|
|
|
|
async sendMessage(roomId: string, txnId: string, content: Record<string, unknown>): Promise<{ eventId: string }> {
|
|
this.messages.push({ roomId, txnId, content });
|
|
return { eventId: `$msg${this.messages.length}` };
|
|
}
|
|
}
|
|
|
|
function input(overrides: Partial<ProvisionCollectiveInput> = {}): ProvisionCollectiveInput {
|
|
return {
|
|
workspaceId: "11111111-1111-4111-8111-111111111111",
|
|
groupId: "22222222-2222-4222-8222-222222222222",
|
|
name: "Acampada 26S",
|
|
language: "es",
|
|
founderUserId: "33333333-3333-4333-8333-333333333333",
|
|
founderMxid: "@founder:chat.example.com",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function initialState(room: RecordedRoom, type: string): Record<string, unknown> | undefined {
|
|
const list = Array.isArray(room.body.initial_state) ? (room.body.initial_state as Array<Record<string, unknown>>) : [];
|
|
return list.find((event) => event.type === type)?.content as Record<string, unknown> | undefined;
|
|
}
|
|
|
|
function provisioner(fake: FakeMatrix): MatrixProvisioner {
|
|
return new MatrixProvisioner({ matrix: fake, txnPrefix: "test" });
|
|
}
|
|
|
|
describe("MatrixProvisioner room set shape", () => {
|
|
it("creates exactly three rooms: open, closed, onboarding", async () => {
|
|
const fake = new FakeMatrix();
|
|
const set = await provisioner(fake).provisionCollective(input());
|
|
expect(fake.rooms).toHaveLength(3);
|
|
expect(set.open.roomId).toBe("!room1:chat.example.com");
|
|
expect(set.closed.roomId).toBe("!room2:chat.example.com");
|
|
expect(set.onboarding.roomId).toBe("!room3:chat.example.com");
|
|
expect(new Set([set.open.roomId, set.closed.roomId, set.onboarding.roomId]).size).toBe(3);
|
|
});
|
|
|
|
it("open room: public, listed, federated, history shared, NO encryption state", async () => {
|
|
const fake = new FakeMatrix();
|
|
await provisioner(fake).provisionCollective(input());
|
|
const open = fake.rooms[0]!;
|
|
expect(open.body.visibility).toBe("public");
|
|
expect(open.body.preset).toBe("public_chat");
|
|
expect((open.body.creation_content as Record<string, unknown>)["m.federate"]).toBe(true);
|
|
expect(initialState(open, "m.room.history_visibility")).toEqual({ history_visibility: "shared" });
|
|
// The single most important negative assertion in this suite.
|
|
expect(initialState(open, "m.room.encryption")).toBeUndefined();
|
|
expect(JSON.stringify(open.body)).not.toContain("m.megolm");
|
|
});
|
|
|
|
it("closed room: private, invite-only preset, NOT federated, m.room.encryption set at creation with megolm", async () => {
|
|
const fake = new FakeMatrix();
|
|
await provisioner(fake).provisionCollective(input());
|
|
const closed = fake.rooms[1]!;
|
|
expect(closed.body.visibility).toBe("private");
|
|
expect(closed.body.preset).toBe("private_chat");
|
|
expect((closed.body.creation_content as Record<string, unknown>)["m.federate"]).toBe(false);
|
|
expect(initialState(closed, "m.room.encryption")).toEqual(CLOSED_ROOM_ENCRYPTION);
|
|
expect((initialState(closed, "m.room.encryption") as { algorithm: string }).algorithm).toBe("m.megolm.v1.aes-sha2");
|
|
});
|
|
|
|
it("CLOSED_ROOM_ENCRYPTION is exactly the megolm algorithm constant", () => {
|
|
expect(CLOSED_ROOM_ENCRYPTION).toEqual({ algorithm: "m.megolm.v1.aes-sha2" });
|
|
});
|
|
|
|
it("onboarding room: private, not federated, NOT encrypted, carries the manual", async () => {
|
|
const fake = new FakeMatrix();
|
|
await provisioner(fake).provisionCollective(input());
|
|
const onboarding = fake.rooms[2]!;
|
|
expect(onboarding.body.visibility).toBe("private");
|
|
expect((onboarding.body.creation_content as Record<string, unknown>)["m.federate"]).toBe(false);
|
|
expect(initialState(onboarding, "m.room.encryption")).toBeUndefined();
|
|
for (const message of fake.messages) expect(message.roomId).toBe(onboarding.roomId);
|
|
});
|
|
|
|
it("posts the manual ES-first (Spanish header before English header)", async () => {
|
|
const fake = new FakeMatrix();
|
|
await provisioner(fake).provisionCollective(input({ language: "es" }));
|
|
const bodies = fake.messages.map((m) => String(m.content.body));
|
|
const esHeader = bodies.findIndex((b) => b.startsWith("Manual de participación"));
|
|
const enHeader = bodies.findIndex((b) => b.startsWith("Participation manual"));
|
|
expect(esHeader).toBeGreaterThanOrEqual(0);
|
|
expect(enHeader).toBeGreaterThan(esHeader);
|
|
// One header + one message per section, per language.
|
|
expect(bodies).toHaveLength(2 * (PARTICIPATION_MANUAL.length + 1));
|
|
// Sections appear in manual order within each language block: ES section 1
|
|
// right after the ES header, EN section 1 right after the EN header.
|
|
expect(bodies[1]).toContain(PARTICIPATION_MANUAL[0]!.heading.es);
|
|
expect(bodies[2 + PARTICIPATION_MANUAL.length]).toContain(PARTICIPATION_MANUAL[0]!.heading.en);
|
|
});
|
|
|
|
it("posts the collective's own language first when it is English", async () => {
|
|
const fake = new FakeMatrix();
|
|
await provisioner(fake).provisionCollective(input({ language: "en" }));
|
|
const bodies = fake.messages.map((m) => String(m.content.body));
|
|
expect(bodies[0]).toMatch(/^Participation manual/);
|
|
expect(bodies[1 + PARTICIPATION_MANUAL.length]).toMatch(/^Manual de participación/);
|
|
});
|
|
|
|
it("manual posts are m.text notices with deterministic txn ids", async () => {
|
|
const fake = new FakeMatrix();
|
|
await provisioner(fake).provisionCollective(input());
|
|
for (const message of fake.messages) {
|
|
expect(message.content.msgtype).toBe("m.text");
|
|
expect(message.txnId).toMatch(/^test-!room3:chat\.example\.com-\d+$/);
|
|
}
|
|
expect(new Set(fake.messages.map((m) => m.txnId)).size).toBe(fake.messages.length);
|
|
});
|
|
|
|
it("power levels: founder and bot at 100, default member at 0, message allowed for members", async () => {
|
|
const fake = new FakeMatrix();
|
|
const founder = "@founder:chat.example.com";
|
|
await provisioner(fake).provisionCollective(input({ founderMxid: founder }));
|
|
for (const room of fake.rooms.slice(0, 2)) {
|
|
const pl = room.body.power_level_content_override as Record<string, unknown>;
|
|
const users = pl.users as Record<string, number>;
|
|
expect(users[founder]).toBe(FOUNDER_POWER_LEVEL);
|
|
expect(pl.users_default).toBe(MEMBER_POWER_LEVEL);
|
|
expect((pl.events as Record<string, number>)["m.room.message"]).toBe(MEMBER_POWER_LEVEL);
|
|
expect(pl.state_default).toBe(FOUNDER_POWER_LEVEL);
|
|
}
|
|
});
|
|
|
|
it("invites the founder to all three rooms when an mxid is known", async () => {
|
|
const fake = new FakeMatrix();
|
|
const set = await provisioner(fake).provisionCollective(input());
|
|
const invited = new Set(fake.invites.filter((i) => i.userId === "@founder:chat.example.com").map((i) => i.roomId));
|
|
expect(invited).toEqual(new Set([set.open.roomId, set.closed.roomId, set.onboarding.roomId]));
|
|
});
|
|
|
|
it("skips invites and power-level grants when no founder mxid is known", async () => {
|
|
const fake = new FakeMatrix();
|
|
await provisioner(fake).provisionCollective(input({ founderMxid: null }));
|
|
expect(fake.invites).toHaveLength(0);
|
|
for (const room of fake.rooms) {
|
|
const users = (room.body.power_level_content_override as Record<string, unknown>).users as Record<string, number>;
|
|
expect(Object.keys(users)).not.toContain("@provisioner:chat.example.com");
|
|
}
|
|
});
|
|
|
|
it("maps any homeserver failure to ProvisionerUnavailableError and persists nothing", async () => {
|
|
const fake = new FakeMatrix();
|
|
fake.failCreateRoom = true;
|
|
const provisioner = new MatrixProvisioner({ matrix: fake });
|
|
await expect(provisioner.provisionCollective(input())).rejects.toMatchObject({ name: "ProvisionerUnavailableError" });
|
|
// No partial room set was produced, and no manual was posted.
|
|
expect(provisioner.calls).toHaveLength(0);
|
|
expect(fake.messages).toHaveLength(0);
|
|
});
|
|
|
|
it("records every Matrix call in order (createRoom x3, manual posts, invites)", async () => {
|
|
const fake = new FakeMatrix();
|
|
const provisioner = new MatrixProvisioner({ matrix: fake, txnPrefix: "rec" });
|
|
await provisioner.provisionCollective(input());
|
|
const kinds = (provisioner.calls as ProvisionerCall[]).map((call) => call.kind);
|
|
expect(kinds.filter((kind) => kind === "createRoom")).toHaveLength(3);
|
|
// The open/closed founder invites legitimately precede the manual posts;
|
|
// what must hold is: every room exists before any post, and the onboarding
|
|
// invite (the last call) happens after the manual is already in the room.
|
|
expect(kinds.indexOf("sendMessage")).toBeGreaterThan(kinds.lastIndexOf("createRoom"));
|
|
expect(kinds.lastIndexOf("invite")).toBeGreaterThan(kinds.lastIndexOf("sendMessage"));
|
|
});
|
|
|
|
it("room names/topics follow the collective's language per room type", async () => {
|
|
const fake = new FakeMatrix();
|
|
await provisioner(fake).provisionCollective(input({ language: "es", name: "Colectivo" }));
|
|
expect(fake.rooms[0]!.body.name).toContain("abierta");
|
|
expect(fake.rooms[1]!.body.name).toContain("producción");
|
|
expect(fake.rooms[2]!.body.name).toContain("Acogida");
|
|
const fakeEn = new FakeMatrix();
|
|
await provisioner(fakeEn).provisionCollective(input({ language: "en", name: "Collective" }));
|
|
expect(fakeEn.rooms[0]!.body.name).toContain("open");
|
|
expect(fakeEn.rooms[1]!.body.name).toContain("production");
|
|
expect(fakeEn.rooms[2]!.body.name).toContain("Onboarding");
|
|
});
|
|
});
|
|
|
|
describe("onboardingSequence", () => {
|
|
it("is the two-language manual, primary first, for every supported language", () => {
|
|
for (const language of MANUAL_LANGUAGES) {
|
|
const sequence = onboardingSequence(language);
|
|
expect(sequence).toHaveLength(2 * (PARTICIPATION_MANUAL.length + 1));
|
|
const first = language === "es" ? /^Manual de participación/ : /^Participation manual/;
|
|
const second = language === "es" ? /^Participation manual/ : /^Manual de participación/;
|
|
expect(sequence[0]).toMatch(first);
|
|
expect(sequence[1 + PARTICIPATION_MANUAL.length]).toMatch(second);
|
|
}
|
|
});
|
|
|
|
it("covers every manual section in both languages", () => {
|
|
const sequence = onboardingSequence("es");
|
|
for (const section of PARTICIPATION_MANUAL) {
|
|
expect(sequence.some((text) => text.includes(section.heading.es))).toBe(true);
|
|
expect(sequence.some((text) => text.includes(section.heading.en))).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("loadProvisionerConfig", () => {
|
|
it("returns null when nothing is configured", () => {
|
|
expect(loadProvisionerConfig({})).toBeNull();
|
|
expect(
|
|
loadProvisionerConfig({
|
|
ARCHIVE_PROVISIONER_HOMESERVER_URL: "",
|
|
ARCHIVE_PROVISIONER_USER: "",
|
|
ARCHIVE_PROVISIONER_PASSWORD: "",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("reads the ARCHIVE_PROVISIONER_* names and defaults the device id", () => {
|
|
const config = loadProvisionerConfig({
|
|
ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://chat.example.com",
|
|
ARCHIVE_PROVISIONER_USER: "@provisioner:chat.example.com",
|
|
ARCHIVE_PROVISIONER_PASSWORD: "hunter2",
|
|
});
|
|
expect(config).toEqual({
|
|
homeserverUrl: new URL("https://chat.example.com"),
|
|
user: "@provisioner:chat.example.com",
|
|
password: "hunter2",
|
|
deviceId: "HUB_PROVISIONER_BOT",
|
|
});
|
|
});
|
|
|
|
it("fails loudly on a partial configuration", () => {
|
|
expect(() =>
|
|
loadProvisionerConfig({
|
|
ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://chat.example.com",
|
|
ARCHIVE_PROVISIONER_USER: "@provisioner:chat.example.com",
|
|
}),
|
|
).toThrow(/ARCHIVE_PROVISIONER_PASSWORD is required/);
|
|
expect(() =>
|
|
loadProvisionerConfig({
|
|
ARCHIVE_PROVISIONER_HOMESERVER_URL: "ftp://nope",
|
|
ARCHIVE_PROVISIONER_USER: "@p:chat.example.com",
|
|
ARCHIVE_PROVISIONER_PASSWORD: "x",
|
|
}),
|
|
).toThrow(/must be http\(s\)/);
|
|
});
|
|
|
|
it("MatrixHttpClient maps login failures to ProvisionerUnavailableError", async () => {
|
|
const client = new MatrixHttpClient({
|
|
homeserverUrl: new URL("https://chat.example.com"),
|
|
user: "@p:chat.example.com",
|
|
password: "x",
|
|
deviceId: "D",
|
|
});
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = (async () => new Response(JSON.stringify({ errcode: "M_FORBIDDEN", error: "bad credentials" }), { status: 403 })) as typeof fetch;
|
|
try {
|
|
const thrown = await client.login().then(
|
|
() => null,
|
|
(err: unknown) => err,
|
|
);
|
|
expect(thrown).toBeInstanceOf(MatrixHttpError);
|
|
const error = thrown as InstanceType<typeof MatrixHttpError>;
|
|
expect(error.status).toBe(403);
|
|
expect(error.errcode).toBe("M_FORBIDDEN");
|
|
expect(error.message).toContain("matrix HTTP 403");
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
});
|