diff --git a/src/services/wikis.ts b/src/services/wikis.ts index c4c580e..6962f96 100644 --- a/src/services/wikis.ts +++ b/src/services/wikis.ts @@ -3,7 +3,10 @@ * Provides functions to inspect, search, and list OpenProject wiki pages and wiki page links. */ -import type { OpenProjectClient } from "../client/api-client.ts"; +import { + OpenProjectAuthenticationError, + type OpenProjectClient, +} from "../client/api-client.ts"; import { extractIdFromHref } from "../client/hal-parser.ts"; import type { HalCollection, HalLink, HalResource } from "../client/types.ts"; import { resolveClient, resolveProjectId } from "./helper.ts"; @@ -261,6 +264,10 @@ export async function searchWikiPages( let cacheMap = wikiPagesCache.get(cacheKey); const needsDiscovery = !cacheMap || !discoveredBaseUrls.has(cacheKey) || params?.refreshCache; + if (params?.refreshCache && cacheMap) { + cacheMap.clear(); + } + if (!cacheMap) { cacheMap = new Map(); wikiPagesCache.set(cacheKey, cacheMap); @@ -288,8 +295,16 @@ export async function searchWikiPages( } } } - } catch { - // Ignore errors if wiki_page_links is not supported or accessible + } catch (err: unknown) { + if ( + err instanceof OpenProjectAuthenticationError || + (err as { statusCode?: number })?.statusCode === 401 || + (err as { statusCode?: number })?.statusCode === 403 || + (err as { statusCode?: number })?.statusCode === 429 + ) { + throw err; + } + // Ignore errors if wiki_page_links is not supported or accessible (e.g. 404) } // 2. Sequential probing of IDs 1..MAX_PROBE_ID with consecutive 404 cutoff @@ -302,8 +317,18 @@ export async function searchWikiPages( const pageDetail = await getWikiPage(id, opClient); cacheMap.set(id, pageDetail); consecutiveMisses = 0; - } catch { - consecutiveMisses++; + } catch (err: unknown) { + if ( + err instanceof OpenProjectAuthenticationError || + (err as { statusCode?: number })?.statusCode === 401 || + (err as { statusCode?: number })?.statusCode === 403 || + (err as { statusCode?: number })?.statusCode === 429 + ) { + throw err; + } + if ((err as { statusCode?: number })?.statusCode === 404) { + consecutiveMisses++; + } if (consecutiveMisses >= CONSECUTIVE_404_CUTOFF) { break; } @@ -316,7 +341,15 @@ export async function searchWikiPages( try { const pageDetail = await getWikiPage(id, opClient); cacheMap.set(id, pageDetail); - } catch { + } catch (err: unknown) { + if ( + err instanceof OpenProjectAuthenticationError || + (err as { statusCode?: number })?.statusCode === 401 || + (err as { statusCode?: number })?.statusCode === 403 || + (err as { statusCode?: number })?.statusCode === 429 + ) { + throw err; + } // Ignore missing pages } } diff --git a/tests/wikis.test.ts b/tests/wikis.test.ts index 771ca99..129b567 100644 --- a/tests/wikis.test.ts +++ b/tests/wikis.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test, beforeEach } from "bun:test"; -import { OpenProjectClient } from "../src/client/api-client.ts"; +import { + OpenProjectAuthenticationError, + OpenProjectClient, +} from "../src/client/api-client.ts"; import { runWithContext, type RequestContext } from "../src/context.ts"; import { clearWikiCache, @@ -382,4 +385,97 @@ describe("Wikis Service", () => { expect(searchRes[0]!.title).toBe("Context Wiki"); }); }); + + test("searchWikiPages re-throws 401 authentication errors during discovery instead of swallowing", async () => { + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + const err = new OpenProjectAuthenticationError("Invalid API key provided"); + throw err; + }, + } as unknown as OpenProjectClient; + + expect(searchWikiPages({}, mockClient)).rejects.toThrow("Invalid API key provided"); + }); + + test("searchWikiPages re-throws 403 and 429 errors during discovery probe", async () => { + const mockForbiddenClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + const err = new Error("Access denied"); + (err as unknown as { statusCode: number }).statusCode = 403; + throw err; + }, + } as unknown as OpenProjectClient; + + expect(searchWikiPages({}, mockForbiddenClient)).rejects.toThrow("Access denied"); + + const mockRateLimitClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + const err = new Error("Too many requests"); + (err as unknown as { statusCode: number }).statusCode = 429; + throw err; + }, + } as unknown as OpenProjectClient; + + expect(searchWikiPages({}, mockRateLimitClient)).rejects.toThrow("Too many requests"); + }); + + test("searchWikiPages with refreshCache evicts stale deleted pages from cache", async () => { + let page2Exists = true; + const mockClient = { + baseUrl: "http://localhost:8080", + get: async (path: string) => { + if (path.startsWith("/api/v3/wiki_page_links")) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "/api/v3/wiki_pages/1") { + return { + _type: "WikiPage", + id: 1, + title: "First Page", + _links: { project: { href: "/api/v3/projects/1", title: "Demo" } }, + }; + } + if (path === "/api/v3/wiki_pages/1/attachments") { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + if (path === "/api/v3/wiki_pages/2" && page2Exists) { + return { + _type: "WikiPage", + id: 2, + title: "Second Page", + _links: { project: { href: "/api/v3/projects/1", title: "Demo" } }, + }; + } + if (path === "/api/v3/wiki_pages/2/attachments" && page2Exists) { + return { _type: "Collection", total: 0, _embedded: { elements: [] } }; + } + const err = new Error("Not found"); + (err as unknown as { statusCode: number }).statusCode = 404; + throw err; + }, + } as unknown as OpenProjectClient; + + const initialResults = await searchWikiPages({}, mockClient); + expect(initialResults).toHaveLength(2); + + // Page 2 is subsequently deleted on OpenProject + page2Exists = false; + + // Refreshing cache should clear stale page 2 from memory + const refreshedResults = await searchWikiPages({ refreshCache: true }, mockClient); + expect(refreshedResults).toHaveLength(1); + expect(refreshedResults[0]!.id).toBe(1); + }); });