8.1/8.2: listSocialFeed is now a UNION ALL of local and remote branches, sorted on COALESCE(remote_published_at, created_at): - local rows: visibility='public' from accepted local follows — and the previously missing accepted_at filter is added (the spec's 'Pending follows contribute nothing' scenario) - remote rows: gated structurally by joining the viewer's OWN accepted follow against the originating actor — which is exactly the followers-only audience rule (a row reaches only viewers whose follow brought it in); attribution from the remote_actors cache; cards link outward to the canonical origin page (no local detail page for remote rows) 9.1: annotated — local Pending button shipped with locked accounts; remote Pending lives on /follows/outgoing. 9.2: already enforced + tested since §3 (actor/webfinger 404). 9.3 hardening: deliver-activity now re-checks the OWNER's profile visibility at send time, closing the enqueue→delivery flip window (enqueue-side and inbound-side gates already existed). Integration tests: the §8 audience-leak guard (A sees followers-only, B does not), public remote attribution + outward link, pending contributes nothing, mixed local/remote COALESCE ordering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
4.9 KiB
TypeScript
140 lines
4.9 KiB
TypeScript
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
|
import { eq, like } from "drizzle-orm";
|
|
import { randomUUID } from "node:crypto";
|
|
import { getDb } from "./db.ts";
|
|
import { users, follows, activities, remoteActors } from "@trails-cool/db/schema/journal";
|
|
import { listSocialFeed } from "./activities.server.ts";
|
|
|
|
// Opt-in: real Postgres. Covers social-federation §8 — the audience
|
|
// leak guard is THE scenario that must never regress.
|
|
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
|
|
|
|
const RUN = Date.now();
|
|
const REMOTE_ACTOR = `https://feed-origin.example/users/x-${RUN}`;
|
|
const createdUserIds: string[] = [];
|
|
|
|
async function makeUser(suffix: string) {
|
|
const db = getDb();
|
|
const id = randomUUID();
|
|
await db.insert(users).values({
|
|
id,
|
|
email: `feed-${suffix}-${RUN}@example.test`,
|
|
username: `feed-${suffix}-${RUN}`,
|
|
domain: "test.local",
|
|
profileVisibility: "public",
|
|
});
|
|
createdUserIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
async function followRemote(followerId: string, accepted: boolean) {
|
|
const db = getDb();
|
|
await db.insert(follows).values({
|
|
id: randomUUID(),
|
|
followerId,
|
|
followedActorIri: REMOTE_ACTOR,
|
|
followedUserId: null,
|
|
acceptedAt: accepted ? new Date() : null,
|
|
});
|
|
}
|
|
|
|
async function remoteActivity(n: number, audience: "public" | "followers-only", publishedAt: Date) {
|
|
const db = getDb();
|
|
await db.insert(activities).values({
|
|
id: randomUUID(),
|
|
ownerId: null,
|
|
name: `Remote ${audience} ${n}`,
|
|
visibility: audience === "public" ? "public" : "private",
|
|
remoteOriginIri: `${REMOTE_ACTOR}/activities/${n}`,
|
|
remoteActorIri: REMOTE_ACTOR,
|
|
remotePublishedAt: publishedAt,
|
|
audience,
|
|
});
|
|
}
|
|
|
|
describe.runIf(runIntegration)("social feed with remote rows (§8, integration)", () => {
|
|
beforeAll(async () => {
|
|
process.env.ORIGIN ??= "http://localhost:3000";
|
|
const db = getDb();
|
|
await db.insert(remoteActors).values({
|
|
actorIri: REMOTE_ACTOR,
|
|
username: "x",
|
|
displayName: "Remote X",
|
|
domain: "feed-origin.example",
|
|
}).onConflictDoNothing();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const db = getDb();
|
|
await db.delete(activities).where(like(activities.remoteOriginIri, `${REMOTE_ACTOR}%`));
|
|
for (const id of createdUserIds.splice(0)) {
|
|
await db.delete(activities).where(eq(activities.ownerId, id));
|
|
await db.delete(follows).where(eq(follows.followerId, id));
|
|
await db.delete(users).where(eq(users.id, id));
|
|
}
|
|
});
|
|
|
|
it("followers-only remote content reaches only the viewer with the accepted follow", async () => {
|
|
const a = await makeUser("a");
|
|
const b = await makeUser("b");
|
|
await followRemote(a, true); // A follows X (accepted)
|
|
// B does NOT follow X at all — but the row exists in our DB.
|
|
await remoteActivity(1, "followers-only", new Date());
|
|
|
|
const feedA = await listSocialFeed(a);
|
|
const feedB = await listSocialFeed(b);
|
|
expect(feedA.map((r) => r.name)).toContain("Remote followers-only 1");
|
|
expect(feedB.map((r) => r.name)).not.toContain("Remote followers-only 1");
|
|
});
|
|
|
|
it("public remote content reaches accepted followers with remote attribution", async () => {
|
|
const a = await makeUser("pub");
|
|
await followRemote(a, true);
|
|
await remoteActivity(2, "public", new Date());
|
|
|
|
const feed = await listSocialFeed(a);
|
|
const entry = feed.find((r) => r.name === "Remote public 2");
|
|
expect(entry).toBeDefined();
|
|
expect(entry!.remote).toBe(true);
|
|
expect(entry!.externalUrl).toBe(`${REMOTE_ACTOR}/activities/2`);
|
|
expect(entry!.ownerUsername).toBe("x");
|
|
expect(entry!.ownerDomain).toBe("feed-origin.example");
|
|
expect(entry!.geojson).toBeNull();
|
|
});
|
|
|
|
it("pending follows contribute nothing", async () => {
|
|
const a = await makeUser("pend");
|
|
await followRemote(a, false); // Pending
|
|
await remoteActivity(3, "public", new Date());
|
|
expect(await listSocialFeed(a)).toHaveLength(0);
|
|
});
|
|
|
|
it("mixes local and remote rows sorted by origin publish time", async () => {
|
|
const viewer = await makeUser("viewer");
|
|
const author = await makeUser("author");
|
|
const db = getDb();
|
|
await db.insert(follows).values({
|
|
id: randomUUID(),
|
|
followerId: viewer,
|
|
followedActorIri: `http://localhost:3000/users/feed-author-${RUN}`,
|
|
followedUserId: author,
|
|
acceptedAt: new Date(),
|
|
});
|
|
await followRemote(viewer, true);
|
|
|
|
const now = Date.now();
|
|
// Local activity created "now" (createdAt defaults to now()).
|
|
await db.insert(activities).values({
|
|
id: randomUUID(),
|
|
ownerId: author,
|
|
name: `Local newest ${RUN}`,
|
|
visibility: "public",
|
|
});
|
|
// Remote activity published an hour ago.
|
|
await remoteActivity(4, "public", new Date(now - 3600_000));
|
|
|
|
const feed = await listSocialFeed(viewer);
|
|
const names = feed.map((r) => r.name);
|
|
expect(names.indexOf(`Local newest ${RUN}`)).toBeLessThan(names.indexOf("Remote public 4"));
|
|
});
|
|
});
|