Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

216 lines
6.2 KiB
TypeScript

import { afterEach, describe, it, expect, vi } from "vitest";
import {
__resetRefreshSingleFlightForTests,
createRecord,
ensureSession,
normalizePdsHost,
rkeyFromUri,
} from "../src/atproto.js";
describe("normalizePdsHost", () => {
it("defaults to bsky.social", () => {
expect(normalizePdsHost(undefined)).toBe("bsky.social");
});
it("accepts host-only values", () => {
expect(normalizePdsHost("bsky.social")).toBe("bsky.social");
});
it("accepts full PDS URLs", () => {
expect(normalizePdsHost("https://bsky.social")).toBe("bsky.social");
expect(normalizePdsHost("https://example.com/")).toBe("example.com");
});
it("preserves ports for https URLs", () => {
expect(normalizePdsHost("https://localhost:2583")).toBe("localhost:2583");
});
it("rejects non-https protocols", () => {
expect(() => normalizePdsHost("http://localhost:2583")).toThrow(
"Invalid PDS host protocol: http:",
);
});
});
describe("rkeyFromUri", () => {
it("extracts rkey from a standard AT-URI", () => {
const rkey = rkeyFromUri("at://did:plc:abc123/site.standard.document/3lwafzkjqm25s");
expect(rkey).toBe("3lwafzkjqm25s");
});
it("extracts rkey from a Bluesky post URI", () => {
const rkey = rkeyFromUri("at://did:plc:abc123/app.bsky.feed.post/3k4duaz5vfs2b");
expect(rkey).toBe("3k4duaz5vfs2b");
});
it("throws on empty URI", () => {
expect(() => rkeyFromUri("")).toThrow("Invalid AT-URI");
});
});
describe("createRecord", () => {
it("refreshes the session when the PDS returns a 400 ExpiredToken response", async () => {
const kv = new Map<string, unknown>([
["settings:pdsHost", "bsky.social"],
["settings:handle", "example.com"],
["settings:appPassword", "app-password"],
["state:accessJwt", "stale-access"],
["state:refreshJwt", "refresh-token"],
["state:did", "did:plc:test"],
]);
const fetch = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "ExpiredToken", message: "Token has expired" }), {
status: 400,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
accessJwt: "fresh-access",
refreshJwt: "fresh-refresh",
did: "did:plc:test",
handle: "example.com",
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({ uri: "at://did:plc:test/site.standard.publication/abc", cid: "cid" }),
{
status: 200,
},
),
);
const ctx = {
http: { fetch },
kv: {
get: vi.fn(async (key: string) => kv.get(key)),
set: vi.fn(async (key: string, value: unknown) => {
kv.set(key, value);
}),
},
} as any;
const result = await createRecord(
ctx,
"bsky.social",
"stale-access",
"did:plc:test",
"site.standard.publication",
{ name: "Example Site" },
);
expect(result).toEqual({ uri: "at://did:plc:test/site.standard.publication/abc", cid: "cid" });
expect(fetch).toHaveBeenCalledTimes(3);
expect(kv.get("state:accessJwt")).toBe("fresh-access");
expect(kv.get("state:refreshJwt")).toBe("fresh-refresh");
});
});
describe("ensureSession token-refresh single-flight", () => {
afterEach(() => {
__resetRefreshSingleFlightForTests();
vi.restoreAllMocks();
});
function makeCtx(fetch: ReturnType<typeof vi.fn>) {
// No state:accessJwt -> ensureSession takes the refresh path.
const kv = new Map<string, unknown>([
["settings:pdsHost", "bsky.social"],
["settings:handle", "example.com"],
["settings:appPassword", "app-password"],
["state:refreshJwt", "refresh-token"],
["state:did", "did:plc:test"],
]);
const ctx = {
http: { fetch },
kv: {
get: vi.fn(async (key: string) => kv.get(key)),
set: vi.fn(async (key: string, value: unknown) => {
kv.set(key, value);
}),
},
} as any;
return { ctx, kv };
}
function freshRefreshResponse(): Response {
return new Response(
JSON.stringify({
accessJwt: "fresh-access",
refreshJwt: "fresh-refresh",
did: "did:plc:test",
handle: "example.com",
}),
{ status: 200 },
);
}
it("coalesces concurrent refreshes onto a single PDS call (waiters read KV)", async () => {
__resetRefreshSingleFlightForTests({ pollMs: 5 });
let refreshCalls = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const fetch = vi.fn(async (url: string) => {
if (url.includes("refreshSession")) {
refreshCalls += 1;
await gate;
return freshRefreshResponse();
}
throw new Error(`unexpected fetch: ${url}`);
});
const { ctx } = makeCtx(fetch);
const a = ensureSession(ctx);
const b = ensureSession(ctx);
// Hold the owner mid-refresh so the second caller parks in the poll
// loop, then let it finish.
await new Promise((resolve) => setTimeout(resolve, 30));
release();
const [ra, rb] = await Promise.all([a, b]);
expect(refreshCalls).toBe(1);
expect(ra.accessJwt).toBe("fresh-access");
expect(rb.accessJwt).toBe("fresh-access");
});
it("a stranded owner (cancelled request) does not hang later refreshers", async () => {
// Regression for the isolate-poisoning class: the old implementation
// cached the in-flight refresh *promise*, so a first caller whose
// request was cancelled mid-refresh left a never-settling promise that
// every later ensureSession on the isolate awaited forever (524). The
// lock-and-poll cache must let a later caller reclaim and recover.
__resetRefreshSingleFlightForTests({ deadlineMs: 80, pollMs: 10, maxWaitMs: 5000 });
let refreshCalls = 0;
const fetch = vi.fn(async (url: string) => {
if (url.includes("refreshSession")) {
refreshCalls += 1;
if (refreshCalls === 1) {
// Owner A's request is cancelled mid-refresh: never settles.
await new Promise(() => {});
}
return freshRefreshResponse();
}
throw new Error(`unexpected fetch: ${url}`);
});
const { ctx } = makeCtx(fetch);
// Owner A claims and strands. Its request is gone; nobody awaits it.
const stranded = ensureSession(ctx);
void stranded.catch(() => {});
await new Promise((resolve) => setTimeout(resolve, 20));
// Reader B must reclaim the stale lock and refresh, not hang on A's
// dead promise.
const recovered = await ensureSession(ctx);
expect(recovered.accessJwt).toBe("fresh-access");
expect(refreshCalls).toBeGreaterThanOrEqual(2);
});
});