Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723import { describe, it } from "node:test";import assert from "node:assert/strict";import { PluginRequests, MAX_RESPONSE_BYTES,} from "/js/plugins/pluginRequests.js";import { ApiError } from "/js/api.js";import { Permissions } from "/js/plugins/pluginPermissions.js";
function pluginFetch(permissions, url, init, fetchImpl) { return new PluginRequests({ fetchImpl }).pluginFetch(permissions, url, init);}
function makePermissions(patterns) { return Permissions.parse({ fetch: patterns });}
function makeFakeFetch({ status = 200, body = "", headers = {} } = {}) { const calls = []; const fakeFetch = async (url, init) => { calls.push({ url, init }); return { status, ok: status >= 200 && status < 300, headers: { get: (name) => headers[name.toLowerCase()] ?? null, }, arrayBuffer: async () => new TextEncoder().encode(body).buffer, }; }; return { fakeFetch, calls };}
// pluginFetch relays the response body as raw bytes so it can carry binary// payloads, not just text - tests that care about the actual body content// decode it back rather than comparing against a string.function decodeBody(bodyBuffer) { return new TextDecoder().decode(bodyBuffer);}
const queryTestPlugin = { pluginId: "test-plugin" };
function makeXrpcPluginRequests({ request = async () => ({ status: 200, data: {} }), session = { did: "did:plc:me", handle: "me.test" }, requireActionPermission = () => {},} = {}) { const requestCalls = []; const permissionCalls = []; const dataLayer = { api: { appViewRequest: async (path, options) => { requestCalls.push({ path, options }); return request(path, options); }, }, }; const permissionsManager = { requireActionPermission: (plugin, action) => { permissionCalls.push({ plugin, action }); return requireActionPermission(plugin, action); }, }; return { pluginRequests: new PluginRequests({ dataLayer, session, permissionsManager, }), requestCalls, permissionCalls, };}
async function expectRejection(fn, includes) { let threw = false; try { await fn(); } catch (error) { threw = true; if (includes) { assert( error.message.toLowerCase().includes(includes.toLowerCase()), `expected error to include "${includes}", got "${error.message}"`, ); } } assert(threw, "expected promise to reject");}
describe("allowlist - scheme", () => { it("rejects http URLs even if pattern matches host", async () => { const { fakeFetch, calls } = makeFakeFetch(); await expectRejection(() => pluginFetch( makePermissions(["https://example.com/*"]), "http://example.com/foo", {}, fakeFetch, ), ); assert.deepEqual(calls.length, 0); });
it("rejects http patterns even with https URL", async () => { const { fakeFetch } = makeFakeFetch(); await expectRejection(() => pluginFetch( makePermissions(["http://example.com/*"]), "https://example.com/x", {}, fakeFetch, ), ); });});
describe("allowlist - host matching", () => { it("allows exact host + path match", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://example.com/things"]), "https://example.com/things", {}, fakeFetch, ); assert.deepEqual(calls.length, 1); });
it("rejects a different host", async () => { const { fakeFetch } = makeFakeFetch(); await expectRejection(() => pluginFetch( makePermissions(["https://example.com/things"]), "https://evil.com/things", {}, fakeFetch, ), ); });
it("rejects a subdomain when pattern has no wildcard", async () => { const { fakeFetch } = makeFakeFetch(); await expectRejection(() => pluginFetch( makePermissions(["https://example.com/*"]), "https://api.example.com/things", {}, fakeFetch, ), ); });
it("is case-insensitive on host", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://example.com/things"]), "https://Example.COM/things", {}, fakeFetch, ); assert.deepEqual(calls.length, 1); });
it("matches *.host on the bare domain", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://*.example.com/*"]), "https://example.com/foo", {}, fakeFetch, ); assert.deepEqual(calls.length, 1); });
it("matches *.host on a subdomain", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://*.example.com/*"]), "https://api.example.com/foo", {}, fakeFetch, ); assert.deepEqual(calls.length, 1); });
it("does not match an unrelated suffix that happens to end in the domain", async () => { const { fakeFetch } = makeFakeFetch(); await expectRejection(() => pluginFetch( makePermissions(["https://*.example.com/*"]), "https://notexample.com/foo", {}, fakeFetch, ), ); });
it("is not fooled by userinfo confusion", async () => { const { fakeFetch } = makeFakeFetch(); await expectRejection(() => pluginFetch( makePermissions(["https://example.com/*"]), "https://example.com@evil.com/x", {}, fakeFetch, ), ); });});
describe("allowlist - path matching", () => { it("matches by prefix when path ends with *", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://example.com/v1/*"]), "https://example.com/v1/items/42", {}, fakeFetch, ); assert.deepEqual(calls.length, 1); });
it("requires exact path when no trailing *", async () => { const { fakeFetch } = makeFakeFetch(); await expectRejection(() => pluginFetch( makePermissions(["https://example.com/v1"]), "https://example.com/v1/items", {}, fakeFetch, ), ); });
it("rejects when the granted permissions have no fetch patterns", async () => { const { fakeFetch, calls } = makeFakeFetch(); await expectRejection(() => pluginFetch( Permissions.parse({}), "https://api.example.com/x", {}, fakeFetch, ), ); assert.deepEqual(calls.length, 0); });});
describe("safe fetch options", () => { it("forces credentials=omit and redirect=error", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", {}, fakeFetch, ); assert.deepEqual(calls[0].init.credentials, "omit"); assert.deepEqual(calls[0].init.redirect, "error"); assert.deepEqual(calls[0].init.referrerPolicy, "no-referrer"); });
it("defaults method to GET", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", {}, fakeFetch, ); assert.deepEqual(calls[0].init.method, "GET"); });
it("passes through allowed methods uppercased", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", { method: "post" }, fakeFetch, ); assert.deepEqual(calls[0].init.method, "POST"); });
it("rejects disallowed methods", async () => { const { fakeFetch, calls } = makeFakeFetch(); await expectRejection( () => pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", { method: "CONNECT" }, fakeFetch, ), "method", ); assert.deepEqual(calls.length, 0); });});
describe("header handling", () => { it("forwards allowed headers", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", { headers: { "X-Custom": "v" } }, fakeFetch, ); assert.deepEqual(calls[0].init.headers["X-Custom"], "v"); });
it("rejects forbidden headers (any casing)", async () => { const { fakeFetch, calls } = makeFakeFetch(); await expectRejection( () => pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", { headers: { Cookie: "session=abc" } }, fakeFetch, ), "header", ); assert.deepEqual(calls.length, 0); });});
describe("body handling", () => { it("forwards a string body", async () => { const { fakeFetch, calls } = makeFakeFetch(); await pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", { method: "POST", body: '{"a":1}' }, fakeFetch, ); assert.deepEqual(calls[0].init.body, '{"a":1}'); });
it("rejects non-string body", async () => { const { fakeFetch } = makeFakeFetch(); await expectRejection(() => pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", { method: "POST", body: { a: 1 } }, fakeFetch, ), ); });});
describe("response shape", () => { it("returns picked headers only", async () => { const { fakeFetch } = makeFakeFetch({ headers: { "content-type": "application/json", "set-cookie": "x=1" }, body: '{"a":1}', }); const result = await pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", {}, fakeFetch, ); assert.deepEqual(result.headers["content-type"], "application/json"); assert(result.headers["set-cookie"] === undefined); });
it("exposes status and ok", async () => { const { fakeFetch } = makeFakeFetch({ status: 404, body: "nope" }); const result = await pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", {}, fakeFetch, ); assert.deepEqual(result.status, 404); assert.deepEqual(result.ok, false); assert.deepEqual(decodeBody(result.body), "nope"); });
it("relays binary bytes intact", async () => { const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0xfe]); const fakeFetch = async () => ({ status: 200, ok: true, headers: { get: () => null }, arrayBuffer: async () => bytes.buffer, }); const result = await pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", {}, fakeFetch, ); assert(result.body instanceof ArrayBuffer); assert.deepEqual([...new Uint8Array(result.body)], [...bytes]); });});
describe("response size", () => { it("rejects a response over the byte cap", async () => { // The cap check only reads .byteLength before any bytes are touched, so // this fakes an over-limit length without actually allocating that much // memory in the test. const fakeFetch = async () => ({ status: 200, ok: true, headers: { get: () => null }, arrayBuffer: async () => ({ byteLength: MAX_RESPONSE_BYTES + 1 }), }); await expectRejection( () => pluginFetch( makePermissions(["https://api.example.com/*"]), "https://api.example.com/x", {}, fakeFetch, ), "too large", ); });});
describe("pluginXrpcRequest", () => { const queryPlugin = { pluginId: "test-plugin" };
it("rejects a query that is not allowlisted without making a request", async () => { const { pluginRequests, requestCalls } = makeXrpcPluginRequests(); await assert.rejects( pluginRequests.pluginXrpcRequest( queryPlugin, "chat.bsky.convo.getMessages", {}, ), /not an allowed query/, ); await assert.rejects( pluginRequests.pluginXrpcRequest( queryPlugin, "com.atproto.repo.deleteRecord", {}, ), /not an allowed query/, ); assert.deepEqual(requestCalls, []); });
it("performs an allowlisted public query with params, without a permission check", async () => { const data = { posts: [{ uri: "at://quote/1" }] }; const { pluginRequests, requestCalls, permissionCalls } = makeXrpcPluginRequests({ request: async () => ({ status: 200, data }), }); const result = await pluginRequests.pluginXrpcRequest( queryPlugin, "app.bsky.feed.getQuotes", { uri: "at://example/post/1", limit: 25 }, ); assert.deepEqual(result, { ok: true, status: 200, data }); assert.deepEqual(requestCalls.length, 1); assert.deepEqual(requestCalls[0].path, "app.bsky.feed.getQuotes"); assert.deepEqual(requestCalls[0].options.query, { uri: "at://example/post/1", limit: 25, }); assert.deepEqual(permissionCalls, []); });
it("requires the privateData permission for private queries", async () => { const { pluginRequests, requestCalls, permissionCalls } = makeXrpcPluginRequests({ requireActionPermission: () => { throw new Error( '"test-plugin" does not have "privateData" action permission', ); }, }); await assert.rejects( pluginRequests.pluginXrpcRequest( queryPlugin, "app.bsky.graph.getMutes", {}, ), /"privateData" action permission/, ); assert.deepEqual(permissionCalls, [ { plugin: queryPlugin, action: "privateData" }, ]); assert.deepEqual(requestCalls, []); });
it("performs a private query when the permission is granted", async () => { const data = { mutes: [] }; const { pluginRequests, requestCalls, permissionCalls } = makeXrpcPluginRequests({ request: async () => ({ status: 200, data }), }); const result = await pluginRequests.pluginXrpcRequest( queryPlugin, "app.bsky.graph.getMutes", {}, ); assert.deepEqual(result, { ok: true, status: 200, data }); assert.deepEqual(permissionCalls, [ { plugin: queryPlugin, action: "privateData" }, ]); assert.deepEqual(requestCalls.length, 1); });
it("rejects private queries when signed out", async () => { const { pluginRequests, requestCalls, permissionCalls } = makeXrpcPluginRequests({ session: null }); await assert.rejects( pluginRequests.pluginXrpcRequest( queryPlugin, "app.bsky.graph.getMutes", {}, ), /Not signed in/, ); assert.deepEqual(permissionCalls, []); assert.deepEqual(requestCalls, []); });
it("returns ok:false with the error body on an ApiError", async () => { const { pluginRequests } = makeXrpcPluginRequests({ request: async () => { throw new ApiError({ status: 400, statusText: "Bad Request", data: { error: "InvalidRequest", message: "bad uri" }, headers: null, url: "", }); }, }); const result = await pluginRequests.pluginXrpcRequest( queryPlugin, "app.bsky.feed.getQuotes", { uri: "nope" }, ); assert.deepEqual(result, { ok: false, status: 400, data: { error: "InvalidRequest", message: "bad uri" }, }); });
it("rejects with a sanitized error on non-API failures", async () => { const { pluginRequests } = makeXrpcPluginRequests({ request: async () => { throw new TypeError("secret internal detail"); }, }); await assert.rejects( pluginRequests.pluginXrpcRequest( queryPlugin, "app.bsky.feed.getQuotes", {}, ), (error) => { assert(!error.message.includes("secret internal detail")); assert( /request to "app\.bsky\.feed\.getQuotes" failed/.test(error.message), ); return true; }, ); });
it("rejects unsupported param values before making a request", async () => { const { pluginRequests, requestCalls } = makeXrpcPluginRequests(); await assert.rejects( pluginRequests.pluginXrpcRequest(queryPlugin, "app.bsky.feed.getQuotes", { uri: { toString: () => "sneaky" }, }), /unsupported type/, ); assert.deepEqual(requestCalls, []); });});
describe("xrpc query allowlist (via pluginXrpcRequest)", () => { it("classifies public queries as callable without a permission check", async () => { for (const nsid of [ "app.bsky.feed.getQuotes", "app.bsky.graph.getLists", "app.bsky.actor.getProfile", ]) { const { pluginRequests, requestCalls, permissionCalls } = makeXrpcPluginRequests(); await pluginRequests.pluginXrpcRequest(queryTestPlugin, nsid, {}); assert.deepEqual(requestCalls.length, 1, nsid); assert.deepEqual(permissionCalls, [], nsid); } });
it("classifies private queries as permission-gated", async () => { for (const nsid of [ "app.bsky.graph.getMutes", "app.bsky.notification.listNotifications", "app.bsky.feed.getTimeline", ]) { const { pluginRequests, requestCalls, permissionCalls } = makeXrpcPluginRequests(); await pluginRequests.pluginXrpcRequest(queryTestPlugin, nsid, {}); assert.deepEqual(requestCalls.length, 1, nsid); assert.deepEqual( permissionCalls, [{ plugin: queryTestPlugin, action: "privateData" }], nsid, ); } });
it("refuses everything not allowlisted", async () => { for (const nsid of [ "chat.bsky.convo.getMessages", "chat.bsky.convo.listConvos", "app.bsky.draft.getDrafts", "com.atproto.repo.createRecord", "app.bsky.feed.sendInteractions", // Would let the AppView forward the user's service auth to a // plugin-chosen feed generator "app.bsky.feed.getFeed", "", ]) { const { pluginRequests, requestCalls } = makeXrpcPluginRequests(); await assert.rejects( pluginRequests.pluginXrpcRequest(queryTestPlugin, nsid, {}), /not an allowed query/, nsid, ); assert.deepEqual(requestCalls, [], nsid); } });});
describe("xrpc param sanitization (via pluginXrpcRequest)", () => { const NSID = "app.bsky.feed.getQuotes";
async function queryWith(params) { const { pluginRequests, requestCalls } = makeXrpcPluginRequests(); await pluginRequests.pluginXrpcRequest(queryTestPlugin, NSID, params); return requestCalls[0].options.query; }
it("passes through scalar and string-array values", async () => { assert.deepEqual( await queryWith({ uri: "at://example/post/1", limit: 25, includePins: true, uris: ["at://a", "at://b"], }), { uri: "at://example/post/1", limit: 25, includePins: true, uris: ["at://a", "at://b"], }, ); });
it("sends an empty query for missing params", async () => { assert.deepEqual(await queryWith(null), {}); assert.deepEqual(await queryWith(undefined), {}); });
it("drops null and undefined values", async () => { assert.deepEqual(await queryWith({ cursor: null, limit: 5 }), { limit: 5 }); });
it("rejects non-object params", async () => { const { pluginRequests, requestCalls } = makeXrpcPluginRequests(); await assert.rejects( pluginRequests.pluginXrpcRequest(queryTestPlugin, NSID, "uri=x"), /must be an object/, ); await assert.rejects( pluginRequests.pluginXrpcRequest(queryTestPlugin, NSID, ["uri"]), /must be an object/, ); assert.deepEqual(requestCalls, []); });
it("rejects object and function values", async () => { const { pluginRequests } = makeXrpcPluginRequests(); await assert.rejects( pluginRequests.pluginXrpcRequest(queryTestPlugin, NSID, { uri: { toString: () => "x" }, }), /unsupported type/, ); await assert.rejects( pluginRequests.pluginXrpcRequest(queryTestPlugin, NSID, { uris: [() => "x"], }), /unsupported type/, ); });
it("rejects oversized values", async () => { const { pluginRequests } = makeXrpcPluginRequests(); await assert.rejects( pluginRequests.pluginXrpcRequest(queryTestPlugin, NSID, { q: "x".repeat(2001), }), /too long/, ); const tooMany = Object.fromEntries( Array.from({ length: 21 }, (_, index) => [`p${index}`, "x"]), ); await assert.rejects( pluginRequests.pluginXrpcRequest(queryTestPlugin, NSID, tooMany), /too many params/, ); });});