Skip to content

Commit bd32d94

Browse files
committed
fix: resolve code review findings for PR #658
P1 — Non-2xx cache responses bypassed fallback (authz.ts + fga.ts): `fetch` resolves (does not throw) for 4xx/5xx responses, so the existing `catch` block never triggered on HTTP errors. Added an explicit `response.ok` guard — the fallback to `httpClient.post` now fires for any non-2xx response from the cache, not just network- level exceptions. Fixed in both authz.ts and fga.ts. P1 — Timer leak: `clearTimeout` missing in error path (authz.ts + fga.ts): Moved `const controller/timeoutId` before the try block and moved `clearTimeout` into a `finally` clause so the timer is always cancelled regardless of success, HTTP error, or exception. Fixed in both authz.ts and fga.ts. P3 — Missing tests for non-2xx cache response fallback (authz.test.ts): Added two new test cases covering the scenario where `fetch` resolves with `{ ok: false, status: 503 }` for `whoCanAccess` and `whatCanTargetAccess`. These tests would have caught the P1 bug. Dismissed: P3 code duplication (postWithOptionalCache identical in authz.ts and fga.ts): intentional pattern match per PR description; extraction to a shared helper is a follow-up refactor. Verification: 309 tests pass, lint clean, build successful.
1 parent 4a9c8b4 commit bd32d94

File tree

3 files changed

+64
-12
lines changed

3 files changed

+64
-12
lines changed

lib/management/authz.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,54 @@ describe('Management Authz', () => {
685685
expect(fetchMock).not.toHaveBeenCalled();
686686
});
687687

688+
it('should fallback to httpClient when cache returns non-OK response for whoCanAccess', async () => {
689+
const targets = { targets: ['u1'] };
690+
const httpResponse = {
691+
ok: true,
692+
json: () => targets,
693+
clone: () => ({
694+
json: () => Promise.resolve(targets),
695+
}),
696+
status: 200,
697+
};
698+
mockHttpClient.post.mockResolvedValue(httpResponse);
699+
fetchMock.mockResolvedValue({ ok: false, status: 503, json: async () => ({}) });
700+
701+
const managementWithCache = withManagement(mockHttpClient, fgaConfig);
702+
const resp = await managementWithCache.authz.whoCanAccess('r', 'owner', 'doc');
703+
704+
expect(fetchMock).toHaveBeenCalled();
705+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.authz.who, {
706+
resource: 'r',
707+
relationDefinition: 'owner',
708+
namespace: 'doc',
709+
});
710+
expect(resp.data).toEqual(['u1']);
711+
});
712+
713+
it('should fallback to httpClient when cache returns non-OK response for whatCanTargetAccess', async () => {
714+
const relationsResponse = { relations: [mockRelation] };
715+
const httpResponse = {
716+
ok: true,
717+
json: () => relationsResponse,
718+
clone: () => ({
719+
json: () => Promise.resolve(relationsResponse),
720+
}),
721+
status: 200,
722+
};
723+
mockHttpClient.post.mockResolvedValue(httpResponse);
724+
fetchMock.mockResolvedValue({ ok: false, status: 503, json: async () => ({}) });
725+
726+
const managementWithCache = withManagement(mockHttpClient, fgaConfig);
727+
const resp = await managementWithCache.authz.whatCanTargetAccess('t');
728+
729+
expect(fetchMock).toHaveBeenCalled();
730+
expect(mockHttpClient.post).toHaveBeenCalledWith(apiPaths.authz.targetAll, {
731+
target: 't',
732+
});
733+
expect(resp.data).toEqual([mockRelation]);
734+
});
735+
688736
it('should fallback to httpClient when cache URL fetch fails for whoCanAccess', async () => {
689737
const targets = { targets: ['u1'] };
690738
const httpResponse = {

lib/management/authz.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@ const WithAuthz = (httpClient: HttpClient, config?: FGAConfig) => {
1818
const postWithOptionalCache = async (path: string, body: unknown): Promise<Response> => {
1919
if (config?.fgaCacheUrl && config.managementKey) {
2020
const url = `${config.fgaCacheUrl}${path}`;
21+
const controller = new AbortController();
22+
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_CACHE_TIMEOUT_MS);
2123

2224
try {
23-
const controller = new AbortController();
24-
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_CACHE_TIMEOUT_MS);
25-
2625
const response = await fetch(url, {
2726
method: 'POST',
2827
headers: {
@@ -35,10 +34,13 @@ const WithAuthz = (httpClient: HttpClient, config?: FGAConfig) => {
3534
signal: controller.signal,
3635
});
3736

38-
clearTimeout(timeoutId);
39-
return response;
37+
if (response.ok) {
38+
return response;
39+
}
4040
} catch {
41-
return httpClient.post(path, body);
41+
// Cache request failed (network error or timeout); fall back to origin
42+
} finally {
43+
clearTimeout(timeoutId);
4244
}
4345
}
4446
return httpClient.post(path, body);

lib/management/fga.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ const WithFGA = (httpClient: HttpClient, config?: FGAConfig) => {
1616
const postWithOptionalCache = async (path: string, body: unknown): Promise<Response> => {
1717
if (config?.fgaCacheUrl && config.managementKey) {
1818
const url = `${config.fgaCacheUrl}${path}`;
19+
const controller = new AbortController();
20+
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_CACHE_TIMEOUT_MS);
1921

2022
try {
21-
const controller = new AbortController();
22-
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_CACHE_TIMEOUT_MS);
23-
2423
const response = await fetch(url, {
2524
method: 'POST',
2625
headers: {
@@ -33,10 +32,13 @@ const WithFGA = (httpClient: HttpClient, config?: FGAConfig) => {
3332
signal: controller.signal,
3433
});
3534

36-
clearTimeout(timeoutId);
37-
return response;
35+
if (response.ok) {
36+
return response;
37+
}
3838
} catch {
39-
return httpClient.post(path, body);
39+
// Cache request failed (network error or timeout); fall back to origin
40+
} finally {
41+
clearTimeout(timeoutId);
4042
}
4143
}
4244
return httpClient.post(path, body);

0 commit comments

Comments
 (0)