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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
264 lines · 10.8 KB Raw History
// deleteGallery and deleteAccount. Both work by asking the user's PDS to delete
// records; the appview only follows along. That makes them untestable through
// `server.fetch` — a real PDS write needs OAuth config the harness has none of,
// and the request 500s before reaching anything interesting.
//
// `defineProcedure` returns its handler untouched, the same as `defineHook`, so
// these call it directly with a real database and a `deleteRecord` that records
// what would have been asked for. What is being checked is *which* records each
// one decides to delete, and in what order — which is the whole of the logic.

import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { startTestServer } from "@hatk/hatk/test";
import deleteAccount from "../server/xrpc/deleteAccount.ts";
import deleteGallery from "../server/xrpc/deleteGallery.ts";

const ALICE = "did:plc:alice";
const BOB = "did:plc:bob";

const GA = `at://${ALICE}/social.grain.gallery/ga`; // Alice's, with everything hanging off it
const GB = `at://${ALICE}/social.grain.gallery/gb`; // Alice's, untouched by the tests
const GBOB = `at://${BOB}/social.grain.gallery/gbob`; // not Alice's

let server: any;

/** Runs a procedure and reports the deleteRecord calls it made. */
async function run(
  proc: { handler: (ctx: any) => Promise<unknown> },
  input: unknown,
  opts: { viewer?: string; failOn?: (collection: string, rkey: string) => boolean } = {},
) {
  const deleted: Array<{ collection: string; rkey: string }> = [];
  const ctx = {
    db: server.db,
    input,
    viewer: opts.viewer === undefined ? { did: ALICE } : { did: opts.viewer },
    deleteRecord: async (collection: string, rkey: string) => {
      if (opts.failOn?.(collection, rkey)) throw new Error("PDS refused");
      deleted.push({ collection, rkey });
    },
    ok: (v: unknown) => v,
  };
  await proc.handler(ctx);
  return deleted;
}

const rkeysFor = (deleted: Array<{ collection: string; rkey: string }>, collection: string) =>
  deleted.filter((d) => d.collection === collection).map((d) => d.rkey);

beforeAll(async () => {
  server = await startTestServer();
  const { db } = server;

  await db.run(
    `CREATE TABLE IF NOT EXISTS _mutes (
       did TEXT NOT NULL, subject TEXT NOT NULL, created_at TEXT NOT NULL,
       PRIMARY KEY (did, subject)
     )`,
  );

  for (const [did, handle] of [
    [ALICE, "alice.test"],
    [BOB, "bob.test"],
  ]) {
    await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, 'active', $2)`, [
      did,
      handle,
    ]);
  }

  for (const [uri, did, id] of [
    [GA, ALICE, "ga"],
    [GB, ALICE, "gb"],
    [GBOB, BOB, "gbob"],
  ] as [string, string, string][]) {
    await db.run(
      `INSERT INTO "social.grain.gallery" (uri, cid, did, indexed_at, title, created_at)
       VALUES ($1, $2, $3, 'i', $4, '2026-01-01')`,
      [uri, `cid-${id}`, did, id],
    );
  }

  // Two photos in GA, one of them with an EXIF record.
  for (const [id, withExif] of [
    ["p1", true],
    ["p2", false],
  ] as [string, boolean][]) {
    const photoUri = `at://${ALICE}/social.grain.photo/${id}`;
    await db.run(
      `INSERT INTO "social.grain.photo" (uri, cid, did, indexed_at, photo, aspect_ratio, created_at)
       VALUES ($1, $2, $3, 'i', $4, '{"width":3,"height":2}', '2026-01-01')`,
      [
        photoUri,
        `cid-${id}`,
        ALICE,
        JSON.stringify({
          $type: "blob",
          ref: { $link: `bafy-${id}` },
          mimeType: "image/jpeg",
          size: 1,
        }),
      ],
    );
    await db.run(
      `INSERT INTO "social.grain.gallery.item" (uri, cid, did, indexed_at, created_at, gallery, item, position)
       VALUES ($1, $2, $3, 'i', '2026-01-01', $4, $5, 0)`,
      [`at://${ALICE}/social.grain.gallery.item/i-${id}`, `cid-i-${id}`, ALICE, GA, photoUri],
    );
    if (withExif) {
      await db.run(
        `INSERT INTO "social.grain.photo.exif" (uri, cid, did, indexed_at, photo, created_at, make, model)
         VALUES ($1, $2, $3, 'i', $4, '2026-01-01', 'Ricoh', 'GR III')`,
        [`at://${ALICE}/social.grain.photo.exif/e-${id}`, `cid-e-${id}`, ALICE, photoUri],
      );
    }
  }

  // Bob favorited and commented on Alice's gallery, and Alice favorited her own.
  await db.run(
    `INSERT INTO "social.grain.favorite" (uri, cid, did, indexed_at, created_at, subject)
     VALUES ($1, 'cid-fb', $2, 'i', '2026-01-02', $3)`,
    [`at://${BOB}/social.grain.favorite/fb`, BOB, GA],
  );
  await db.run(
    `INSERT INTO "social.grain.favorite" (uri, cid, did, indexed_at, created_at, subject)
     VALUES ($1, 'cid-fa', $2, 'i', '2026-01-03', $3)`,
    [`at://${ALICE}/social.grain.favorite/fa`, ALICE, GA],
  );
  await db.run(
    `INSERT INTO "social.grain.comment" (uri, cid, did, indexed_at, created_at, subject, text)
     VALUES ($1, 'cid-cb', $2, 'i', '2026-01-04', $3, 'nice')`,
    [`at://${BOB}/social.grain.comment/cb`, BOB, GA],
  );

  // State that deleteAccount is meant to clear, and state it is meant to keep.
  await db.run(`INSERT INTO _mutes (did, subject, created_at) VALUES ($1, $2, 'i')`, [ALICE, BOB]);
  await db.run(`INSERT INTO _preferences (did, key, value) VALUES ($1, 'x', '1')`, [ALICE]);
  await db.run(`INSERT INTO _mutes (did, subject, created_at) VALUES ($1, $2, 'i')`, [BOB, ALICE]);
  await db.run(`INSERT INTO _preferences (did, key, value) VALUES ($1, 'x', '1')`, [BOB]);
  await db.run(
    `INSERT INTO _oauth_sessions (did, pds_endpoint, access_token, dpop_jkt)
     VALUES ($1, 'https://pds.example', 'tok', 'jkt')`,
    [ALICE],
  );
});

afterAll(async () => await server?.close());

describe("deleteGallery", () => {
  test("deletes the gallery and everything hanging off it", async () => {
    const deleted = await run(deleteGallery, { rkey: "ga" });
    expect(rkeysFor(deleted, "social.grain.gallery")).toEqual(["ga"]);
    expect(rkeysFor(deleted, "social.grain.photo").sort()).toEqual(["p1", "p2"]);
    expect(rkeysFor(deleted, "social.grain.gallery.item").sort()).toEqual(["i-p1", "i-p2"]);
    expect(rkeysFor(deleted, "social.grain.photo.exif")).toEqual(["e-p1"]);
  });

  test("deletes other people's favorites and comments on it too", async () => {
    // They live in their authors' repos, so the delete will usually fail — but
    // it is attempted, because leaving them behind orphans them in the index.
    const deleted = await run(deleteGallery, { rkey: "ga" });
    expect(rkeysFor(deleted, "social.grain.favorite").sort()).toEqual(["fa", "fb"]);
    expect(rkeysFor(deleted, "social.grain.comment")).toEqual(["cb"]);
  });

  test("deletes the gallery last, after its contents", async () => {
    // An orphaned item visible in the appview between deletes is worse than a
    // gallery that briefly has nothing in it.
    const deleted = await run(deleteGallery, { rkey: "ga" });
    const galleryAt = deleted.findIndex((d) => d.collection === "social.grain.gallery");
    expect(galleryAt).toBe(deleted.length - 1);
  });

  test("carries on when a favorite or comment cannot be deleted", async () => {
    // Bob's records are in Bob's repo, so his PDS will refuse. Alice's gallery
    // still has to go.
    const deleted = await run(
      deleteGallery,
      { rkey: "ga" },
      { failOn: (c) => c === "social.grain.favorite" || c === "social.grain.comment" },
    );
    expect(rkeysFor(deleted, "social.grain.favorite")).toEqual([]);
    expect(rkeysFor(deleted, "social.grain.gallery")).toEqual(["ga"]);
  });

  test("deletes a gallery that holds nothing", async () => {
    const deleted = await run(deleteGallery, { rkey: "gb" });
    expect(deleted).toEqual([{ collection: "social.grain.gallery", rkey: "gb" }]);
  });

  test("refuses an rkey the viewer does not own", async () => {
    // The handler builds the uri from the viewer's own did, so naming someone
    // else's rkey just addresses a gallery of yours that does not exist. That
    // construction, not the query's `AND did = $2`, is what makes this safe —
    // the extra condition cannot be violated through this interface, and
    // removing it changes nothing.
    await expect(run(deleteGallery, { rkey: "gbob" })).rejects.toThrow("Gallery not found");
    await expect(run(deleteGallery, { rkey: "nope" })).rejects.toThrow("Gallery not found");
  });

  test("requires a session", async () => {
    await expect(
      deleteGallery.handler({
        db: server.db,
        input: { rkey: "ga" },
        viewer: null,
        deleteRecord: async () => {},
        ok: (v: unknown) => v,
      }),
    ).rejects.toThrow("Authentication required");
  });
});

describe("deleteAccount", () => {
  test("deletes every grain record the account owns", async () => {
    const deleted = await run(deleteAccount, {});
    const byCollection = new Set(deleted.map((d) => d.collection));
    expect(byCollection).toContain("social.grain.gallery");
    expect(byCollection).toContain("social.grain.photo");
    expect(byCollection).toContain("social.grain.photo.exif");
    expect(byCollection).toContain("social.grain.gallery.item");
    expect(byCollection).toContain("social.grain.favorite");
    // Alice owns two galleries and one favorite; Bob's records are not hers.
    expect(rkeysFor(deleted, "social.grain.gallery").sort()).toEqual(["ga", "gb"]);
    expect(rkeysFor(deleted, "social.grain.favorite")).toEqual(["fa"]);
    expect(rkeysFor(deleted, "social.grain.comment")).toEqual([]);
  });

  test("deletes children before their parents", async () => {
    const deleted = await run(deleteAccount, {});
    const first = (c: string) => deleted.findIndex((d) => d.collection === c);
    expect(first("social.grain.photo.exif")).toBeLessThan(first("social.grain.photo"));
    expect(first("social.grain.gallery.item")).toBeLessThan(first("social.grain.gallery"));
  });

  test("carries on past a record the PDS will not delete", async () => {
    const deleted = await run(
      deleteAccount,
      {},
      {
        failOn: (c) => c === "social.grain.photo",
      },
    );
    expect(rkeysFor(deleted, "social.grain.photo")).toEqual([]);
    expect(rkeysFor(deleted, "social.grain.gallery").sort()).toEqual(["ga", "gb"]);
  });

  test("clears the account's own server-side state and nobody else's", async () => {
    await run(deleteAccount, {});
    const { db } = server;
    expect(await db.query(`SELECT * FROM _mutes WHERE did = $1`, [ALICE])).toEqual([]);
    expect(await db.query(`SELECT * FROM _preferences WHERE did = $1`, [ALICE])).toEqual([]);
    expect(await db.query(`SELECT * FROM _oauth_sessions WHERE did = $1`, [ALICE])).toEqual([]);

    // Bob's rows are untouched.
    expect(await db.query(`SELECT * FROM _mutes WHERE did = $1`, [BOB])).toHaveLength(1);
    expect(await db.query(`SELECT * FROM _preferences WHERE did = $1`, [BOB])).toHaveLength(1);
  });

  test("requires a session", async () => {
    await expect(
      deleteAccount.handler({ db: server.db, input: {}, viewer: null, ok: (v: unknown) => v }),
    ).rejects.toThrow("Authentication required");
  });
});