Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287import { test, expect } from "@playwright/test"import pg from "pg"import { loginAsTestAdmin } from "./auth-helper"
const DB_URL = "postgres://happyview:happyview@localhost:5434/happyview_test"
const TEST_NSID = "test.e2e.linkedrepos.item"const TEST_LEXICON = { lexicon: 1, id: TEST_NSID, defs: { main: { type: "record", key: "tid", record: { type: "object", properties: { title: { type: "string" }, }, }, }, },}
async function seedLexicon( request: import("@playwright/test").APIRequestContext,) { const resp = await request.post("/admin/lexicons", { data: { lexicon_json: TEST_LEXICON, backfill: false, target_collection: TEST_NSID, }, }) if (!resp.ok()) { const text = await resp.text() if (!text.includes("already exists")) { throw new Error(`Failed to seed lexicon: ${resp.status()} ${text}`) } }}
async function cleanupLexicon( request: import("@playwright/test").APIRequestContext,) { await request.delete(`/admin/lexicons/${encodeURIComponent(TEST_NSID)}`)}
// Defensive cleanup for the linked_repos rows this spec creates. The dashboard// deletes a grant's session + auth-state rows transactionally when removed// through the UI, but a test that fails mid-flow could leave one behind —// so clean up by content (scopes referencing our seeded collection) rather// than relying on the UI delete step having run.async function cleanupLinkedRepos(): Promise<void> { const client = new pg.Client(DB_URL) await client.connect() try { const like = `%${TEST_NSID}%` await client.query( `DELETE FROM happyview_linked_repo_auth_state WHERE grant_id IN ( SELECT id FROM happyview_linked_repos WHERE scopes LIKE $1 )`, [like], ) await client.query( `DELETE FROM happyview_linked_repo_sessions WHERE did IN ( SELECT did FROM happyview_linked_repos WHERE scopes LIKE $1 )`, [like], ) await client.query("DELETE FROM happyview_linked_repos WHERE scopes LIKE $1", [ like, ]) } finally { await client.end() }}
test.describe("Linked Repos", () => { test.beforeEach(async ({ page }) => { await loginAsTestAdmin(page) await seedLexicon(page.request) })
test.afterEach(async ({ page }) => { await cleanupLinkedRepos() await cleanupLexicon(page.request) })
test("shows empty state when no linked repos exist", async ({ page }) => { await page.goto("/dashboard/settings/linked-repos")
await expect( page.getByText("No linked repos yet."), ).toBeVisible({ timeout: 5000 }) })
test("creates an open grant via invite link, shows it pending, then deletes it", async ({ page, }) => { await page.goto("/dashboard/settings/linked-repos")
await page.getByRole("button", { name: "Link a repo" }).click()
const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible({ timeout: 3000 })
// Leave the handle/DID field blank — this is an open grant. Build a scope // from the prefix + value pair: the builder no longer enumerates // collections, so nothing here depends on a lexicon being present. await dialog.getByLabel("Scope 1 prefix").fill("repo") await dialog.getByLabel("Scope 1 value").fill(`${TEST_NSID}?action=create`)
const expectedScope = `repo:${TEST_NSID}?action=create` // `atproto` is mandatory in every AT Protocol authorization request, so the // builder always emits it — the preview must say so rather than the server // silently adding a scope the operator never saw. await expect(dialog.getByText(`atproto ${expectedScope}`)).toBeVisible()
await dialog.getByRole("button", { name: "Create link" }).click()
await expect(dialog.getByText("Invite link created")).toBeVisible({ timeout: 5000, }) const inviteUrlInput = dialog.locator("input[readonly]") await expect(inviteUrlInput).toBeVisible() await expect(inviteUrlInput).toHaveValue( /\/auth\/linked-repo\/start\?token=/, )
await dialog.getByRole("button", { name: "Done" }).click() await expect(dialog).not.toBeVisible()
const row = page.locator("table tbody tr", { hasText: expectedScope }) await expect(row).toBeVisible({ timeout: 5000 }) await expect(row.getByText("pending")).toBeVisible() await expect(row.getByText("1 link outstanding")).toBeVisible() await expect(row.getByText(expectedScope)).toBeVisible()
// Row controls moved into a detail sheet: open it, then remove from there. await row.click() const detail = page.getByRole("dialog").filter({ hasText: "Invite links" }) await expect(detail).toBeVisible({ timeout: 5000 })
// The sheet shows the invite created above, and the scopes in full. await expect(detail.getByText("1 link outstanding")).toHaveCount(0) await expect(detail.getByText(expectedScope)).toBeVisible() await expect( detail.getByRole("button", { name: "Create a new invite link" }), ).toBeVisible()
await detail.getByRole("button", { name: /remove this linked repo/i }).click()
const confirm = page.getByRole("alertdialog") await expect(confirm).toBeVisible({ timeout: 3000 }) await expect(confirm.getByText("Remove this linked repo?")).toBeVisible() await confirm.getByRole("button", { name: "Remove" }).click()
await expect( page.getByText("No linked repos yet."), ).toBeVisible({ timeout: 5000 }) })
test("blocks submission when the raw scope field is malformed", async ({ page, }) => { await page.goto("/dashboard/settings/linked-repos")
await page.getByRole("button", { name: "Link a repo" }).click()
const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible({ timeout: 3000 })
const prefix = dialog.getByLabel("Scope 1 prefix") const value = dialog.getByLabel("Scope 1 value") const createLinkButton = dialog.getByRole("button", { name: "Create link" })
// Nothing entered: the builder still emits `atproto`, but that alone // authorizes nothing, so submission stays blocked. await expect(createLinkButton).toBeDisabled()
// "foo" has too few NSID segments (the client validator is a // character-for-character port of the server's), so this is rejected before // the action list is even considered. await prefix.fill("repo") await value.fill("foo?action=frobnicate") await expect(dialog.getByText("invalid NSID: foo")).toBeVisible() await expect(createLinkButton).toBeDisabled()
// "com" — also too few NSID segments, same rule, different token. await value.fill("com") await expect(dialog.getByText("invalid NSID: com")).toBeVisible() await expect(createLinkButton).toBeDisabled()
// A prefix the server rejects outright. await prefix.fill("nonsense") await value.fill("thing") await expect(dialog.getByText("unknown scope prefix: nonsense")).toBeVisible() await expect(createLinkButton).toBeDisabled()
// transition:generic is allowed, but warned about — the grant is long-lived // and its scopes can't be narrowed later. await prefix.fill("transition") await value.fill("generic") await expect(dialog.getByText(/grants broad write access/i)).toBeVisible() await expect(createLinkButton).toBeEnabled() })
test("builds repo actions from checkboxes", async ({ page }) => { await page.goto("/dashboard/settings/linked-repos") await page.getByRole("button", { name: "Link a repo" }).click()
const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible({ timeout: 3000 })
const createLinkButton = dialog.getByRole("button", { name: "Create link" }) await dialog.getByLabel("Scope 1 prefix").fill("repo") await dialog.getByLabel("Scope 1 value").fill(TEST_NSID)
// A collection with no actions serialises to `repo:<nsid>`, which the // server reads as create *and* update *and* delete. The builder refuses to // emit that by accident — scopes are immutable, so an over-broad grant made // by not choosing can only be fixed by re-linking the repo. await expect(dialog.getByText("select at least one action")).toBeVisible() await expect(createLinkButton).toBeDisabled()
await dialog.getByLabel("create", { exact: true }).check() await expect( dialog.getByText(`atproto repo:${TEST_NSID}?action=create`), ).toBeVisible() await expect(createLinkButton).toBeEnabled()
// Actions serialise in declaration order, not the order they were clicked, // so the same access always produces the same string. await dialog.getByLabel("delete", { exact: true }).check() await dialog.getByLabel("update", { exact: true }).check() await expect( dialog.getByText(`atproto repo:${TEST_NSID}?action=create,update,delete`), ).toBeVisible()
// `update` without `create` can't upsert — flagged on the row that has the // problem rather than as a general note nobody reads. await dialog.getByLabel("create", { exact: true }).uncheck() await dialog.getByLabel("delete", { exact: true }).uncheck() await expect(dialog.getByText(/can't/).first()).toBeVisible() await expect( dialog.getByText(`atproto repo:${TEST_NSID}?action=update`), ).toBeVisible() // Still a legitimate grant, just a narrow one — warned about, not blocked. await expect(createLinkButton).toBeEnabled() })
test("hoists a pasted action query into the checkboxes", async ({ page }) => { await page.goto("/dashboard/settings/linked-repos") await page.getByRole("button", { name: "Link a repo" }).click()
const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible({ timeout: 3000 })
const value = dialog.getByLabel("Scope 1 value") await dialog.getByLabel("Scope 1 prefix").fill("repo")
// Pasting a whole scope keeps working: the query is lifted into the boxes // and the field is left holding the collection alone, so there's never a // second, invisible copy of the action list. await value.fill(`${TEST_NSID}?action=create,update`) await expect(value).toHaveValue(TEST_NSID) await expect(dialog.getByLabel("create", { exact: true })).toBeChecked() await expect(dialog.getByLabel("update", { exact: true })).toBeChecked() await expect(dialog.getByLabel("delete", { exact: true })).not.toBeChecked() await expect( dialog.getByText(`atproto repo:${TEST_NSID}?action=create,update`), ).toBeVisible()
// A malformed action list is left in the field so the error can name it, // rather than being silently swallowed by the checkboxes. await value.fill(`${TEST_NSID}?action=frobnicate`) await expect(value).toHaveValue(`${TEST_NSID}?action=frobnicate`) await expect( dialog.getByText("unknown repo action: frobnicate"), ).toBeVisible() await expect( dialog.getByRole("button", { name: "Create link" }), ).toBeDisabled() })})