T-HUB/packages/connector-mastodon/src/contract.test.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
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/.
2026-10-10 11:56:42 +00:00

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 &amp; 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);
});
});