~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
215 lines · 7.7 KB Raw History
import { afterEach, describe, expect, test, vi } from "vitest";
import { getSpaceSupport, probeSpaceSupport } from "../server/helpers/spaceSupport.ts";

const PDS = "https://pds.example.com";

/**
 * The method list a pds.js deployment with PDS_ENABLE_SPACES answers with,
 * trimmed to the space methods. Captured from pds.chad-53c.workers.dev.
 */
const SPACE_METHODS = [
  "com.atproto.simplespace.addMember",
  "com.atproto.simplespace.createSpace",
  "com.atproto.simplespace.deleteSpace",
  "com.atproto.simplespace.getSpace",
  "com.atproto.simplespace.listMembers",
  "com.atproto.simplespace.removeMember",
  "com.atproto.simplespace.updateSpace",
  "com.atproto.space.applyWrites",
  "com.atproto.space.createRecord",
  "com.atproto.space.deleteRecord",
  "com.atproto.space.getBlob",
  "com.atproto.space.getDelegationToken",
  "com.atproto.space.getLatestCommit",
  "com.atproto.space.getRecord",
  "com.atproto.space.getRepo",
  "com.atproto.space.getSpaceCredential",
  "com.atproto.space.listRecords",
  "com.atproto.space.listRepos",
  "com.atproto.space.putRecord",
  "com.atproto.space.registerNotify",
];

/** Stand in for a describe response listing `methods`. */
function describeResponse(methods: string[]) {
  return new Response(
    JSON.stringify({
      roles: ["pds"],
      methods: methods.map((value) => ({
        $type: "community.lexicon.service.describe#nsid",
        value,
      })),
    }),
    { status: 200, headers: { "content-type": "application/json" } },
  );
}

function stubFetch(response: Response | Error) {
  const fn = vi.fn(async (url: string | URL | Request) => {
    expect(String(url)).toBe(`${PDS}/xrpc/community.lexicon.service.describe`);
    if (response instanceof Error) throw response;
    return response;
  });
  vi.stubGlobal("fetch", fn);
  return fn;
}

afterEach(() => {
  vi.unstubAllGlobals();
});

describe("probeSpaceSupport", () => {
  test("a spaces-enabled PDS is supported", async () => {
    stubFetch(describeResponse(SPACE_METHODS));

    const result = await probeSpaceSupport(PDS);

    expect(result.supported).toBe(true);
    expect(result.missing).toEqual([]);
    expect(result.pds).toBe(PDS);
  });

  test("accepts the reference alpha's names, which have no simplespace", async () => {
    const alpha = SPACE_METHODS.filter((m) => !m.startsWith("com.atproto.simplespace.")).concat(
      "com.atproto.space.createSpace",
      "com.atproto.space.getSpace",
      "com.atproto.memberspace.putMember",
      "com.atproto.memberspace.listMembers",
    );
    stubFetch(describeResponse(alpha));

    expect(await probeSpaceSupport(PDS)).toMatchObject({ supported: true, missing: [] });
  });

  test("accepts either spelling of getSpace", async () => {
    // The proposal renamed things after implementations shipped, so a server
    // may answer to com.atproto.space.getSpace instead.
    const renamed = SPACE_METHODS.filter((m) => m !== "com.atproto.simplespace.getSpace").concat(
      "com.atproto.space.getSpace",
    );
    stubFetch(describeResponse(renamed));

    expect((await probeSpaceSupport(PDS)).supported).toBe(true);
  });

  test("names the methods a partial implementation is missing", async () => {
    stubFetch(
      describeResponse(
        SPACE_METHODS.filter(
          (m) => m !== "com.atproto.space.getBlob" && m !== "com.atproto.space.getSpaceCredential",
        ),
      ),
    );

    const result = await probeSpaceSupport(PDS);

    expect(result.supported).toBe(false);
    expect(result.missing).toEqual([
      "com.atproto.space.getSpaceCredential",
      "com.atproto.space.getBlob",
    ]);
  });

  test("a PDS with no space methods is unsupported", async () => {
    stubFetch(describeResponse(["com.atproto.repo.createRecord", "com.atproto.sync.getBlob"]));

    const result = await probeSpaceSupport(PDS);

    expect(result.supported).toBe(false);
    expect(result.missing).toHaveLength(10);
  });

  test("no describe endpoint reads as unsupported, not as an error", async () => {
    // What every PDS that predates the proposal does, bsky.social included.
    stubFetch(new Response("not found", { status: 404 }));

    const result = await probeSpaceSupport(PDS);

    expect(result.supported).toBe(false);
    expect(result.missing).toContain("com.atproto.simplespace.createSpace");
  });

  test("an unreachable PDS reads as unsupported", async () => {
    stubFetch(new TypeError("fetch failed"));

    await expect(probeSpaceSupport(PDS)).resolves.toMatchObject({ supported: false });
  });

  test("a describe answer that isn't one reads as unsupported", async () => {
    stubFetch(new Response("<html>hello</html>", { status: 200 }));

    await expect(probeSpaceSupport(PDS)).resolves.toMatchObject({ supported: false });
  });
});

/** The one row of _space_support this helper reads and writes, in memory. */
function fakeDb(row?: { supported: number; missing: string; checked_at: string }) {
  let stored = row;
  return {
    query: async () => (stored ? [{ ...stored }] : []),
    run: async (_sql: string, params?: unknown[]) => {
      const [, supported, missing, checkedAt] = params as [string, number, string, string];
      stored = { supported, missing, checked_at: checkedAt };
    },
    get stored() {
      return stored;
    },
  };
}

const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
const YES = { supported: 1, missing: "[]", checked_at: ago(0) };
const NO = { supported: 0, missing: '["com.atproto.simplespace.createSpace"]', checked_at: ago(0) };

describe("getSpaceSupport caching", () => {
  test("a fresh answer is served from cache without probing", async () => {
    const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
    const db = fakeDb({ ...YES, checked_at: ago(60 * 1000) });

    await expect(getSpaceSupport(db, PDS)).resolves.toMatchObject({ supported: true });
    expect(fetchFn).not.toHaveBeenCalled();
  });

  test("force probes again even when the cached answer is fresh", async () => {
    const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
    const db = fakeDb({ ...NO, checked_at: ago(1000) });

    // The recourse for a viewer whose server has just gained spaces: the thing
    // that would change the answer has already happened, and without this they
    // wait out a cache for it.
    await expect(getSpaceSupport(db, PDS, { force: true })).resolves.toMatchObject({
      supported: true,
    });
    expect(fetchFn).toHaveBeenCalledOnce();
  });

  test("a no is re-probed long before a yes would be", async () => {
    // Ten minutes: past the window a no is held for, nowhere near a yes's.
    const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
    const db = fakeDb({ ...NO, checked_at: ago(10 * 60 * 1000) });

    await expect(getSpaceSupport(db, PDS)).resolves.toMatchObject({ supported: true });
    expect(fetchFn).toHaveBeenCalledOnce();

    // And the fresh yes is what gets written back.
    expect(db.stored?.supported).toBe(1);
  });

  test("a yes that old is still trusted", async () => {
    const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
    const db = fakeDb({ ...YES, checked_at: ago(10 * 60 * 1000) });

    await expect(getSpaceSupport(db, PDS)).resolves.toMatchObject({ supported: true });
    expect(fetchFn).not.toHaveBeenCalled();
  });

  test("a probe that could not reach the server does not outlive the minute it failed in", async () => {
    // The case that stranded a real account: a restart mid-probe, cached as
    // though the server had answered and said no.
    const fetchFn = stubFetch(describeResponse(SPACE_METHODS));
    const db = fakeDb({ supported: 0, missing: "[]", checked_at: ago(6 * 60 * 1000) });

    await expect(getSpaceSupport(db, PDS)).resolves.toMatchObject({ supported: true });
    expect(fetchFn).toHaveBeenCalledOnce();
  });
});