fix: resolve final review findings (wiki cache tenant isolation, meeting search pagination, ambient test)

This commit is contained in:
Lucas Winther committed 2026-09-07 03:12:55 +02:00
1 parent e0aa91e388
commit 7097d1c174
8 files changed
+353 -21

No files matched your search

+20
View File
@@ -442,6 +442,26 @@ describe("OpenProjectClient (Mocked Unit Tests)", () => {
expect(capturedHeaders?.get("Accept")).toContain("application/hal+json");
});
test("getCacheKey returns unique key scoped to both baseUrl and apiKey", () => {
const clientA = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "user-key-a",
});
const clientB = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "user-key-b",
});
const clientC = new OpenProjectClient({
baseUrl: "https://other.example.com",
apiKey: "user-key-a",
});
expect(clientA.getCacheKey()).toBe("https://openproject.example.com#user-key-a");
expect(clientB.getCacheKey()).toBe("https://openproject.example.com#user-key-b");
expect(clientC.getCacheKey()).toBe("https://other.example.com#user-key-a");
expect(clientA.getCacheKey()).not.toBe(clientB.getCacheKey());
});
test("sanitizes API credentials from error messages", async () => {
const secretKey = "super-secret-key-xyz-999";
const mockFetch = async (): Promise<Response> => {
+65
View File
@@ -252,6 +252,71 @@ describe("Meetings Service", () => {
expect(res.elements[0]!.meeting.id).toBe(2);
});
test("searchMeetings iterates candidate pages when total meetings exceed single page size", async () => {
const requestedOffsets: number[] = [];
const mockClient = {
get: async (path: string) => {
if (path.startsWith("/api/v3/meetings?")) {
const url = new URL(`http://dummy${path}`);
const offset = Number(url.searchParams.get("offset") ?? "1");
requestedOffsets.push(offset);
if (offset === 1) {
// Page 1: 50 meetings, none matching query
const page1Elements = Array.from({ length: 50 }, (_, i) => ({
_type: "Meeting",
id: i + 1,
title: `Daily Routine ${i + 1}`,
state: "open",
startTime: "2026-09-01T10:00:00Z",
endTime: "2026-09-01T10:30:00Z",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
}));
return {
_type: "Collection",
total: 55,
count: 50,
pageSize: 50,
offset: 1,
_embedded: { elements: page1Elements },
};
}
if (offset === 2) {
// Page 2: 5 meetings, meeting #52 has matching title
const page2Elements = Array.from({ length: 5 }, (_, i) => ({
_type: "Meeting",
id: 51 + i,
title: i === 1 ? "Special Architecture Sync" : `Daily Routine ${51 + i}`,
state: "open",
startTime: "2026-09-02T10:00:00Z",
endTime: "2026-09-02T10:30:00Z",
_links: { project: { href: "/api/v3/projects/1", title: "Demo" } },
}));
return {
_type: "Collection",
total: 55,
count: 5,
pageSize: 50,
offset: 2,
_embedded: { elements: page2Elements },
};
}
}
if (path.includes("/agenda_items")) {
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
}
throw new Error(`Unexpected path: ${path}`);
},
} as unknown as OpenProjectClient;
const res = await searchMeetings({ query: "Architecture" }, mockClient);
expect(requestedOffsets).toEqual([1, 2]);
expect(res.total).toBe(1);
expect(res.elements[0]!.meeting.id).toBe(52);
expect(res.elements[0]!.meeting.title).toBe("Special Architecture Sync");
});
test("listMeetings supports time filter (upcoming and past)", async () => {
let capturedPath = "";
const mockClient = {
+177
View File
@@ -478,4 +478,181 @@ describe("Wikis Service", () => {
expect(refreshedResults).toHaveLength(1);
expect(refreshedResults[0]!.id).toBe(1);
});
test("maintains isolated wiki page cache across different clients with same baseUrl but different API keys", async () => {
let clientACalls = 0;
let clientBCalls = 0;
const mockFetchA = async (input: string | URL | Request) => {
clientACalls++;
const url = String(input);
if (url.includes("/api/v3/wiki_pages/1/attachments")) {
return new Response(
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
return new Response(
JSON.stringify({
_type: "WikiPage",
id: 1,
title: "User A Confidential Wiki",
_links: { project: { href: "/api/v3/projects/1", title: "Project A" } },
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
};
const mockFetchB = async (input: string | URL | Request) => {
clientBCalls++;
const url = String(input);
if (url.includes("/api/v3/wiki_pages/1/attachments")) {
return new Response(
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
return new Response(
JSON.stringify({
_type: "WikiPage",
id: 1,
title: "User B Confidential Wiki",
_links: { project: { href: "/api/v3/projects/2", title: "Project B" } },
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
};
const clientA = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "api-key-tenant-a",
fetchFn: mockFetchA,
});
const clientB = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "api-key-tenant-b",
fetchFn: mockFetchB,
});
// Client A fetches wiki page 1 -> cached in client A's bucket
const pageA = await getWikiPage(1, clientA);
expect(pageA.title).toBe("User A Confidential Wiki");
expect(clientACalls).toBe(2);
// Client B fetches wiki page 1 -> must NOT use client A's cache
const pageB = await getWikiPage(1, clientB);
expect(pageB.title).toBe("User B Confidential Wiki");
expect(clientBCalls).toBe(2);
// Repeat fetch with client A -> returns User A data without refetching from server
const pageA2 = await getWikiPage(1, clientA);
expect(pageA2.title).toBe("User A Confidential Wiki");
});
test("maintains isolated wiki search discovery cache across different API keys", async () => {
const mockFetchA = async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("/api/v3/wiki_page_links")) {
return new Response(
JSON.stringify({
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
id: 101,
_links: { wikiPage: { href: "/api/v3/wiki_pages/10" } },
},
],
},
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
if (url.includes("/api/v3/wiki_pages/10/attachments")) {
return new Response(
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
if (url.includes("/api/v3/wiki_pages/10")) {
return new Response(
JSON.stringify({
_type: "WikiPage",
id: 10,
title: "Tenant A Page",
_links: { project: { href: "/api/v3/projects/1", title: "Project A" } },
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
return new Response(JSON.stringify({ message: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
};
const mockFetchB = async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("/api/v3/wiki_page_links")) {
return new Response(
JSON.stringify({
_type: "Collection",
total: 1,
_embedded: {
elements: [
{
id: 201,
_links: { wikiPage: { href: "/api/v3/wiki_pages/20" } },
},
],
},
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
if (url.includes("/api/v3/wiki_pages/20/attachments")) {
return new Response(
JSON.stringify({ _type: "Collection", total: 0, _embedded: { elements: [] } }),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
if (url.includes("/api/v3/wiki_pages/20")) {
return new Response(
JSON.stringify({
_type: "WikiPage",
id: 20,
title: "Tenant B Page",
_links: { project: { href: "/api/v3/projects/2", title: "Project B" } },
}),
{ headers: { "Content-Type": "application/hal+json" } }
);
}
return new Response(JSON.stringify({ message: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
};
const clientA = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "api-key-tenant-a",
fetchFn: mockFetchA,
});
const clientB = new OpenProjectClient({
baseUrl: "https://openproject.example.com",
apiKey: "api-key-tenant-b",
fetchFn: mockFetchB,
});
const searchResultsA = await searchWikiPages({}, clientA);
expect(searchResultsA).toHaveLength(1);
expect(searchResultsA[0]!.title).toBe("Tenant A Page");
const searchResultsB = await searchWikiPages({}, clientB);
expect(searchResultsB).toHaveLength(1);
expect(searchResultsB[0]!.title).toBe("Tenant B Page");
});
});
+40
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { OpenProjectClient } from "../src/client/api-client.ts";
import { runWithContext, type RequestContext } from "../src/context.ts";
import { listWorkPackageActivities } from "../src/services/work-packages.ts";
describe("Work Package Activities Service", () => {
@@ -97,4 +98,43 @@ describe("Work Package Activities Service", () => {
const res = await listWorkPackageActivities({ workPackageId: 10 }, mockClient);
expect(res).toEqual([]);
});
test("resolves ambient RequestContext client when client parameter is omitted", async () => {
let calledPath = "";
const ambientClient = {
get: async (path: string) => {
calledPath = path;
return {
_type: "Collection",
total: 1,
count: 1,
_embedded: {
elements: [
{
id: 101,
version: 1,
createdAt: "2026-09-07T00:00:00Z",
comment: { raw: "Ambient resolution test comment" },
details: [],
},
],
},
};
},
} as unknown as OpenProjectClient;
const context: RequestContext = {
client: ambientClient,
isReadOnly: false,
};
await runWithContext(context, async () => {
const res = await listWorkPackageActivities({ workPackageId: 77 });
expect(calledPath).toBe("/api/v3/work_packages/77/activities");
expect(res).toHaveLength(1);
expect(res[0]!.id).toBe(101);
expect(res[0]!.comment).toBe("Ambient resolution test comment");
expect(res[0]!.isComment).toBe(true);
});
});
});