diff --git a/e2e/harness/api.ts b/e2e/harness/api.ts index 7f09a84c7..ac22e523d 100644 --- a/e2e/harness/api.ts +++ b/e2e/harness/api.ts @@ -18,6 +18,7 @@ import { export interface RepoOptions { spindle?: string; forkOf?: string; + importFrom?: string; } const sessions = new Map>(); @@ -115,6 +116,14 @@ const knotCall = (who: Account, repo: string, nsid: string, input: object) => export const knotReply = (who: Account, repo: string, nsid: string, input: object) => serviceReply(who, repo, nsid, { repo, ...input }); +const IMPORT_STATUS = "org.tangled.repo.getImportStatus"; +// the token is addressed to the repo, like web's, not to the knot +export const importStatus = async (repo: string, who?: Account) => + request( + xrpc(config.knotUrl, IMPORT_STATUS, { repo }), + who ? { token: await tokenFor(who, repo, IMPORT_STATUS) } : {} + ); + // gitmirror forwards the token to the knot's receive-pack, so it's minted for that export const mirrorMerge = async ( who: Account, @@ -252,6 +261,14 @@ export const repos = { ...(options.spindle ? { spindle: options.spindle } : {}), ...(options.forkOf ? { source: { $type: "org.tangled.repo.defs#sourceTangled", did: options.forkOf } } + : {}), + ...(options.importFrom + ? { + source: { + $type: "org.tangled.repo.defs#sourceExternal", + uri: options.importFrom + } + } : {}) }); const declaration = await pdsCall(who, "com.atproto.repo.createRecord", { diff --git a/e2e/harness/flow.ts b/e2e/harness/flow.ts index e4b8f5ea8..875697dde 100644 --- a/e2e/harness/flow.ts +++ b/e2e/harness/flow.ts @@ -1,7 +1,7 @@ import { expect, test, type Browser, type TestStepInfo } from "@playwright/test"; import { fileURLToPath } from "node:url"; -import { setPolicy, type Policy } from "./api"; +import { setPolicy, type Policy, type RepoOptions } from "./api"; import { claimAttestation, isAttested } from "./attestations"; import { browserLaunch, config, type Account } from "./config"; import type { Reply } from "./net"; @@ -95,7 +95,7 @@ class Flow { return this.add(`actor ${who.name} (seeded)`, (run) => run.switchTo(who, options)); } - repo(options: { spindle?: string } = {}) { + repo(options: Pick = {}) { return this.add("repo", (run) => run.own(options)); } diff --git a/e2e/specs/repos.spec.ts b/e2e/specs/repos.spec.ts index 5d5d8b54b..91d081f4b 100644 --- a/e2e/specs/repos.spec.ts +++ b/e2e/specs/repos.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; -import { knotRecord } from "../harness/api"; -import { config } from "../harness/config"; +import { importStatus, knotRecord } from "../harness/api"; +import { config, seeded } from "../harness/config"; import { bobbin, button, @@ -10,6 +10,7 @@ import { declarationId, exactLink, flow, + type Flow, git, hydrant, knot, @@ -28,7 +29,7 @@ import { } from "../harness/flow"; import { lsRemote, push } from "../harness/git"; import { partsOf, selfUri, stamp, until } from "../harness/net"; -import { base, manifestOf, mirrorLists, recall, remember } from "../harness/shared"; +import { base, forgetTheLogin, manifestOf, mirrorLists, recall, remember } from "../harness/shared"; const files = { "README.md": "hello from e2e\n" }; @@ -402,3 +403,88 @@ flow("importing a repo from github brings its files over") .open(page.repo) .sees(exactLink("README.md")) .sees(exactLink("hello.txt")); + +const helloRepo = "https://github.tngl.boltless.dev/octostub/hello.git"; +const unfetchableRepo = "https://github.tngl.boltless.dev/octostub/unfetchable.git"; +const hiddenReason = "the import from the source didn't finish; the repo owner can see details"; + +const importSays = (wanted: string) => async (ctx: Ctx) => + until(`the knot should say the import is ${wanted}`, 60_000, async () => { + const { status, body } = await importStatus(ctx.repo.did); + return [ + status === 200 && body?.status === wanted, + `it answered ${status} ${JSON.stringify(body)}` + ]; + }); + +const reasons = new WeakMap(); + +const ownerReadsWhatWentWrong = async (ctx: Ctx) => { + const { status, body } = await importStatus(ctx.repo.did, ctx.actor); + expect(status, JSON.stringify(body)).toBe(200); + expect(body.status).toBe("failed"); + expect(body.reason, "the owner should read what went wrong").toBeTruthy(); + expect(body.reason).not.toBe(hiddenReason); + reasons.set(ctx, body.reason); +}; + +const everyoneElseReadsTheHiddenReason = async (ctx: Ctx) => { + const asked = { "an anonymous caller": undefined, "a stranger": seeded.charlie }; + for (const [who, account] of Object.entries(asked)) { + const { status, body } = await importStatus(ctx.repo.did, account); + expect(status, `${who}: ${JSON.stringify(body)}`).toBe(200); + expect(body, who).toEqual({ status: "failed", reason: hiddenReason }); + } +}; + +const unknownRepoIsNotFound = async () => { + const { status, body } = await importStatus("did:plc:e2enotarepoanywhereaaaaa"); + expect(status, JSON.stringify(body)).toBe(404); + expect(body.error).toBe("RepoNotFound"); +}; + +const importFailed = text(/The import from .* failed\./); +const askTheOperator = text("Ask your knot operator for support."); +const ownersReason = target("the reason the owner read from the knot", (tab, ctx) => + tab.getByText(reasons.get(ctx) ?? "no reason was read") +); + +const seesTheFixedBanner = (flow: Flow) => + flow + .sees(importFailed) + .sees(askTheOperator) + .sees(not(ownersReason)) + .sees(not(text(hiddenReason))); + +flow("an import that lands says so on the knot") + .actor("owner") + .repo({ importFrom: helloRepo }) + .step("the knot says the import is present", importSays("present")) + .step("an anonymous caller reads it the same, with no reason", async (ctx) => { + expect((await importStatus(ctx.repo.did)).body).toEqual({ status: "present" }); + }) + .step("bobbin's mirror lists main", mirrorHas("main")) + .open(page.repo) + .sees(exactLink("hello.txt")) + .sees(not(importFailed)); + +flow( + "a failed import tells only its owner what went wrong, the page asks everyone to ask their knot operator" +) + .actor("owner") + .repo({ importFrom: unfetchableRepo }) + .step("the knot gives up on the import", importSays("failed")) + .step("the owner reads what went wrong", ownerReadsWhatWentWrong) + .step( + "an anonymous caller and a stranger read the hidden reason", + everyoneElseReadsTheHiddenReason + ) + .step("the knot doesn't know an unknown repo", unknownRepoIsNotFound) + .open(page.repo) + .use(seesTheFixedBanner) + .actor(seeded.charlie) + .open(page.repo) + .use(seesTheFixedBanner) + .step("signs out", forgetTheLogin) + .open(page.repo) + .use(seesTheFixedBanner);