Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
447 lines
18 KiB
TypeScript
447 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
|
import { CapabilityNotSupportedError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
|
|
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
|
import {
|
|
AuthorizationRedirectRequired,
|
|
createMastodonConnector,
|
|
type MastodonAuthorizationRequest,
|
|
} from "./index.js";
|
|
|
|
/**
|
|
* Stubbed Mastodon instance: canned JSON per endpoint, real request/response
|
|
* shapes. Every call is recorded so tests can assert on what was sent.
|
|
*/
|
|
interface StubCall {
|
|
method: string;
|
|
url: URL;
|
|
init: RequestInit | undefined;
|
|
}
|
|
|
|
interface StubState {
|
|
calls: StubCall[];
|
|
nextStatusId: number;
|
|
/** Idempotency-Key → status id, like the instance's one-hour dedupe cache. */
|
|
idempotency?: Map<string, string>;
|
|
/** Statuses actually created (side effects, independent of responses). */
|
|
created?: string[];
|
|
}
|
|
|
|
const INSTANCE = "https://mastodon.example";
|
|
const HOST = "mastodon.example";
|
|
const TOKEN = "stub-access-token";
|
|
const REDIRECT = "https://app.example.com/oauth/mastodon/callback";
|
|
|
|
const ACCOUNT = {
|
|
id: "109000000000000001",
|
|
username: "anarchoposter",
|
|
acct: "anarchoposter",
|
|
display_name: "AnarchoPoster",
|
|
avatar: `${INSTANCE}/avatars/original/missing.png`,
|
|
url: `${INSTANCE}/@anarchoposter`,
|
|
};
|
|
|
|
const OTHER = {
|
|
id: "109000000000000002",
|
|
username: "alice",
|
|
acct: "alice@other.example",
|
|
display_name: "Alice",
|
|
avatar: "",
|
|
url: "https://other.example/@alice",
|
|
};
|
|
|
|
const connection: ConnectorConnection = {
|
|
id: "conn-mastodon",
|
|
workspaceId: "ws-1",
|
|
provider: "mastodon",
|
|
channelId: "conn-mastodon",
|
|
credentialId: { kind: "credential-ref", id: "cred-1" },
|
|
fields: { instanceUrl: HOST },
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
function status(
|
|
id: string,
|
|
overrides: Partial<Record<string, unknown>> = {},
|
|
): Record<string, unknown> {
|
|
return {
|
|
id,
|
|
created_at: "2026-10-06T12:00:00.000Z",
|
|
url: `${INSTANCE}/@tactical-media-broadcast-hub/${id}`,
|
|
uri: `${INSTANCE}/users/anarchoposter/statuses/${id}`,
|
|
content: "<p>hello</p>",
|
|
visibility: "public",
|
|
account: ACCOUNT,
|
|
media_attachments: [],
|
|
mentions: [],
|
|
reblogs_count: 0,
|
|
replies_count: 0,
|
|
favourites_count: 0,
|
|
in_reply_to_id: null,
|
|
reblog: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function json(body: unknown, init: ResponseInit = {}): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
...init,
|
|
headers: { "content-type": "application/json", ...init.headers },
|
|
});
|
|
}
|
|
|
|
function jsonBody(init: RequestInit | undefined): Record<string, unknown> {
|
|
return JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
|
}
|
|
|
|
function formBody(init: RequestInit | undefined): URLSearchParams {
|
|
return new URLSearchParams(String(init?.body ?? ""));
|
|
}
|
|
|
|
function headerOf(init: RequestInit | undefined, name: string): string | undefined {
|
|
return new Headers(init?.headers).get(name) ?? undefined;
|
|
}
|
|
|
|
function stubInstance(state: StubState): typeof fetch {
|
|
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
|
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
const url = new URL(raw);
|
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
state.calls.push({ method, url, init });
|
|
const route = `${method} ${url.pathname}`;
|
|
|
|
if (route === "POST /api/v1/apps") {
|
|
return json({
|
|
id: "563419",
|
|
name: formBody(init).get("client_name"),
|
|
client_id: "stub-client-id",
|
|
client_secret: "stub-client-secret",
|
|
redirect_uri: formBody(init).get("redirect_uris"),
|
|
});
|
|
}
|
|
if (route === "POST /oauth/token") {
|
|
const form = formBody(init);
|
|
if (form.get("code") !== "good-code") {
|
|
return json({ error: "invalid_grant", error_description: "The provided authorization grant is invalid" }, { status: 400 });
|
|
}
|
|
return json({ access_token: TOKEN, token_type: "Bearer", scope: "read write:statuses write:media", created_at: 1_760_000_000 });
|
|
}
|
|
|
|
// Everything below is an authenticated API call.
|
|
if (headerOf(init, "authorization") !== `Bearer ${TOKEN}`) {
|
|
return json({ error: "The access token is invalid" }, { status: 401 });
|
|
}
|
|
|
|
if (route === "GET /api/v1/accounts/verify_credentials") return json(ACCOUNT);
|
|
|
|
if (route === "POST /api/v1/statuses") {
|
|
const body = jsonBody(init);
|
|
const text = String(body["status"]);
|
|
if (text === "rate-limit-me") return json({ error: "Too many requests" }, { status: 429, headers: { "retry-after": "30" } });
|
|
if (text === "server-error-me") return json({ error: "Internal" }, { status: 500 });
|
|
if (text === "invalid-me") return json({ error: "Validation failed: Text can't be blank" }, { status: 422 });
|
|
const key = headerOf(init, "idempotency-key");
|
|
const seen = key === undefined ? undefined : state.idempotency?.get(key);
|
|
if (seen !== undefined) return json(status(seen, { content: `<p>${text}</p>` }));
|
|
const id = String((state.nextStatusId += 1));
|
|
if (key !== undefined) state.idempotency?.set(key, id);
|
|
state.created?.push(id);
|
|
if (typeof body["scheduled_at"] === "string") {
|
|
return json({ id: `s${id}`, scheduled_at: body["scheduled_at"], params: { text: body["status"] }, media_attachments: [] });
|
|
}
|
|
return json(
|
|
status(id, {
|
|
content: `<p>${String(body["status"])}</p>`,
|
|
visibility: body["visibility"] ?? "public",
|
|
...(typeof body["quoted_status_id"] === "string"
|
|
? { quote: { state: "accepted", quoted_status: status(body["quoted_status_id"], { account: OTHER }) } }
|
|
: {}),
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (route === "GET /api/v1/timelines/home") {
|
|
return json(
|
|
[
|
|
status("300", {
|
|
account: OTHER,
|
|
content: "<p>first & foremost<br>line two</p>",
|
|
media_attachments: [
|
|
{ id: "m1", type: "image", url: "https://files.example/m1.png", preview_url: null },
|
|
],
|
|
reblogs_count: 2,
|
|
replies_count: 1,
|
|
favourites_count: 5,
|
|
}),
|
|
status("299", { reblog: status("250", { account: OTHER, content: "<p>boosted</p>" }) }),
|
|
],
|
|
{ headers: { link: `<${INSTANCE}/api/v1/timelines/home?max_id=299>; rel="next", <${INSTANCE}/api/v1/timelines/home?min_id=300>; rel="prev"` } },
|
|
);
|
|
}
|
|
|
|
const reblog = /^POST \/api\/v1\/statuses\/([^/]+)\/reblog$/.exec(route);
|
|
if (reblog?.[1] !== undefined) {
|
|
const original = status(decodeURIComponent(reblog[1]), { account: OTHER });
|
|
return json(status(String((state.nextStatusId += 1)), { reblog: original, content: "" }));
|
|
}
|
|
|
|
return json({ error: `stub: no route for ${route}` }, { status: 404 });
|
|
};
|
|
}
|
|
|
|
interface Harness {
|
|
state: StubState;
|
|
connector: ReturnType<typeof createMastodonConnector>;
|
|
credentials: InMemoryCredentials;
|
|
}
|
|
|
|
async function createHarness(seedToken = true): Promise<Harness> {
|
|
const state: StubState = { calls: [], nextStatusId: 1000, idempotency: new Map(), created: [] };
|
|
const credentials = new InMemoryCredentials();
|
|
if (seedToken) await credentials.put(connection, "access_token", TOKEN);
|
|
const connector = createMastodonConnector({
|
|
fetchImpl: stubInstance(state),
|
|
requestsPerSecond: 1000,
|
|
sleep: async () => Promise.resolve(),
|
|
credentials,
|
|
});
|
|
return { state, connector, credentials };
|
|
}
|
|
|
|
const leg1: MastodonAuthorizationRequest = {
|
|
redirectUri: REDIRECT,
|
|
workspaceId: "ws-1",
|
|
state: "csrf-1",
|
|
fields: { instanceUrl: HOST },
|
|
};
|
|
|
|
const post = (text: string) => ({
|
|
text,
|
|
mediaAssetIds: [],
|
|
visibility: "public" as const,
|
|
providerMetadata: {},
|
|
});
|
|
|
|
connectorContractTests((await createHarness()).connector, { connection });
|
|
|
|
describe("mastodon connector: authorize + identity", () => {
|
|
it("leg 1 registers an app on the instance and redirects to its consent page", async () => {
|
|
const { state, connector } = await createHarness(false);
|
|
const error = await connector
|
|
.authorize(leg1)
|
|
.then(
|
|
() => undefined,
|
|
(e: unknown) => e,
|
|
);
|
|
expect(error).toBeInstanceOf(AuthorizationRedirectRequired);
|
|
const redirect = error as AuthorizationRedirectRequired;
|
|
const consent = new URL(redirect.authorizationUrl);
|
|
expect(consent.origin + consent.pathname).toBe(`${INSTANCE}/oauth/authorize`);
|
|
expect(consent.searchParams.get("client_id")).toBe("stub-client-id");
|
|
expect(consent.searchParams.get("redirect_uri")).toBe(REDIRECT);
|
|
expect(consent.searchParams.get("state")).toBe("csrf-1");
|
|
expect(redirect.connectionId).toBe(`mastodon:ws-1:${HOST}`);
|
|
|
|
const app = state.calls.find((c) => c.url.pathname === "/api/v1/apps");
|
|
expect(app?.method).toBe("POST");
|
|
expect(formBody(app?.init).get("scopes")).toBe("read write:statuses write:media");
|
|
});
|
|
|
|
it("leg 2 exchanges the code and the identity roundtrips through getIdentity", async () => {
|
|
const { state, connector } = await createHarness(false);
|
|
await expect(connector.authorize(leg1)).rejects.toBeInstanceOf(AuthorizationRedirectRequired);
|
|
|
|
const leg2: MastodonAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
|
|
const result = await connector.authorize(leg2);
|
|
const token = state.calls.find((c) => c.url.pathname === "/oauth/token");
|
|
const form = formBody(token?.init);
|
|
expect(form.get("grant_type")).toBe("authorization_code");
|
|
expect(form.get("client_id")).toBe("stub-client-id");
|
|
expect(form.get("client_secret")).toBe("stub-client-secret");
|
|
// Client registered once in leg 1, reused from the vault in leg 2.
|
|
expect(state.calls.filter((c) => c.url.pathname === "/api/v1/apps")).toHaveLength(1);
|
|
|
|
expect(result.connection.fields.instanceUrl).toBe(INSTANCE);
|
|
expect(result.identity).toEqual({
|
|
provider: "mastodon",
|
|
remoteId: ACCOUNT.id,
|
|
handle: `@anarchoposter@${HOST}`,
|
|
displayName: "AnarchoPoster",
|
|
avatarUrl: ACCOUNT.avatar,
|
|
});
|
|
expect(await connector.getIdentity(result.connection)).toEqual(result.identity);
|
|
});
|
|
|
|
it("maps a rejected code to a ConnectorError with the instance's description", async () => {
|
|
const { connector } = await createHarness(false);
|
|
const request: MastodonAuthorizationRequest = {
|
|
...leg1,
|
|
fields: { instanceUrl: HOST, clientId: "cid", clientSecret: "csecret" },
|
|
providerMetadata: { code: "bad-code" },
|
|
};
|
|
await expect(connector.authorize(request)).rejects.toThrow(/authorization grant is invalid/);
|
|
});
|
|
|
|
it("getIdentity sends the bearer token and caches verify_credentials", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const first = await connector.getIdentity(connection);
|
|
const second = await connector.getIdentity(connection);
|
|
expect(second).toEqual(first);
|
|
const calls = state.calls.filter((c) => c.url.pathname === "/api/v1/accounts/verify_credentials");
|
|
expect(calls).toHaveLength(1);
|
|
expect(headerOf(calls[0]?.init, "authorization")).toBe(`Bearer ${TOKEN}`);
|
|
});
|
|
});
|
|
|
|
describe("mastodon connector: publish", () => {
|
|
it("returns a Publication carrying the status id and url", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const publication = await connector.publish?.(connection, post("hello fediverse"));
|
|
const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses");
|
|
expect(call?.method).toBe("POST");
|
|
expect(jsonBody(call?.init)).toEqual({ status: "hello fediverse", visibility: "public" });
|
|
expect(publication?.remoteId).toBe("1001");
|
|
expect(publication?.remoteUrl).toBe(`${INSTANCE}/@tactical-media-broadcast-hub/1001`);
|
|
expect(publication?.workspaceId).toBe("ws-1");
|
|
expect(publication?.connectionId).toBe("conn-mastodon");
|
|
expect(publication?.draftId).toBe("direct");
|
|
expect(publication?.publishedAt).toEqual(new Date("2026-10-06T12:00:00.000Z"));
|
|
expect(publication?.providerMetadata).toMatchObject({ visibility: "public" });
|
|
});
|
|
|
|
it("surfaces a far-future schedule as provider_scheduled with a scheduled: remote id", async () => {
|
|
const { connector } = await createHarness();
|
|
const scheduledAt = new Date(Date.now() + 60 * 60 * 1000);
|
|
const publication = await connector.publish?.(connection, {
|
|
...post("later"),
|
|
scheduledAt,
|
|
providerMetadata: { draftId: "draft-from-user" },
|
|
});
|
|
expect(publication?.remoteId).toBe("scheduled:s1001");
|
|
expect(publication?.publishedAt).toEqual(scheduledAt);
|
|
expect(publication?.providerMetadata).toMatchObject({
|
|
outcome: "provider_scheduled",
|
|
scheduled: true,
|
|
scheduledStatusId: "s1001",
|
|
});
|
|
// Caller metadata never becomes the publication's draft identity.
|
|
expect(publication?.draftId).toBe("direct");
|
|
});
|
|
|
|
it("sends the injected idempotency key and replays to the same status", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const key = "wp3:ws-1:draft-1:a1:publish";
|
|
const withKey = { ...post("once"), providerMetadata: { idempotencyKey: key, draftId: "draft-1" } };
|
|
const first = await connector.publish?.(connection, withKey);
|
|
const replay = await connector.publish?.(connection, withKey);
|
|
const posts = state.calls.filter((c) => c.method === "POST" && c.url.pathname === "/api/v1/statuses");
|
|
expect(posts.map((c) => headerOf(c.init, "idempotency-key"))).toEqual([key, key]);
|
|
expect(replay?.remoteId).toBe(first?.remoteId);
|
|
expect(state.created).toEqual(["1001"]);
|
|
expect(first?.providerMetadata).toMatchObject({ outcome: "published", idempotencyKey: key });
|
|
expect(first?.draftId).toBe("direct");
|
|
});
|
|
|
|
it("never resends a status POST on 5xx and reports unknown delivery", async () => {
|
|
const { state, connector } = await createHarness();
|
|
await expect(connector.publish?.(connection, post("server-error-me"))).rejects.toMatchObject({
|
|
name: "ProviderServerError",
|
|
status: 500,
|
|
delivery: "unknown",
|
|
});
|
|
expect(state.calls.filter((c) => c.url.pathname === "/api/v1/statuses")).toHaveLength(1);
|
|
});
|
|
|
|
it("surfaces a status POST 429 as a single-call RateLimitError with Retry-After", async () => {
|
|
const { state, connector } = await createHarness();
|
|
await expect(connector.publish?.(connection, post("rate-limit-me"))).rejects.toMatchObject({
|
|
name: "RateLimitError",
|
|
retryAfterMs: 30_000,
|
|
delivery: "rejected",
|
|
});
|
|
expect(state.calls.filter((c) => c.url.pathname === "/api/v1/statuses")).toHaveLength(1);
|
|
});
|
|
|
|
it("maps a validation 422 to ProviderRejectedError", async () => {
|
|
const { connector } = await createHarness();
|
|
await expect(connector.publish?.(connection, post("invalid-me"))).rejects.toMatchObject({
|
|
name: "ProviderRejectedError",
|
|
status: 422,
|
|
delivery: "rejected",
|
|
});
|
|
});
|
|
|
|
it("maps a 401 to AuthError", async () => {
|
|
const { connector, credentials } = await createHarness();
|
|
await credentials.put(connection, "access_token", "revoked");
|
|
await expect(connector.publish?.(connection, post("x"))).rejects.toMatchObject({ name: "AuthError" });
|
|
});
|
|
});
|
|
|
|
describe("mastodon connector: feed", () => {
|
|
it("home returns a Page<FeedItem> with plain text, unwrapped reblogs and a Link cursor", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const page = await connector.getFeed?.(connection, { kind: "home", limit: 20, cursor: "400" });
|
|
const call = state.calls.find((c) => c.url.pathname === "/api/v1/timelines/home");
|
|
expect(call?.url.searchParams.get("limit")).toBe("20");
|
|
expect(call?.url.searchParams.get("max_id")).toBe("400");
|
|
|
|
expect(page?.items).toHaveLength(2);
|
|
expect(page?.nextCursor).toBe("299");
|
|
const [first, boosted] = page?.items ?? [];
|
|
expect(first).toEqual({
|
|
id: "300",
|
|
provider: "mastodon",
|
|
remoteId: "300",
|
|
authorHandle: "alice@other.example",
|
|
text: "first & foremost\nline two",
|
|
mediaUrls: ["https://files.example/m1.png"],
|
|
createdAt: new Date("2026-10-06T12:00:00.000Z"),
|
|
metrics: { reblogs: 2, replies: 1, favourites: 5 },
|
|
});
|
|
// The wrapper keeps its own id; content comes from the reblogged original.
|
|
expect(boosted?.id).toBe("299");
|
|
expect(boosted?.remoteId).toBe("250");
|
|
expect(boosted?.text).toBe("boosted");
|
|
});
|
|
|
|
it("refuses search queries that are not a single hashtag", async () => {
|
|
const { connector } = await createHarness();
|
|
await expect(
|
|
connector.getFeed?.(connection, { kind: "search", query: "two words", limit: 10 }),
|
|
).rejects.toThrow(/single hashtag/);
|
|
});
|
|
});
|
|
|
|
describe("mastodon connector: amplify", () => {
|
|
it("repost reblogs the target and returns a Publication", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const publication = await connector.amplify?.(connection, { kind: "repost", targetRemoteId: "250" });
|
|
const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses/250/reblog");
|
|
expect(call?.method).toBe("POST");
|
|
expect(publication?.remoteId).toBe("1001");
|
|
expect(publication?.draftId).toBe("amplify:repost");
|
|
expect(publication?.remoteUrl).toBe(`${INSTANCE}/@tactical-media-broadcast-hub/1001`);
|
|
expect(publication?.providerMetadata).toMatchObject({ amplifies: "250", kind: "repost" });
|
|
});
|
|
|
|
it("quote posts with quoted_status_id on a 4.5+ instance", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const publication = await connector.amplify?.(connection, {
|
|
kind: "quote",
|
|
targetRemoteId: "250",
|
|
commentary: "worth a read",
|
|
});
|
|
const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses");
|
|
expect(jsonBody(call?.init)).toEqual({ status: "worth a read", quoted_status_id: "250" });
|
|
expect(publication?.providerMetadata).toMatchObject({ kind: "quote", quoteState: "accepted" });
|
|
});
|
|
|
|
it("share is not a Mastodon amplification kind", async () => {
|
|
const { connector } = await createHarness();
|
|
await expect(
|
|
connector.amplify?.(connection, { kind: "share", targetRemoteId: "250" }),
|
|
).rejects.toBeInstanceOf(CapabilityNotSupportedError);
|
|
});
|
|
});
|