0
2
// The on-login hook, which seeds a grain profile from the user's Bluesky one
// the first time they sign in.
//
// Like the on-commit hooks, `defineHook` hands back the handler untouched, so
// this runs against a real database. `fetch` is stubbed because the hook reads
// the Bluesky profile straight off the user's PDS, and the two record writers
// are stubbed to record what would have been written to it.
import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { startTestServer } from "@hatk/hatk/test";
import onLogin from "../server/hooks/on-login.ts";
const NEW = "did:plc:newcomer"; // no grain profile at all
const BARE = "did:plc:bare"; // a grain profile with no display name
const DONE = "did:plc:done"; // a grain profile already filled in
const NOSESSION = "did:plc:nosession"; // no oauth session row
const PDS = "https://pds.example";
let server: any;
/** Runs the hook and reports what it tried to do. */
async function fire(did: string) {
const created: any[] = [];
const put: any[] = [];
const ensured: string[] = [];
await onLogin.handler({
did,
db: server.db,
lookup: (async (_collection: string, _field: string, dids: string[]) => {
const rows = (await server.db.query(
`SELECT did, cid, display_name, created_at FROM "social.grain.actor.profile" WHERE did IN (${dids
.map((_, i) => `$${i + 1}`)
.join(",")})`,
dids,
)) as any[];
const map = new Map();
for (const r of rows) {
map.set(r.did, {
did: r.did,
cid: r.cid,
value: { displayName: r.display_name ?? undefined, createdAt: r.created_at },
});
}
return map;
}) as any,
ensureRepo: async (d: string) => void ensured.push(d),
createRecord: async (collection: string, record: any, opts?: any) => {
created.push({ collection, record, opts });
return {};
},
putRecord: async (collection: string, rkey: string, record: any) => {
put.push({ collection, rkey, record });
return {};
},
deleteRecord: async () => {},
} as any);
return { created, put, ensured };
}
const BSKY_PROFILE = {
displayName: "Alice from Bluesky",
description: "photographer",
avatar: { $type: "blob", ref: { $link: "bafy-avatar" }, mimeType: "image/jpeg", size: 1 },
};
/** Answers the PDS getRecord call; everything else 404s. */
function stubPds(value: unknown | null, ok = true) {
vi.stubGlobal("fetch", async (url: string) => {
if (typeof url === "string" && url.includes("com.atproto.repo.getRecord")) {
if (!ok) return new Response("nope", { status: 500 });
return new Response(JSON.stringify({ value }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("{}", { status: 404 });
});
}
beforeAll(async () => {
server = await startTestServer();
const { db } = server;
for (const did of [NEW, BARE, DONE, NOSESSION]) {
await db.run(`INSERT INTO _repos (did, status, handle) VALUES ($1, 'active', $2)`, [
did,
`${did.split(":").pop()}.test`,
]);
}
// Everyone but NOSESSION has signed in, so we know where their PDS is.
for (const did of [NEW, BARE, DONE]) {
await db.run(
`INSERT INTO _oauth_sessions (did, pds_endpoint, access_token, dpop_jkt)
VALUES ($1, $2, 'tok', 'jkt')`,
[did, PDS],
);
}
// BARE has a profile record but never set a display name; DONE has one.
await db.run(
`INSERT INTO "social.grain.actor.profile" (uri, cid, did, indexed_at, created_at)
VALUES ($1, 'cid-bare', $2, 'i', '2020-01-01')`,
[`at://${BARE}/social.grain.actor.profile/self`, BARE],
);
await db.run(
`INSERT INTO "social.grain.actor.profile" (uri, cid, did, indexed_at, display_name, created_at)
VALUES ($1, 'cid-done', $2, 'i', 'Already Set', '2020-01-01')`,
[`at://${DONE}/social.grain.actor.profile/self`, DONE],
);
});
afterEach(() => vi.unstubAllGlobals());
afterAll(async () => await server?.close());
describe("on-login", () => {
test("kicks off a backfill for whoever just signed in", async () => {
stubPds(BSKY_PROFILE);
const { ensured } = await fire(NEW);
expect(ensured).toEqual([NEW]);
});
test("creates a grain profile from the Bluesky one on a first sign-in", async () => {
stubPds(BSKY_PROFILE);
const { created, put } = await fire(NEW);
expect(put).toEqual([]);
expect(created).toHaveLength(1);
expect(created[0].collection).toBe("social.grain.actor.profile");
expect(created[0].opts).toEqual({ rkey: "self" });
expect(created[0].record).toMatchObject({
displayName: "Alice from Bluesky",
description: "photographer",
avatar: BSKY_PROFILE.avatar,
});
expect(created[0].record.createdAt).toBeTruthy();
});
test("updates an existing but empty profile rather than creating a second", async () => {
stubPds(BSKY_PROFILE);
const { created, put } = await fire(BARE);
expect(created).toEqual([]);
expect(put).toHaveLength(1);
expect(put[0]).toMatchObject({ collection: "social.grain.actor.profile", rkey: "self" });
expect(put[0].record.displayName).toBe("Alice from Bluesky");
});
test("keeps the original createdAt when filling in an existing profile", async () => {
// The account's own history, not the moment we noticed it was blank.
stubPds(BSKY_PROFILE);
const { put } = await fire(BARE);
expect(put[0].record.createdAt).toBe("2020-01-01");
});
test("leaves a profile that already has a display name alone", async () => {
stubPds(BSKY_PROFILE);
const { created, put } = await fire(DONE);
expect(created).toEqual([]);
expect(put).toEqual([]);
});
test("writes only the fields Bluesky actually had", async () => {
stubPds({ displayName: "Just a name" });
const { created } = await fire(NEW);
expect(created[0].record.displayName).toBe("Just a name");
expect(created[0].record).not.toHaveProperty("description");
expect(created[0].record).not.toHaveProperty("avatar");
});
test("writes nothing when there is no session to find the PDS with", async () => {
stubPds(BSKY_PROFILE);
const { created, put, ensured } = await fire(NOSESSION);
expect(ensured).toEqual([NOSESSION]); // the backfill still runs
expect(created).toEqual([]);
expect(put).toEqual([]);
});
test("writes nothing when the PDS refuses the request", async () => {
stubPds(BSKY_PROFILE, false);
const { created, put } = await fire(NEW);
expect(created).toEqual([]);
expect(put).toEqual([]);
});
test("writes nothing when the PDS has no Bluesky profile to copy", async () => {
stubPds(null);
const { created, put } = await fire(NEW);
expect(created).toEqual([]);
expect(put).toEqual([]);
});
});