420 lines
17 KiB
TypeScript
420 lines
17 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ConnectorConnection, MediaAsset, NormalizedPost } 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,
|
|
createFacebookConnector,
|
|
type FacebookAuthorizationRequest,
|
|
} from "./index.js";
|
|
|
|
/**
|
|
* Stubbed Graph API: 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;
|
|
/** Parsed multipart form when the request carried FormData. */
|
|
form: FormData | undefined;
|
|
}
|
|
|
|
interface StubState {
|
|
calls: StubCall[];
|
|
nextPostId: number;
|
|
nextPhotoId: number;
|
|
/** When set, DELETE of this id succeeds; everything else 404s. */
|
|
deletedId: string;
|
|
}
|
|
|
|
const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth";
|
|
const APP_ID = "1234567890";
|
|
const APP_SECRET = "app-secret";
|
|
const USER_TOKEN = "short-lived-user-token";
|
|
const LONG_TOKEN = "long-lived-user-token";
|
|
const PAGE_TOKEN = "page-access-token";
|
|
const PAGE_ID = "112233445566";
|
|
const PAGE_NAME = "AnarchoPoster Page";
|
|
const REDIRECT = "https://app.example.com/oauth/facebook/callback";
|
|
const WS = "ws-1";
|
|
|
|
const connection: ConnectorConnection = {
|
|
id: "conn-facebook",
|
|
workspaceId: WS,
|
|
provider: "facebook",
|
|
channelId: PAGE_ID,
|
|
credentialId: { kind: "credential-ref", id: "cred-facebook" },
|
|
fields: {},
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
const pageAsset: MediaAsset = {
|
|
id: "asset-1",
|
|
workspaceId: WS,
|
|
storageKey: "s3://bucket/one.png",
|
|
mimeType: "image/png",
|
|
sizeBytes: 4,
|
|
width: 640,
|
|
height: 480,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
function json(body: unknown, status = 200) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function graphError(message: string, code: number, status = 400) {
|
|
return json({ error: { message, type: "OAuthException", code } }, status);
|
|
}
|
|
|
|
function requestUrl(input: Parameters<typeof fetch>[0]): URL {
|
|
if (typeof input === "string") return new URL(input);
|
|
if (input instanceof URL) return input;
|
|
return new URL(input.url);
|
|
}
|
|
|
|
function authorizationOf(init: RequestInit | undefined): string | undefined {
|
|
return new Headers(init?.headers).get("authorization") ?? undefined;
|
|
}
|
|
|
|
/** Parses an x-www-form-urlencoded request body. */
|
|
function formParams(init: RequestInit | undefined): URLSearchParams {
|
|
return new URLSearchParams(typeof init?.body === "string" ? init.body : "");
|
|
}
|
|
|
|
const PAGES: Record<string, { name: string; username?: string; token: string }> = {
|
|
[`/${PAGE_ID}`]: { name: PAGE_NAME, username: "anarchoposter", token: PAGE_TOKEN },
|
|
"/999888777": { name: "Other Page", token: "other-page-token" },
|
|
};
|
|
|
|
function stubGraph(state: StubState) {
|
|
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
|
const url = requestUrl(input);
|
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
const form = init?.body instanceof FormData ? init.body : undefined;
|
|
state.calls.push({ method, url, init, form });
|
|
const path = url.pathname.replace(/^\/v[\d.]+/, "");
|
|
|
|
// --- OAuth legs (user token / app credentials, no Page token) ---
|
|
if (path === "/oauth/access_token") {
|
|
if (url.searchParams.get("grant_type") === "fb_exchange_token") {
|
|
if (url.searchParams.get("fb_exchange_token") !== USER_TOKEN) {
|
|
return graphError("Invalid OAuth access token", 190);
|
|
}
|
|
return json({ access_token: LONG_TOKEN, token_type: "bearer", expires_in: 5_184_000 });
|
|
}
|
|
if (url.searchParams.get("code") !== "good-code") {
|
|
return graphError("Code was invalid or expired", 100);
|
|
}
|
|
return json({ access_token: USER_TOKEN, token_type: "bearer", expires_in: 5_400 });
|
|
}
|
|
if (path === "/me/accounts") {
|
|
if (url.searchParams.get("access_token") !== LONG_TOKEN) {
|
|
return graphError("Invalid OAuth access token", 190);
|
|
}
|
|
return json({
|
|
data: Object.entries(PAGES).map(([path, page]) => ({
|
|
id: path.slice(1),
|
|
name: page.name,
|
|
...(page.username !== undefined ? { username: page.username } : {}),
|
|
access_token: page.token,
|
|
})),
|
|
});
|
|
}
|
|
|
|
// --- Page-scoped calls must carry a Page token (not a user token) ---
|
|
const pageTokens = Object.values(PAGES).map((page) => `Bearer ${page.token}`);
|
|
if (!pageTokens.includes(authorizationOf(init) ?? "")) {
|
|
return graphError("The access token is invalid", 190, 401);
|
|
}
|
|
|
|
if (method === "GET" && path in PAGES) {
|
|
if (url.searchParams.get("fields") !== "id,name,username") {
|
|
return graphError("unknown fields", 100);
|
|
}
|
|
const page = PAGES[path];
|
|
return json({ id: path.slice(1), name: page?.name, username: page?.username });
|
|
}
|
|
if (method === "POST" && path === `/${PAGE_ID}/photos`) {
|
|
if (form === undefined || form.get("published") !== "false") {
|
|
return graphError("photo upload must be unpublished", 100);
|
|
}
|
|
const file = form.get("file");
|
|
if (!(file instanceof Blob)) return graphError("missing file", 100);
|
|
const photoId = `photo-${state.nextPhotoId}`;
|
|
state.nextPhotoId += 1;
|
|
return json({ id: photoId, post_id: `${PAGE_ID}_${photoId.slice("photo-".length)}` });
|
|
}
|
|
if (method === "POST" && path === `/${PAGE_ID}/feed`) {
|
|
const body = formParams(init);
|
|
const id = `${PAGE_ID}_${state.nextPostId}`;
|
|
state.nextPostId += 1;
|
|
return json({ id, message: body.get("message") ?? "", link: body.get("link") ?? undefined });
|
|
}
|
|
if (method === "DELETE" && /^\/\d+_\d+$/.test(path)) {
|
|
if (path.endsWith(state.deletedId)) return json({ success: true });
|
|
return graphError("The post was not found", 100, 404);
|
|
}
|
|
return graphError(`stub: no route for ${method} ${path}`, 100, 404);
|
|
};
|
|
}
|
|
|
|
interface Harness {
|
|
state: StubState;
|
|
connector: ReturnType<typeof createFacebookConnector>;
|
|
credentials: InMemoryCredentials;
|
|
}
|
|
|
|
async function createHarness(seedPageToken = true): Promise<Harness> {
|
|
const state: StubState = { calls: [], nextPostId: 1, nextPhotoId: 1, deletedId: "" };
|
|
const credentials = new InMemoryCredentials();
|
|
if (seedPageToken) {
|
|
await credentials.put(connection, "access_token", PAGE_TOKEN);
|
|
await credentials.put(connection, "page_id", PAGE_ID);
|
|
}
|
|
const connector = createFacebookConnector({
|
|
env: { FACEBOOK_CLIENT_ID: APP_ID, FACEBOOK_CLIENT_SECRET: APP_SECRET },
|
|
fetchImpl: stubGraph(state),
|
|
requestsPerSecond: 1000,
|
|
sleep: async () => Promise.resolve(),
|
|
credentials,
|
|
loadAsset: async (assetId) => {
|
|
if (assetId === "asset-1") return pageAsset;
|
|
if (assetId === "asset-2") return { ...pageAsset, id: assetId };
|
|
if (assetId === "asset-video") return { ...pageAsset, id: assetId, mimeType: "video/mp4" };
|
|
throw new Error("asset not found");
|
|
},
|
|
openAsset: async () => ({ bytes: new Uint8Array([1, 2, 3, 4]), filename: "one.png" }),
|
|
});
|
|
return { state, connector, credentials };
|
|
}
|
|
|
|
const leg1: FacebookAuthorizationRequest = {
|
|
redirectUri: REDIRECT,
|
|
workspaceId: WS,
|
|
state: "csrf-1",
|
|
fields: { clientId: APP_ID, clientSecret: APP_SECRET },
|
|
};
|
|
|
|
function post(text: string, mediaAssetIds: string[] = [], meta: Record<string, unknown> = {}): NormalizedPost {
|
|
return { text, mediaAssetIds, visibility: "public", providerMetadata: meta };
|
|
}
|
|
|
|
connectorContractTests((await createHarness()).connector, { connection });
|
|
|
|
describe("facebook pages connector", () => {
|
|
it("declares honest Page capabilities and credential-backed fields", () => {
|
|
const manifest = createFacebookConnector({ credentials: new InMemoryCredentials() }).manifest();
|
|
expect(manifest.id).toBe("facebook");
|
|
expect(manifest.displayName).toBe("Facebook Pages");
|
|
expect(manifest.capabilities).toEqual(
|
|
expect.objectContaining({
|
|
publishText: true,
|
|
publishImages: true,
|
|
deletePublication: true,
|
|
nativeScheduling: false,
|
|
analytics: false,
|
|
feed: false,
|
|
comments: false,
|
|
webhooks: false,
|
|
publishVideo: false,
|
|
}),
|
|
);
|
|
expect(manifest.oauth).toMatchObject({
|
|
kind: "oauth2-redirect",
|
|
authorizeUrlTemplate: DIALOG,
|
|
scopes: ["pages_show_list", "pages_manage_posts", "pages_read_engagement"],
|
|
});
|
|
expect(manifest.connectionFields).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ name: "clientId", secret: false }),
|
|
expect.objectContaining({ name: "clientSecret", secret: true }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("leg 1 builds the v21.0 consent URL with the Pages scopes", async () => {
|
|
const { state, connector } = await createHarness(false);
|
|
const error = await connector.authorize(leg1).then(
|
|
() => undefined,
|
|
(e: unknown) => e,
|
|
);
|
|
expect(error).toBeInstanceOf(AuthorizationRedirectRequired);
|
|
const consent = new URL((error as AuthorizationRedirectRequired).authorizationUrl);
|
|
expect(consent.origin + consent.pathname).toBe(DIALOG);
|
|
expect(consent.searchParams.get("client_id")).toBe(APP_ID);
|
|
expect(consent.searchParams.get("redirect_uri")).toBe(REDIRECT);
|
|
expect(consent.searchParams.get("state")).toBe("csrf-1");
|
|
expect(consent.searchParams.get("scope")?.split(",")).toEqual([
|
|
"pages_show_list",
|
|
"pages_manage_posts",
|
|
"pages_read_engagement",
|
|
]);
|
|
expect(state.calls).toHaveLength(0);
|
|
|
|
const redirect = await connector.buildAuthorizationUrl?.(leg1);
|
|
expect(redirect?.authorizationUrl).toBe((error as AuthorizationRedirectRequired).authorizationUrl);
|
|
expect(redirect?.connection.provider).toBe("facebook");
|
|
});
|
|
|
|
it("leg 2 exchanges code → short-lived → long-lived token → /me/accounts page token", async () => {
|
|
const { state, connector, credentials } = await createHarness(false);
|
|
await expect(connector.authorize(leg1)).rejects.toBeInstanceOf(AuthorizationRedirectRequired);
|
|
|
|
const leg2: FacebookAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
|
|
const result = await connector.authorize(leg2);
|
|
|
|
const calls = state.calls.filter((call) => call.url.pathname.endsWith("/oauth/access_token"));
|
|
expect(calls).toHaveLength(2);
|
|
const short = calls[0]?.url.searchParams;
|
|
expect(short?.get("grant_type")).toBeNull();
|
|
expect(short?.get("code")).toBe("good-code");
|
|
expect(short?.get("client_id")).toBe(APP_ID);
|
|
expect(short?.get("client_secret")).toBe(APP_SECRET);
|
|
const extend = calls[1]?.url.searchParams;
|
|
expect(extend?.get("grant_type")).toBe("fb_exchange_token");
|
|
expect(extend?.get("fb_exchange_token")).toBe(USER_TOKEN);
|
|
|
|
const meAccounts = state.calls.find((call) => call.url.pathname.endsWith("/me/accounts"));
|
|
expect(meAccounts?.url.searchParams.get("access_token")).toBe(LONG_TOKEN);
|
|
|
|
// The PAGE token + page id are what get stored, not the user token.
|
|
expect(await credentials.getAccessToken(result.connection)).toBe(PAGE_TOKEN);
|
|
expect(await credentials.getSecret(result.connection, "page_id")).toBe(PAGE_ID);
|
|
expect(result.connection.channelId).toBe(PAGE_ID);
|
|
|
|
expect(result.identity).toEqual({
|
|
provider: "facebook",
|
|
remoteId: PAGE_ID,
|
|
handle: "@anarchoposter",
|
|
displayName: PAGE_NAME,
|
|
});
|
|
});
|
|
|
|
it("leg 2 honors a requested pageId from /me/accounts", async () => {
|
|
const { connector } = await createHarness(false);
|
|
const leg2: FacebookAuthorizationRequest = {
|
|
...leg1,
|
|
providerMetadata: { code: "good-code", pageId: "999888777" },
|
|
};
|
|
const result = await connector.authorize(leg2);
|
|
expect(result.connection.channelId).toBe("999888777");
|
|
expect(result.identity.handle).toBe("Other Page");
|
|
});
|
|
|
|
it("getIdentity reads id,name,username with the page token", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const identity = await connector.getIdentity(connection);
|
|
expect(identity).toEqual({
|
|
provider: "facebook",
|
|
remoteId: PAGE_ID,
|
|
handle: "@anarchoposter",
|
|
displayName: PAGE_NAME,
|
|
});
|
|
const call = state.calls.at(-1);
|
|
expect(call?.method).toBe("GET");
|
|
expect(call?.url.pathname).toBe(`/v21.0/${PAGE_ID}`);
|
|
expect(call?.url.searchParams.get("fields")).toBe("id,name,username");
|
|
expect(authorizationOf(call?.init)).toBe(`Bearer ${PAGE_TOKEN}`);
|
|
});
|
|
|
|
it("publishes a message to /{page-id}/feed with the page token", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const publication = await connector.publish?.(connection, post("Launch day"));
|
|
expect(publication?.remoteId).toBe(`${PAGE_ID}_1`);
|
|
expect(publication?.remoteUrl).toBe(`https://www.facebook.com/${PAGE_ID}_1`);
|
|
expect(publication?.connectionId).toBe(connection.id);
|
|
const call = state.calls.at(-1);
|
|
expect(call?.method).toBe("POST");
|
|
expect(call?.url.pathname).toBe(`/v21.0/${PAGE_ID}/feed`);
|
|
expect(authorizationOf(call?.init)).toBe(`Bearer ${PAGE_TOKEN}`);
|
|
const body = formParams(call?.init);
|
|
expect(body.get("message")).toBe("Launch day");
|
|
expect(body.get("link")).toBeNull();
|
|
});
|
|
|
|
it("publishes a link post via the feed link parameter", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const publication = await connector.publish?.(connection, post("Read this", [], { link: "https://example.com/a" }));
|
|
expect(publication?.remoteId).toBe(`${PAGE_ID}_1`);
|
|
const call = state.calls.at(-1);
|
|
const body = formParams(call?.init);
|
|
expect(body.get("message")).toBe("Read this");
|
|
expect(body.get("link")).toBe("https://example.com/a");
|
|
});
|
|
|
|
it("uploads images unpublished and attaches them via media_fbid", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const publication = await connector.publish?.(
|
|
connection,
|
|
post("With photos", ["asset-1", "asset-2"]),
|
|
);
|
|
expect(publication?.remoteId).toBe(`${PAGE_ID}_1`);
|
|
expect(publication?.providerMetadata).toMatchObject({
|
|
photoIds: ["photo-1", "photo-2"],
|
|
});
|
|
|
|
const photos = state.calls.filter((call) => call.url.pathname === `/v21.0/${PAGE_ID}/photos`);
|
|
expect(photos).toHaveLength(2);
|
|
for (const photo of photos) {
|
|
expect(authorizationOf(photo.init)).toBe(`Bearer ${PAGE_TOKEN}`);
|
|
expect(photo.form?.get("published")).toBe("false");
|
|
expect(photo.form?.get("file")).toBeInstanceOf(Blob);
|
|
}
|
|
const feed = state.calls.find((call) => call.url.pathname === `/v21.0/${PAGE_ID}/feed`);
|
|
const feedBody = formParams(feed?.init);
|
|
expect(feedBody.get("message")).toBe("With photos");
|
|
expect(feedBody.get("attached_media[0]")).toBe('{"media_fbid":"photo-1"}');
|
|
expect(feedBody.get("attached_media[1]")).toBe('{"media_fbid":"photo-2"}');
|
|
expect(feedBody.get("link")).toBeNull();
|
|
});
|
|
|
|
it("rejects more than 10 photos and non-image assets", async () => {
|
|
const { connector } = await createHarness();
|
|
await expect(
|
|
connector.publish?.(connection, post("too many", Array.from({ length: 11 }, (_, i) => `asset-${i}`))),
|
|
).rejects.toThrow(/at most 10 photos/);
|
|
await expect(
|
|
connector.publish?.(connection, post("video", ["asset-video"])),
|
|
).rejects.toBeInstanceOf(CapabilityNotSupportedError);
|
|
});
|
|
|
|
it("uploadMedia returns an unpublished photo id without publishing a post", async () => {
|
|
const { state, connector } = await createHarness();
|
|
const remote = await connector.uploadMedia?.(connection, pageAsset);
|
|
expect(remote).toMatchObject({ remoteId: "photo-1", mimeType: "image/png", width: 640, height: 480 });
|
|
expect(state.calls.some((call) => call.url.pathname.endsWith("/feed"))).toBe(false);
|
|
});
|
|
|
|
it("deletePublication DELETEs {page-id}_{post-id} and tolerates already-deleted", async () => {
|
|
const { state, connector } = await createHarness();
|
|
state.deletedId = `${PAGE_ID}_1`;
|
|
await expect(connector.deletePublication?.(connection, `${PAGE_ID}_1`)).resolves.toBeUndefined();
|
|
const call = state.calls.at(-1);
|
|
expect(call?.method).toBe("DELETE");
|
|
expect(call?.url.pathname).toBe(`/v21.0/${PAGE_ID}_1`);
|
|
expect(authorizationOf(call?.init)).toBe(`Bearer ${PAGE_TOKEN}`);
|
|
|
|
await expect(
|
|
connector.deletePublication?.(connection, `${PAGE_ID}_does-not-exist`),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("surfaces other Graph failures from deletePublication", async () => {
|
|
const { state, connector } = await createHarness();
|
|
state.deletedId = "never-matches";
|
|
await expect(
|
|
connector.deletePublication?.(connection, `${PAGE_ID}_404`),
|
|
).resolves.toBeUndefined(); // 404 → idempotent, not an error
|
|
await expect(
|
|
connector.publish?.(connection, post("nope", ["asset-unknown"])),
|
|
).rejects.toThrow(/asset not found/);
|
|
});
|
|
});
|