diff --git a/.changeset/public-search-suggestions.md b/.changeset/public-search-suggestions.md new file mode 100644 index 000000000..b8672b834 --- /dev/null +++ b/.changeset/public-search-suggestions.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes public LiveSearch autocomplete requests being blocked by auth middleware. diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 29bf57f96..151e5fe10 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -119,6 +119,7 @@ const PUBLIC_API_EXACT = new Set([ // so unauthenticated callers only see published content. Admin endpoints // (/enable, /rebuild, /stats) remain private because they're not in this set. "/_emdash/api/search", + "/_emdash/api/search/suggest", ]); // Build merged public routes at module load from auth provider descriptors. diff --git a/packages/core/tests/integration/search/suggest.test.ts b/packages/core/tests/integration/search/suggest.test.ts index df917f90b..edc5e91e8 100644 --- a/packages/core/tests/integration/search/suggest.test.ts +++ b/packages/core/tests/integration/search/suggest.test.ts @@ -47,6 +47,23 @@ describe("getSuggestions (Integration)", () => { }); }); + it("does not return draft content", async () => { + await repo.create( + createPostFixture({ + slug: "designing-secret-things", + status: "draft", + data: { title: "Designing secret things" }, + }), + ); + + const suggestions = await getSuggestions(db, "des", { + collections: ["post"], + }); + + expect(suggestions).toHaveLength(1); + expect(suggestions[0]?.title).toBe("Designing things"); + }); + it("returns empty array for a non-matching query", async () => { const suggestions = await getSuggestions(db, "zzz", { collections: ["post"], diff --git a/packages/core/tests/unit/middleware/oauth-csrf.test.ts b/packages/core/tests/unit/middleware/oauth-csrf.test.ts index cd298344a..05db300bd 100644 --- a/packages/core/tests/unit/middleware/oauth-csrf.test.ts +++ b/packages/core/tests/unit/middleware/oauth-csrf.test.ts @@ -37,6 +37,7 @@ async function runAuthMiddleware(opts: { extraHeaders?: Record; }): Promise<{ response: Response; next: ReturnType }> { const url = new URL(opts.pathname, "https://site.example.com"); + const method = opts.method ?? "POST"; const headers: Record = { "Content-Type": "application/json", ...opts.extraHeaders, @@ -53,9 +54,9 @@ async function runAuthMiddleware(opts: { { url, request: new Request(url, { - method: opts.method ?? "POST", + method, headers, - body: "{}", + body: method === "GET" || method === "HEAD" ? undefined : "{}", }), locals: { emdash: { db: {}, config: {} }, @@ -70,6 +71,31 @@ async function runAuthMiddleware(opts: { return { response, next }; } +describe("Public search API routes", () => { + it.each(["/_emdash/api/search", "/_emdash/api/search/suggest"])( + "allows anonymous GET to %s", + async (pathname) => { + const { response, next } = await runAuthMiddleware({ + pathname, + method: "GET", + }); + + expect(next).toHaveBeenCalledOnce(); + expect(response.status).toBe(200); + }, + ); + + it("keeps search management endpoints private", async () => { + const { response, next } = await runAuthMiddleware({ + pathname: "/_emdash/api/search/rebuild", + method: "GET", + }); + + expect(next).not.toHaveBeenCalled(); + expect(response.status).toBe(401); + }); +}); + /** * OAuth protocol endpoints (RFC 6749, 7591, 8628) are designed to be called * cross-origin. They must bypass the Origin-based CSRF check that applies to