Replaces the earlier 404-for-private model with Mastodon-style locked accounts. A private profile now returns 200 with a stub layout and gates content behind follow approval. Default for new users flips from 'public' to 'private' to align with trails.cool's privacy-first content defaults. Schema: - users.profile_visibility default flipped to 'private'. Existing rows remain 'public' (backfill on first migration handled them). Follow API (follow.server.ts): - followUser now creates Pending (accepted_at = NULL) against private targets and Accepted against public targets — no more refusal. - New: countPendingFollowRequests, listPendingFollowRequests, approveFollowRequest, rejectFollowRequest. Approve/reject are owner-bound: only the followed user can act on their own incoming requests. - countFollowers / countFollowing / listFollowers / listFollowing now filter to accepted-only relations. Loader (users.$username.tsx): - Drops the 404 paths. New canSeeContent flag = isOwn || profile_visibility='public' || (followState.following === true). - When canSeeContent=false, render a stub: header + 🔒 badge + body copy + Request-to-follow / sign-in CTA. Routes/activities sections are not rendered. UI: - FollowButton gains a "Request to follow" / "Requested" state for private targets via a new isPrivateTarget prop. Cancel-request reuses the unfollow endpoint. - New /follows/requests page lists incoming Pending requests with Approve / Reject buttons. - New API routes: POST /api/follows/:id/approve and /reject. - Navbar shows a count badge linking to /follows/requests when pending > 0. Privacy manifest already documents the follows relation; no changes needed (the locked-account semantics don't add new data — same row, different lifecycle). Specs / design (social-feed change): - public-profiles delta rewritten around the four-mode locked model (public, private+anon, private+pending, private+accepted) with scenarios for each. - social-follows delta gains Pending lifecycle requirements (auto vs. manual accept, approve/reject endpoints, pending request management, Pending follows do not contribute to feed). - design.md decision section reflects the new model and rationale for default-private; non-goal "locked-local-accounts as a follow-up" is removed since this change ships it. Tests: - follow.integration.test.ts: pending-against-private, approve flips to accepted, reject deletes, owner-bound enforcement. - e2e/social.test.ts: full Request → Pending → Approve → full-view flow, plus stub-for-anonymous and /follows/requests auth gate. Supersedes PR #309 (closed): the empty-public-profile 200 is now a side-effect of the new render path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
6.5 KiB
TypeScript
159 lines
6.5 KiB
TypeScript
import { test, expect, type CDPSession, type Page } from "./fixtures/test";
|
|
|
|
// Inline virtual-authenticator + register helpers, mirroring the pattern
|
|
// in auth.test.ts / public-content.test.ts so this file runs standalone.
|
|
async function setupVirtualAuthenticator(cdp: CDPSession) {
|
|
await cdp.send("WebAuthn.enable");
|
|
const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
|
options: {
|
|
protocol: "ctap2",
|
|
transport: "internal",
|
|
hasResidentKey: true,
|
|
hasUserVerification: true,
|
|
isUserVerified: true,
|
|
},
|
|
});
|
|
return authenticatorId;
|
|
}
|
|
|
|
async function registerUser(page: Page, email: string, username: string) {
|
|
await page.goto("/auth/register");
|
|
await expect(page.getByRole("heading", { name: "Register" })).toBeVisible();
|
|
await page.getByLabel("Email").fill(email);
|
|
await page.getByLabel("Username").fill(username);
|
|
await page.getByRole("checkbox").check();
|
|
await page.getByRole("button", { name: /Register with Passkey/ }).click();
|
|
await expect(page).toHaveURL("/", { timeout: 10000 });
|
|
}
|
|
|
|
async function setProfileVisibility(page: Page, value: "public" | "private") {
|
|
await page.goto("/settings");
|
|
if (value === "public") {
|
|
await page.getByLabel("Public").check();
|
|
} else {
|
|
await page.getByLabel(/Private/).check();
|
|
}
|
|
await page.getByRole("button", { name: /^Save$/ }).first().click();
|
|
await page.waitForLoadState("networkidle");
|
|
}
|
|
|
|
// WebAuthn + parallel workers + shared local Postgres race; serialize.
|
|
test.describe.configure({ mode: "serial" });
|
|
|
|
test.describe("Social follows + /feed", () => {
|
|
test("/feed redirects anonymous visitors to login", async ({ page }) => {
|
|
await page.goto("/feed");
|
|
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
|
|
});
|
|
|
|
test("/follows/requests redirects anonymous visitors to login", async ({ page }) => {
|
|
await page.goto("/follows/requests");
|
|
await expect(page).toHaveURL(/\/auth\/login/, { timeout: 10000 });
|
|
});
|
|
|
|
test("Follow button toggles state on a public profile", async ({ page, browser }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await setupVirtualAuthenticator(cdp);
|
|
|
|
const stamp = Date.now();
|
|
const aEmail = `social-a-${stamp}@example.com`;
|
|
const aUsername = `sa${stamp}`;
|
|
const bEmail = `social-b-${stamp}@example.com`;
|
|
const bUsername = `sb${stamp}`;
|
|
|
|
await registerUser(page, aEmail, aUsername);
|
|
|
|
// Register B in a separate browser context so we have two independent
|
|
// sessions. New users default to `private` — B flips to public so this
|
|
// test exercises the auto-accept path.
|
|
const bCtx = await browser.newContext();
|
|
const bPage = await bCtx.newPage();
|
|
const bCdp = await bPage.context().newCDPSession(bPage);
|
|
await setupVirtualAuthenticator(bCdp);
|
|
await registerUser(bPage, bEmail, bUsername);
|
|
await setProfileVisibility(bPage, "public");
|
|
|
|
// A visits B's profile and follows.
|
|
await page.goto(`/users/${bUsername}`);
|
|
await expect(page.getByRole("button", { name: "Follow" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Follow" }).click();
|
|
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible({ timeout: 5000 });
|
|
|
|
// Follower count on B's profile should now read 1.
|
|
await page.reload();
|
|
await expect(page.getByRole("link", { name: /1\s+Followers/i })).toBeVisible();
|
|
|
|
// Unfollow.
|
|
await page.getByRole("button", { name: "Unfollow" }).click();
|
|
await expect(page.getByRole("button", { name: "Follow" })).toBeVisible({ timeout: 5000 });
|
|
|
|
await bCtx.close();
|
|
});
|
|
|
|
test("Private profile: stub for visitors, Request → Pending → Approve → full view", async ({ page, browser }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await setupVirtualAuthenticator(cdp);
|
|
|
|
const stamp = Date.now();
|
|
const aEmail = `req-a-${stamp}@example.com`;
|
|
const aUsername = `ra${stamp}`;
|
|
const bEmail = `req-b-${stamp}@example.com`;
|
|
const bUsername = `rb${stamp}`;
|
|
|
|
await registerUser(page, aEmail, aUsername);
|
|
|
|
const bCtx = await browser.newContext();
|
|
const bPage = await bCtx.newPage();
|
|
const bCdp = await bPage.context().newCDPSession(bPage);
|
|
await setupVirtualAuthenticator(bCdp);
|
|
await registerUser(bPage, bEmail, bUsername);
|
|
// B stays at the default `private`. Verify by loading the profile
|
|
// anonymously and seeing the stub.
|
|
|
|
const anonCtx = await browser.newContext();
|
|
const anon = await anonCtx.newPage();
|
|
const anonResp = await anon.goto(`/users/${bUsername}`);
|
|
expect(anonResp?.status()).toBe(200);
|
|
await expect(anon.getByText(/This profile is private/i)).toBeVisible();
|
|
|
|
// A (signed in) visits B's private profile — sees stub + Request button.
|
|
await page.goto(`/users/${bUsername}`);
|
|
await expect(page.getByText(/This profile is private/i)).toBeVisible();
|
|
await expect(page.getByRole("button", { name: /Request to follow/i })).toBeVisible();
|
|
await page.getByRole("button", { name: /Request to follow/i }).click();
|
|
await expect(page.getByRole("button", { name: /Requested/i })).toBeVisible({ timeout: 5000 });
|
|
|
|
// B sees the request in /follows/requests with badge in nav.
|
|
await bPage.goto("/follows/requests");
|
|
await expect(bPage.getByText(`@${aUsername}`)).toBeVisible();
|
|
await bPage.getByRole("button", { name: "Approve" }).click();
|
|
await bPage.waitForLoadState("networkidle");
|
|
// Empty state after approval.
|
|
await expect(bPage.getByText(/No pending follow requests/i)).toBeVisible();
|
|
|
|
// A reloads B's profile — now sees full content (no stub) + Unfollow.
|
|
await page.goto(`/users/${bUsername}`);
|
|
await expect(page.getByText(/This profile is private/i)).not.toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Unfollow" })).toBeVisible();
|
|
|
|
await bCtx.close();
|
|
await anonCtx.close();
|
|
});
|
|
|
|
test("Private profile: visitor sees stub layout (200, not 404)", async ({ page, browser }) => {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await setupVirtualAuthenticator(cdp);
|
|
const stamp = Date.now();
|
|
const username = `priv${stamp}`;
|
|
await registerUser(page, `priv-${stamp}@example.com`, username);
|
|
// User stays at default `private`.
|
|
|
|
const anonCtx = await browser.newContext();
|
|
const anon = await anonCtx.newPage();
|
|
const resp = await anon.goto(`/users/${username}`);
|
|
expect(resp?.status()).toBe(200);
|
|
await expect(anon.getByText(/This profile is private/i)).toBeVisible();
|
|
|
|
await anonCtx.close();
|
|
});
|
|
});
|