// Offline tests for the platform adapters: every platform API is simulated by a fake fetch that records // what Quorla sends. Run: node --test connector/test/ (no network, no real accounts touched) import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-test-")); process.env.QUORLA_CONNECTIONS_FILE = path.join(TMP, "connections.json"); process.env.DASHBOARD_SECRET = "test-secret-not-real"; process.env.PUBLIC_URL = "https://app.example.test"; const { PROJECT_ROOT } = await import("../src/env.js"); const P = await import("../src/platforms/index.js"); const { vault } = P; const media = await import("../src/platforms/media.js"); const { littleText } = await import("../src/platforms/linkedin.js"); const { chunkPlan } = await import("../src/platforms/tiktok.js"); // Test media inside data/library (the only folders media links may expose). const LIB = path.join(PROJECT_ROOT, "data", "library", "_quorla_test"); const IMG = path.join(LIB, "1700000000000-abcd-photo.jpg"); const IMG2 = path.join(LIB, "1700000000001-abcd-photo2.png"); const VID = path.join(LIB, "1700000000002-abcd-clip.mp4"); // ---------- fake fetch ---------- let calls = []; let routes = []; const realFetch = globalThis.fetch; function route(method, match, reply) { routes.push({ method, match, reply }); } function reset() { calls = []; routes = []; } const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } }); async function fakeFetch(input, init = {}) { const url = String(input instanceof URL ? input : input?.url || input); const method = (init.method || "GET").toUpperCase(); let body = init.body; if (body instanceof URLSearchParams) body = body.toString(); calls.push({ method, url, headers: init.headers || {}, body }); const r = routes.find((x) => x.method === method && (typeof x.match === "string" ? url.startsWith(x.match) : x.match.test(url))); if (!r) return json({ error: { message: `no fake route for ${method} ${url}` } }, 599); return typeof r.reply === "function" ? r.reply({ url, method, body, headers: init.headers || {} }) : r.reply.clone(); } const sent = (re) => calls.filter((c) => re.test(c.url)); const bodyJson = (c) => JSON.parse(c.body); const form = (c) => Object.fromEntries(new URLSearchParams(c.body)); const user = { id: "u1", name: "Tester" }; async function connect(platform, client, { tokenReply, identify }) { vault.saveApp(platform, { clientId: `${platform}-id`, clientSecret: `${platform}-secret` }, "test"); const url = new URL(P.startConnect({ platform, client, redirectUri: `https://app.example.test/oauth/${platform}/callback`, user })); const state = url.searchParams.get("state"); route("POST", P.adapter(platform).auth.tokenUrl, tokenReply || json({ access_token: `${platform}-at`, refresh_token: `${platform}-rt`, expires_in: 3600, scope: "a b" })); for (const [m, match, reply] of identify) route(m, match, reply); const conn = await P.finishConnect({ platform, code: "the-code", state, user }); return { url, conn }; } before(() => { fs.mkdirSync(LIB, { recursive: true }); fs.writeFileSync(IMG, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3])); fs.writeFileSync(IMG2, Buffer.from([0x89, 0x50, 0x4e, 0x47, 4, 5])); fs.writeFileSync(VID, Buffer.alloc(1024, 7)); globalThis.fetch = fakeFetch; }); after(() => { globalThis.fetch = realFetch; fs.rmSync(LIB, { recursive: true, force: true }); fs.rmSync(TMP, { recursive: true, force: true }); }); // ---------- vault ---------- test("vault: secrets are encrypted at rest and never listed", () => { vault.saveApp("linkedin", { clientId: "cid", clientSecret: "super-secret-value" }, "t"); const raw = fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8"); assert.ok(!raw.includes("super-secret-value") && !raw.includes("cid"), "app secret must not be stored in plain text"); assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value"); vault.saveApp("linkedin", { clientId: "cid2", clientSecret: "" }, "t"); assert.equal(vault.appCredentials("linkedin").clientSecret, "super-secret-value", "empty secret keeps the stored one"); const c = vault.saveConnection({ client: "acme", platform: "linkedin", account: { id: "1", name: "A" }, tokens: { accessToken: "tok-123" }, by: "t" }); assert.ok(!JSON.stringify(vault.listConnections()).includes("tok-123")); assert.ok(!fs.readFileSync(process.env.QUORLA_CONNECTIONS_FILE, "utf8").includes("tok-123")); assert.equal(vault.getConnection(c.id, { withTokens: true }).tokens.accessToken, "tok-123"); vault.removeConnection(c.id); }); test("vault: a sign-in state works once and only for the same platform/user", () => { const s = vault.createState({ platform: "x", userId: "u1" }); assert.equal(vault.takeState(s).platform, "x"); assert.throws(() => vault.takeState(s), /expired or was already used/); }); // ---------- media links ---------- test("media links: signed, expiring, limited to library/uploads", () => { const u = media.publicMediaUrl(IMG); assert.match(u, /^https:\/\/app\.example\.test\/m\/[^/]+\/photo\.jpg$/); const token = u.split("/m/")[1].split("/")[0]; assert.equal(media.resolveMediaToken(token), path.resolve(IMG)); assert.equal(media.resolveMediaToken(token.slice(0, -2) + "xx"), null, "tampered signature rejected"); assert.throws(() => media.publicMediaUrl(path.join(PROJECT_ROOT, ".env")), /Only files from the content library/); const old = media.publicMediaUrl(IMG, { hours: -1 }).split("/m/")[1].split("/")[0]; assert.equal(media.resolveMediaToken(old), null, "expired link rejected"); }); // ---------- helpers ---------- test("LinkedIn little-text escaping keeps hashtags as hashtags", () => { assert.equal(littleText("Hi (team) #Waterloo_Eats, see [x]!"), "Hi \\(team\\) {hashtag|\\#|Waterloo_Eats}, see \\[x\\]!"); assert.equal(littleText("C# 100%"), "C\\# 100%"); }); test("TikTok chunk plan follows TikTok's rules", () => { assert.deepEqual(chunkPlan(3 * 1024 * 1024), { chunkSize: 3 * 1024 * 1024, count: 1 }); const big = 150 * 1024 * 1024; const p = chunkPlan(big); assert.equal(p.chunkSize, 10 * 1024 * 1024); assert.equal(p.count, 15); assert.ok(big - (p.count - 1) * p.chunkSize <= 128 * 1024 * 1024, "last chunk stays under 128 MB"); }); // ---------- sign-in URLs ---------- test("sign-in URLs carry the right client id, scopes and PKCE per platform", () => { for (const id of P.PLATFORM_IDS) vault.saveApp(id, { clientId: `${id}-id`, clientSecret: "s" }, "t"); const u = (id) => new URL(P.startConnect({ platform: id, client: "acme", redirectUri: `https://app.example.test/oauth/${id}/callback`, user })); const tt = u("tiktok"); assert.equal(tt.searchParams.get("client_key"), "tiktok-id"); assert.match(tt.searchParams.get("scope"), /video\.publish,video\.upload/); const yt = u("youtube"); assert.equal(yt.searchParams.get("access_type"), "offline"); assert.equal(yt.searchParams.get("code_challenge_method"), "S256"); assert.ok(yt.searchParams.get("code_challenge")); assert.equal(u("reddit").searchParams.get("duration"), "permanent"); assert.equal(u("linkedin").searchParams.get("redirect_uri"), "https://app.example.test/oauth/linkedin/callback"); vault.saveApp("linkedin", { clientId: "x", clientSecret: "s", scopes: "openid profile w_member_social" }, "t"); assert.equal(u("linkedin").searchParams.get("scope"), "openid profile w_member_social", "scope override is used"); }); // ---------- each platform: connect + publish ---------- test("LinkedIn: connect, list company Pages, post image to a company Page", async () => { reset(); const { conn } = await connect("linkedin", "acme", { identify: [ ["GET", "https://api.linkedin.com/v2/userinfo", json({ sub: "abc", name: "Pat Lee", picture: "https://p" })], ["GET", "https://api.linkedin.com/rest/organizationAcls", json({ elements: [{ organization: "urn:li:organization:42" }] })], ["GET", "https://api.linkedin.com/rest/organizations/42", json({ localizedName: "Acme Co" })], ] }); assert.equal(conn.account.name, "Pat Lee"); const targets = await P.targetsFor(conn.id); assert.deepEqual(targets.map((t) => t.id), ["urn:li:person:abc", "urn:li:organization:42"]); await P.setTarget(conn.id, "urn:li:organization:42"); route("POST", "https://api.linkedin.com/rest/images?action=initializeUpload", json({ value: { uploadUrl: "https://upload.linkedin.test/1", image: "urn:li:image:9" } })); route("PUT", "https://upload.linkedin.test/1", new Response("", { status: 201 })); route("POST", "https://api.linkedin.com/rest/posts", new Response("", { status: 201, headers: { "x-restli-id": "urn:li:share:77" } })); const r = await P.publish("linkedin", "acme", { type: "IMAGE", media: [IMG], caption: "New menu #Waterloo" }); assert.equal(r.id, "urn:li:share:77"); const post = bodyJson(sent(/rest\/posts/)[0]); assert.equal(post.author, "urn:li:organization:42"); assert.equal(post.content.media.id, "urn:li:image:9"); assert.match(post.commentary, /\{hashtag\|\\#\|Waterloo\}/); assert.ok(sent(/rest\/posts/)[0].headers["LinkedIn-Version"]); }); test("YouTube: connect a channel, upload a Short with title and privacy", async () => { reset(); const { conn } = await connect("youtube", "acme", { identify: [ ["GET", "https://www.googleapis.com/youtube/v3/channels", json({ items: [{ id: "UC1", snippet: { title: "Acme TV", customUrl: "@acmetv", thumbnails: { default: { url: "t" } } }, statistics: { subscriberCount: "1200" } }] })], ] }); assert.equal(conn.account.followers, 1200); route("POST", "https://www.googleapis.com/upload/youtube/v3/videos", new Response("", { status: 200, headers: { location: "https://upload.youtube.test/s1" } })); route("PUT", "https://upload.youtube.test/s1", json({ id: "vid1", status: { privacyStatus: "unlisted" } })); P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: { youtube: { title: "Behind the scenes" } } }); assert.throws(() => P.validate("youtube", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /video title is required/); assert.throws(() => P.validate("youtube", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: { youtube: { title: "t" } } }), /doesn't take image posts/); const r = await P.publish("youtube", "acme", { type: "REELS", media: [VID], caption: "Desc", options: { youtube: { title: "Behind the scenes", privacy: "unlisted" } } }); assert.equal(r.permalink, "https://www.youtube.com/shorts/vid1"); const meta = JSON.parse(sent(/upload\/youtube/)[0].body); assert.equal(meta.snippet.title, "Behind the scenes"); assert.equal(meta.status.privacyStatus, "unlisted"); assert.equal(meta.status.selfDeclaredMadeForKids, false); }); test("Google Business Profile: auto-picks the only location, posts with a button and photo link", async () => { reset(); const { conn } = await connect("gbp", "acme", { identify: [ ["GET", "https://openidconnect.googleapis.com/v1/userinfo", json({ sub: "g1", email: "owner@acme.test" })], ["GET", "https://mybusinessaccountmanagement.googleapis.com/v1/accounts", json({ accounts: [{ name: "accounts/1" }] })], ["GET", "https://mybusinessbusinessinformation.googleapis.com/v1/accounts/1/locations", json({ locations: [{ name: "locations/5", title: "Acme Bakery", storefrontAddress: { addressLines: ["1 King St"], locality: "Waterloo" } }] })], ] }); assert.equal(vault.getConnection(conn.id).target.id, "accounts/1/locations/5"); route("POST", "https://mybusiness.googleapis.com/v4/accounts/1/locations/5/localPosts", json({ name: "accounts/1/locations/5/localPosts/9", searchUrl: "https://g.test/p" })); assert.throws(() => P.validate("gbp", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /doesn't take video/); await assert.rejects(P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER" } } }), /add a link for the button/); const r = await P.publish("gbp", "acme", { type: "IMAGE", media: [IMG], caption: "Fresh bread", options: { gbp: { cta: "ORDER", ctaUrl: "https://acme.test/order" } } }); assert.equal(r.permalink, "https://g.test/p"); const b = bodyJson(sent(/localPosts/).at(-1)); assert.deepEqual(b.callToAction, { actionType: "ORDER", url: "https://acme.test/order" }); assert.match(b.media[0].sourceUrl, /^https:\/\/app\.example\.test\/m\//); }); test("TikTok: connect, privacy is enforced, video uploads and photo posts pull from signed links", async () => { reset(); const { conn } = await connect("tiktok", "acme", { tokenReply: json({ access_token: "tt-at", refresh_token: "tt-rt", expires_in: 86400, open_id: "op1", scope: "video.publish" }), identify: [["GET", "https://open.tiktokapis.com/v2/user/info/", json({ data: { user: { open_id: "op1", display_name: "Acme", username: "acme", follower_count: 50 } }, error: { code: "ok" } })]], }); assert.equal(conn.account.username, "acme"); assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: {} }), /who can view is required/); route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } })); await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "PUBLIC_TO_EVERYONE" } } }), /can't post as/); route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ data: { publish_id: "pub1", upload_url: "https://upload.tiktok.test/u1" }, error: { code: "ok" } })); route("PUT", "https://upload.tiktok.test/u1", new Response("", { status: 201 })); route("POST", "https://open.tiktokapis.com/v2/post/publish/status/fetch/", json({ data: { status: "PUBLISH_COMPLETE", publicaly_available_post_id: [7001] }, error: { code: "ok" } })); const r = await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "Our kitchen", options: { tiktok: { privacy: "SELF_ONLY" } } }); assert.equal(r.permalink, "https://www.tiktok.com/@acme/video/7001"); const init = bodyJson(sent(/video\/init/)[0]); assert.equal(init.source_info.total_chunk_count, 1); assert.equal(init.post_info.privacy_level, "SELF_ONLY"); assert.equal(sent(/upload\.tiktok/)[0].headers["Content-Range"], "bytes 0-1023/1024"); route("POST", "https://open.tiktokapis.com/v2/post/publish/content/init/", json({ data: { publish_id: "pub2" }, error: { code: "ok" } })); await P.publish("tiktok", "acme", { type: "CAROUSEL", media: [IMG, IMG2], caption: "Menu", options: { tiktok: { privacy: "SELF_ONLY", title: "New menu" } } }); const photo = bodyJson(sent(/content\/init/)[0]); assert.equal(photo.media_type, "PHOTO"); assert.equal(photo.source_info.photo_images.length, 2); assert.ok(photo.source_info.photo_images.every((u) => u.startsWith("https://app.example.test/m/"))); }); test("Pinterest: boards, base64 image Pin to the chosen board", async () => { reset(); const { conn } = await connect("pinterest", "acme", { identify: [ ["GET", "https://api.pinterest.com/v5/user_account", json({ username: "acmepins", business_name: "Acme", follower_count: 300 })], ["GET", "https://api.pinterest.com/v5/boards", json({ items: [{ id: "b1", name: "Cakes" }, { id: "b2", name: "Bread" }] })], ] }); assert.equal(calls.find((c) => c.url.startsWith("https://api.pinterest.com/v5/oauth/token")).headers.Authorization.startsWith("Basic "), true, "Pinterest uses Basic auth for tokens"); assert.equal(vault.getConnection(conn.id).target, null, "two boards: nothing auto-picked"); assert.throws(() => P.validate("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "x", options: {} }), /board is required/); route("POST", "https://api.pinterest.com/v5/pins", json({ id: "pin9" })); const r = await P.publish("pinterest", "acme", { type: "IMAGE", media: [IMG], caption: "Birthday cakes", options: { pinterest: { boardId: "b1", title: "Cakes", link: "https://acme.test" } } }); assert.equal(r.permalink, "https://www.pinterest.com/pin/pin9/"); const pin = bodyJson(sent(/v5\/pins/)[0]); assert.equal(pin.board_id, "b1"); assert.equal(pin.media_source.source_type, "image_base64"); assert.equal(pin.media_source.data, fs.readFileSync(IMG).toString("base64")); }); test("Threads: long-lived token exchange, text and image posts", async () => { reset(); route("GET", "https://graph.threads.net/access_token", json({ access_token: "th-long", expires_in: 5184000 })); const { conn } = await connect("threads", "acme", { tokenReply: json({ access_token: "th-short", user_id: 555 }), identify: [ ["GET", "https://graph.threads.net/v1.0/me", json({ id: "555", username: "acme" })], ["GET", "https://graph.threads.net/v1.0/555/threads_insights", json({ data: [{ total_value: { value: 88 } }] })], ], }); assert.equal(vault.getConnection(conn.id, { withTokens: true }).tokens.accessToken, "th-long"); assert.equal(conn.account.followers, 88); route("POST", "https://graph.threads.net/v1.0/555/threads?", json({ id: "c1" })); route("GET", "https://graph.threads.net/v1.0/c1", json({ status: "FINISHED" })); route("POST", "https://graph.threads.net/v1.0/555/threads_publish", json({ id: "t1" })); route("GET", "https://graph.threads.net/v1.0/t1", json({ permalink: "https://threads.net/@acme/post/t1" })); const r = await P.publish("threads", "acme", { type: "IMAGE", media: [IMG], caption: "Hello" }); assert.equal(r.permalink, "https://threads.net/@acme/post/t1"); const c = new URL(sent(/555\/threads\?/)[0].url); assert.equal(c.searchParams.get("media_type"), "IMAGE"); assert.match(c.searchParams.get("image_url"), /^https:\/\/app\.example\.test\/m\//); assert.throws(() => P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: {} }), /limit is 500/); P.validate("threads", "acme", { type: "TEXT", media: [], caption: "x".repeat(501), options: { threads: { caption: "short" } } }); }); test("Reddit: text post needs subreddit and title, sends User-Agent", async () => { reset(); await connect("reddit", "acme", { identify: [["GET", "https://oauth.reddit.com/api/v1/me", json({ id: "r1", name: "acme_bakery", total_karma: 12 })]] }); assert.throws(() => P.validate("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "waterloo" } } }), /post title is required/); route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [], data: { name: "t3_abc", url: "https://reddit.com/r/waterloo/abc" } } })); const r = await P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "We're open late", options: { reddit: { subreddit: "r/waterloo", title: "Late hours" } } }); assert.equal(r.permalink, "https://reddit.com/r/waterloo/abc"); const sub = sent(/api\/submit/)[0]; assert.deepEqual({ sr: form(sub).sr, kind: form(sub).kind, text: form(sub).text }, { sr: "waterloo", kind: "self", text: "We're open late" }); assert.ok(sub.headers["User-Agent"]); route("POST", "https://oauth.reddit.com/api/submit", json({ json: { errors: [["SUBREDDIT_NOTALLOWED", "you aren't allowed to post there.", "sr"]] } })); routes = routes.filter((x, i, a) => !(x.match === "https://oauth.reddit.com/api/submit" && a.findIndex((y) => y.match === x.match) === i)); await assert.rejects(P.publish("reddit", "acme", { type: "TEXT", media: [], caption: "x", options: { reddit: { subreddit: "x", title: "t" } } }), /aren't allowed to post/); }); test("expired tokens are refreshed and saved; failed refresh asks to reconnect", async () => { reset(); const c = vault.connectionFor("acme", "youtube"); vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "yt-rt", expiresAt: new Date(Date.now() - 1000).toISOString() } }); route("POST", "https://oauth2.googleapis.com/token", json({ access_token: "new-at", expires_in: 3600 })); const { tokens } = await P.tokensFor(c.id); assert.equal(tokens.accessToken, "new-at"); assert.equal(tokens.refreshToken, "yt-rt", "refresh token kept when the platform doesn't send a new one"); assert.equal(form(sent(/oauth2\.googleapis/)[0]).grant_type, "refresh_token"); reset(); vault.updateConnection(c.id, { tokens: { accessToken: "old", refreshToken: "bad", expiresAt: new Date(Date.now() - 1000).toISOString() } }); route("POST", "https://oauth2.googleapis.com/token", json({ error: "invalid_grant", error_description: "Token has been revoked." }, 400)); await assert.rejects(P.tokensFor(c.id), /Reconnect it/); assert.equal(vault.getConnection(c.id).status, "reconnect"); }); test("posting to an unconnected platform is refused clearly", () => { assert.throws(() => P.validate("linkedin", "nobody", { type: "TEXT", media: [], caption: "x", options: {} }), /isn't connected for this client/); });