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
368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
/**
|
|
* DidResolver unit tests + the D1 binding's contract test.
|
|
*
|
|
* The class is exercised end-to-end with a real WebCrypto-backed signing key
|
|
* (generated once in `beforeAll`) and a Map-backed cache so cache behaviour is
|
|
* verified independently of D1 wiring. A separate suite runs the D1 binding
|
|
* against the test pool's in-memory D1 and re-runs the cache contract via
|
|
* the same scenarios — that way the contract is the same in tests and in
|
|
* production.
|
|
*/
|
|
|
|
import { P256PrivateKeyExportable } from "@atcute/crypto";
|
|
import type { DidDocument } from "@atcute/identity";
|
|
import type { Did } from "@atcute/lexicons/syntax";
|
|
import { applyD1Migrations, env } from "cloudflare:test";
|
|
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
type CachedDidDoc,
|
|
createD1DidDocCache,
|
|
type DidDocCache,
|
|
type DidDocumentResolverLike,
|
|
DidResolver,
|
|
} from "../src/did-resolver.js";
|
|
|
|
interface TestEnv {
|
|
DB: D1Database;
|
|
TEST_MIGRATIONS: Parameters<typeof applyD1Migrations>[1];
|
|
}
|
|
|
|
const testEnv = env as unknown as TestEnv;
|
|
|
|
const TEST_DID = "did:plc:test00000000000000000000";
|
|
const TEST_PDS = "https://pds.test.example";
|
|
|
|
let signingKeyMultibase: string;
|
|
|
|
beforeAll(async () => {
|
|
const kp = await P256PrivateKeyExportable.createKeypair();
|
|
signingKeyMultibase = await kp.exportPublicKey("multikey");
|
|
});
|
|
|
|
class MapDidDocCache implements DidDocCache {
|
|
private readonly entries = new Map<string, CachedDidDoc>();
|
|
readonly reads: string[] = [];
|
|
readonly upserts: Array<{ did: string; doc: Omit<CachedDidDoc, "resolvedAt">; now: Date }> = [];
|
|
readonly expires: string[] = [];
|
|
|
|
read(did: string): Promise<CachedDidDoc | null> {
|
|
this.reads.push(did);
|
|
return Promise.resolve(this.entries.get(did) ?? null);
|
|
}
|
|
upsert(did: string, doc: Omit<CachedDidDoc, "resolvedAt">, now: Date): Promise<void> {
|
|
this.upserts.push({ did, doc, now });
|
|
this.entries.set(did, { ...doc, resolvedAt: now });
|
|
return Promise.resolve();
|
|
}
|
|
expire(did: string): Promise<void> {
|
|
this.expires.push(did);
|
|
const entry = this.entries.get(did);
|
|
if (entry) {
|
|
this.entries.set(did, { ...entry, resolvedAt: new Date(0) });
|
|
}
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
class StubResolver implements DidDocumentResolverLike {
|
|
readonly calls: Did[] = [];
|
|
private response: DidDocument;
|
|
private error: Error | null = null;
|
|
|
|
constructor(response: DidDocument) {
|
|
this.response = response;
|
|
}
|
|
|
|
resolve(did: Did): Promise<DidDocument> {
|
|
this.calls.push(did);
|
|
if (this.error) return Promise.reject(this.error);
|
|
return Promise.resolve(this.response);
|
|
}
|
|
|
|
setResponse(doc: DidDocument): void {
|
|
this.response = doc;
|
|
}
|
|
setError(err: Error): void {
|
|
this.error = err;
|
|
}
|
|
}
|
|
|
|
function buildDidDoc(overrides: Partial<DidDocument> = {}): DidDocument {
|
|
return {
|
|
id: TEST_DID as `did:${string}:${string}`,
|
|
verificationMethod: [
|
|
{
|
|
id: `${TEST_DID}#atproto`,
|
|
type: "Multikey",
|
|
controller: TEST_DID as `did:${string}:${string}`,
|
|
publicKeyMultibase: signingKeyMultibase,
|
|
},
|
|
],
|
|
service: [
|
|
{
|
|
id: "#atproto_pds",
|
|
type: "AtprotoPersonalDataServer",
|
|
serviceEndpoint: TEST_PDS,
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("DidResolver", () => {
|
|
describe("with in-memory cache", () => {
|
|
it("resolves on cache miss, writes the row, returns a usable PublicKey", async () => {
|
|
const cache = new MapDidDocCache();
|
|
const resolver = new StubResolver(buildDidDoc());
|
|
const subject = new DidResolver({ cache, resolver, now: () => new Date(1000) });
|
|
|
|
const result = await subject.resolve(TEST_DID);
|
|
|
|
expect(result.pds).toBe(TEST_PDS);
|
|
expect(result.signingKeyId).toBe(`${TEST_DID}#atproto`);
|
|
expect(typeof result.publicKey.verify).toBe("function");
|
|
expect(resolver.calls).toEqual([TEST_DID]);
|
|
expect(cache.upserts).toHaveLength(1);
|
|
expect(cache.upserts[0]).toMatchObject({
|
|
did: TEST_DID,
|
|
doc: { pds: TEST_PDS, signingKey: signingKeyMultibase },
|
|
});
|
|
});
|
|
|
|
it("hits cache on second call within TTL — no resolver call", async () => {
|
|
const cache = new MapDidDocCache();
|
|
const resolver = new StubResolver(buildDidDoc());
|
|
const subject = new DidResolver({
|
|
cache,
|
|
resolver,
|
|
ttlMs: 60_000,
|
|
now: () => new Date(1000),
|
|
});
|
|
|
|
await subject.resolve(TEST_DID);
|
|
await subject.resolve(TEST_DID);
|
|
|
|
expect(resolver.calls).toHaveLength(1);
|
|
expect(cache.upserts).toHaveLength(1);
|
|
});
|
|
|
|
it("re-resolves when cached entry is past TTL", async () => {
|
|
const cache = new MapDidDocCache();
|
|
const resolver = new StubResolver(buildDidDoc());
|
|
let now = 1_000;
|
|
const subject = new DidResolver({
|
|
cache,
|
|
resolver,
|
|
ttlMs: 60_000,
|
|
now: () => new Date(now),
|
|
});
|
|
|
|
await subject.resolve(TEST_DID);
|
|
now = 1_000 + 60_001;
|
|
await subject.resolve(TEST_DID);
|
|
|
|
expect(resolver.calls).toHaveLength(2);
|
|
expect(cache.upserts).toHaveLength(2);
|
|
});
|
|
|
|
it("propagates resolver errors without writing to cache", async () => {
|
|
const cache = new MapDidDocCache();
|
|
const resolver = new StubResolver(buildDidDoc());
|
|
resolver.setError(new Error("plc unreachable"));
|
|
const subject = new DidResolver({ cache, resolver });
|
|
|
|
await expect(subject.resolve(TEST_DID)).rejects.toThrow("plc unreachable");
|
|
expect(cache.upserts).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects DID documents with no PDS service entry", async () => {
|
|
const cache = new MapDidDocCache();
|
|
const resolver = new StubResolver(buildDidDoc({ service: [] }));
|
|
const subject = new DidResolver({ cache, resolver });
|
|
|
|
await expect(subject.resolve(TEST_DID)).rejects.toThrow(/no atproto PDS/i);
|
|
});
|
|
|
|
it("rejects DID documents with no #atproto verification method", async () => {
|
|
const cache = new MapDidDocCache();
|
|
const resolver = new StubResolver(buildDidDoc({ verificationMethod: [] }));
|
|
const subject = new DidResolver({ cache, resolver });
|
|
|
|
await expect(subject.resolve(TEST_DID)).rejects.toThrow(/#atproto verification method/i);
|
|
});
|
|
|
|
it("rejects malformed DIDs without calling the resolver or cache", async () => {
|
|
const cache = new MapDidDocCache();
|
|
const resolver = new StubResolver(buildDidDoc());
|
|
const subject = new DidResolver({ cache, resolver });
|
|
|
|
await expect(subject.resolve("not-a-did")).rejects.toThrow(/invalid DID/i);
|
|
expect(resolver.calls).toHaveLength(0);
|
|
expect(cache.reads).toHaveLength(0);
|
|
});
|
|
|
|
it("invalidate() forces re-resolution on the next call", async () => {
|
|
const cache = new MapDidDocCache();
|
|
const resolver = new StubResolver(buildDidDoc());
|
|
// Use a real-world `now` so invalidate's "epoch" sentinel falls
|
|
// well outside the TTL window.
|
|
const subject = new DidResolver({
|
|
cache,
|
|
resolver,
|
|
ttlMs: 60_000,
|
|
now: () => new Date("2026-05-09T12:00:00.000Z"),
|
|
});
|
|
|
|
await subject.resolve(TEST_DID);
|
|
await subject.invalidate(TEST_DID);
|
|
await subject.resolve(TEST_DID);
|
|
|
|
expect(resolver.calls).toHaveLength(2);
|
|
});
|
|
|
|
it("invalidate() on an unknown DID is a no-op", async () => {
|
|
const cache = new MapDidDocCache();
|
|
const resolver = new StubResolver(buildDidDoc());
|
|
const subject = new DidResolver({ cache, resolver });
|
|
|
|
await expect(subject.invalidate(TEST_DID)).resolves.toBeUndefined();
|
|
expect(cache.upserts).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("createD1DidDocCache", () => {
|
|
beforeAll(async () => {
|
|
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await testEnv.DB.exec("DELETE FROM known_publishers");
|
|
});
|
|
|
|
it("returns null when no row exists", async () => {
|
|
const cache = createD1DidDocCache(testEnv.DB);
|
|
expect(await cache.read(TEST_DID)).toBeNull();
|
|
});
|
|
|
|
it("returns null when row exists but cache fields are unpopulated", async () => {
|
|
// Backfill or future code may insert a known_publishers row before the
|
|
// consumer has resolved the DID doc; the read should treat that as
|
|
// a cache miss, not a stale cache hit.
|
|
const now = new Date().toISOString();
|
|
await testEnv.DB.prepare(
|
|
`INSERT INTO known_publishers (did, first_seen_at, last_seen_at)
|
|
VALUES (?, ?, ?)`,
|
|
)
|
|
.bind(TEST_DID, now, now)
|
|
.run();
|
|
|
|
const cache = createD1DidDocCache(testEnv.DB);
|
|
expect(await cache.read(TEST_DID)).toBeNull();
|
|
});
|
|
|
|
it("upsert + read round-trips a cache entry", async () => {
|
|
const cache = createD1DidDocCache(testEnv.DB);
|
|
const now = new Date("2026-05-09T12:00:00.000Z");
|
|
await cache.upsert(
|
|
TEST_DID,
|
|
{
|
|
pds: TEST_PDS,
|
|
signingKey: signingKeyMultibase,
|
|
signingKeyId: `${TEST_DID}#atproto`,
|
|
},
|
|
now,
|
|
);
|
|
|
|
const out = await cache.read(TEST_DID);
|
|
expect(out).not.toBeNull();
|
|
expect(out?.pds).toBe(TEST_PDS);
|
|
expect(out?.signingKey).toBe(signingKeyMultibase);
|
|
expect(out?.signingKeyId).toBe(`${TEST_DID}#atproto`);
|
|
expect(out?.resolvedAt.toISOString()).toBe(now.toISOString());
|
|
});
|
|
|
|
it("upsert preserves first_seen_at across updates", async () => {
|
|
const cache = createD1DidDocCache(testEnv.DB);
|
|
const t1 = new Date("2026-05-09T12:00:00.000Z");
|
|
const t2 = new Date("2026-05-10T12:00:00.000Z");
|
|
|
|
await cache.upsert(
|
|
TEST_DID,
|
|
{
|
|
pds: TEST_PDS,
|
|
signingKey: signingKeyMultibase,
|
|
signingKeyId: `${TEST_DID}#atproto`,
|
|
},
|
|
t1,
|
|
);
|
|
await cache.upsert(
|
|
TEST_DID,
|
|
{
|
|
pds: TEST_PDS,
|
|
signingKey: signingKeyMultibase,
|
|
signingKeyId: `${TEST_DID}#atproto`,
|
|
},
|
|
t2,
|
|
);
|
|
|
|
const row = await testEnv.DB.prepare(
|
|
`SELECT first_seen_at, last_seen_at, pds_resolved_at FROM known_publishers WHERE did = ?`,
|
|
)
|
|
.bind(TEST_DID)
|
|
.first<{ first_seen_at: string; last_seen_at: string; pds_resolved_at: string }>();
|
|
expect(row?.first_seen_at).toBe(t1.toISOString());
|
|
expect(row?.last_seen_at).toBe(t2.toISOString());
|
|
expect(row?.pds_resolved_at).toBe(t2.toISOString());
|
|
});
|
|
|
|
it("end-to-end: resolver wired to D1 cache", async () => {
|
|
const cache = createD1DidDocCache(testEnv.DB);
|
|
const resolver = new StubResolver(buildDidDoc());
|
|
const subject = new DidResolver({ cache, resolver });
|
|
|
|
await subject.resolve(TEST_DID);
|
|
await subject.resolve(TEST_DID);
|
|
|
|
// One resolver call for the cache miss; the second resolve hits D1.
|
|
expect(resolver.calls).toHaveLength(1);
|
|
});
|
|
|
|
it("expire only touches pds_resolved_at; preserves first_seen_at and last_seen_at", async () => {
|
|
const cache = createD1DidDocCache(testEnv.DB);
|
|
const seenAt = new Date("2026-05-09T12:00:00.000Z");
|
|
await cache.upsert(
|
|
TEST_DID,
|
|
{
|
|
pds: TEST_PDS,
|
|
signingKey: signingKeyMultibase,
|
|
signingKeyId: `${TEST_DID}#atproto`,
|
|
},
|
|
seenAt,
|
|
);
|
|
|
|
await cache.expire(TEST_DID);
|
|
|
|
const row = await testEnv.DB.prepare(
|
|
`SELECT first_seen_at, last_seen_at, pds_resolved_at FROM known_publishers WHERE did = ?`,
|
|
)
|
|
.bind(TEST_DID)
|
|
.first<{ first_seen_at: string; last_seen_at: string; pds_resolved_at: string }>();
|
|
expect(row?.first_seen_at).toBe(seenAt.toISOString());
|
|
expect(row?.last_seen_at).toBe(seenAt.toISOString());
|
|
// pds_resolved_at gets pushed to epoch so the next resolve()
|
|
// sees the row as stale per the TTL check.
|
|
expect(row?.pds_resolved_at).toBe("1970-01-01T00:00:00.000Z");
|
|
});
|
|
|
|
it("expire on an unknown DID is a no-op (no row appears)", async () => {
|
|
const cache = createD1DidDocCache(testEnv.DB);
|
|
await cache.expire(TEST_DID);
|
|
const row = await testEnv.DB.prepare(`SELECT did FROM known_publishers WHERE did = ?`)
|
|
.bind(TEST_DID)
|
|
.first();
|
|
expect(row).toBeNull();
|
|
});
|
|
});
|
|
});
|