Skip to content

Commit f5e2af9

Browse files
CopilotkarpikplCopilot
authored
Fix: Cache key includes query parameters to prevent stale data after 422 errors (#250)
* Initial plan * Fix: Update cache key to include query parameters and clear cache on errors Co-authored-by: karpikpl <[email protected]> * fix for caching * error message * Update shared/utils/metrics-util.ts Co-authored-by: Copilot <[email protected]> * code review comments * fix test setup --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: karpikpl <[email protected]> Co-authored-by: Piotr Karpala <[email protected]> Co-authored-by: Piotr Karpala <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 5966740 commit f5e2af9

File tree

7 files changed

+244
-42
lines changed

7 files changed

+244
-42
lines changed

app/components/MainComponent.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ export default defineNuxtComponent({
174174
const config = useRuntimeConfig();
175175
176176
this.isLoading = true;
177+
// Clear previous API errors when making a new request
178+
this.apiError = undefined;
177179
178180
try {
179181
const options = Options.fromRoute(this.route, this.dateRange.since, this.dateRange.until);
@@ -216,7 +218,7 @@ export default defineNuxtComponent({
216218
this.apiError = `404 Not Found - is the ${this.config?.public?.scope || ''} org:"${this.config?.public?.githubOrg || ''}" ent:"${this.config?.public?.githubEnt || ''}" team:"${this.config?.public?.githubTeam}" correct? ${error.message}`;
217219
break;
218220
case 422:
219-
this.apiError = `422 Unprocessable Entity - Is the Copilot Metrics API enabled for the Org/Ent? ${error.message}`;
221+
this.apiError = `422 Unprocessable Entity - Is the Copilot Metrics API enabled for the Org/Ent? When changing filters, try adjusting the "from" date. ${error.message}`;
220222
break;
221223
case 500:
222224
this.apiError = `500 Internal Server Error - most likely a bug in the app. Error: ${error.message}`;

server/api/teams.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
import { Options, type Scope } from '@/model/Options'
22
import type { H3Event, EventHandlerRequest } from 'h3'
33

4-
const cache = new Map<string, CacheData>()
5-
6-
interface CacheData {
7-
data: Team[]
8-
valid_until: number
9-
}
104
interface Team { name: string; slug: string; description: string }
115
interface GitHubTeam { name: string; slug: string; description?: string }
126

@@ -72,16 +66,6 @@ export async function getTeams(event: H3Event<EventHandlerRequest>): Promise<Tea
7266
return teams
7367
}
7468

75-
// Use cache if valid
76-
if (cache.has(event.path)) {
77-
const cachedData = cache.get(event.path)
78-
if (cachedData && cachedData.valid_until > Math.floor(Date.now() / 1000)) {
79-
logger.info(`Returning cached data for ${event.path}`)
80-
return cachedData.data
81-
}
82-
cache.delete(event.path)
83-
}
84-
8569
if (!event.context.headers.has('Authorization')) {
8670
logger.error('No Authentication provided')
8771
throw new TeamsError('No Authentication provided', 401)
@@ -114,7 +98,5 @@ export async function getTeams(event: H3Event<EventHandlerRequest>): Promise<Tea
11498
page += 1
11599
}
116100

117-
// Cache for 60 seconds
118-
cache.set(event.path, { data: allTeams, valid_until: Math.floor(Date.now() / 1000) + 60 })
119101
return allTeams
120102
}

shared/utils/metrics-util.ts

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { readFileSync } from 'fs';
55
import { resolve } from 'path';
66
import { getLocale } from "./getLocale";
77
import { filterHolidaysFromMetrics, isHoliday, parseUtcDate } from '@/utils/dateUtils';
8+
import { createHash } from 'crypto';
89

910
const cache = new Map<string, CacheData>();
1011

@@ -24,6 +25,50 @@ class MetricsError extends Error {
2425
}
2526
}
2627

28+
/**
29+
* Builds a cache key for metrics data that is bound to the caller's Authorization header (hashed) + path + query.
30+
* Exported for unit testing.
31+
*/
32+
type QueryParamValue = string | string[] | undefined;
33+
type QueryParams = Record<string, QueryParamValue>;
34+
35+
export function buildMetricsCacheKey(path: string, query: QueryParams, authHeader: string): string {
36+
// Split existing query params from provided path (if any)
37+
const [rawPath, existingQueryString] = path.split('?');
38+
const merged = new Map<string, string>();
39+
40+
// Add existing params first
41+
if (existingQueryString) {
42+
const existingParams = new URLSearchParams(existingQueryString);
43+
existingParams.forEach((value, key) => {
44+
merged.set(key, value);
45+
});
46+
}
47+
48+
// Merge in provided query object (overrides existing)
49+
Object.entries(query).forEach(([k, v]) => {
50+
if (v === undefined || v === null) return;
51+
if (Array.isArray(v)) {
52+
if (v.length === 0) return;
53+
merged.set(k, v.join(','));
54+
} else if (v !== '') {
55+
merged.set(k, v);
56+
}
57+
});
58+
59+
// Build stable, sorted query string
60+
const sortedKeys = Array.from(merged.keys()).sort();
61+
const finalParams = new URLSearchParams();
62+
sortedKeys.forEach(k => {
63+
const val = merged.get(k);
64+
if (val !== undefined) finalParams.set(k, val);
65+
});
66+
const finalQueryString = finalParams.toString();
67+
68+
const authFingerprint = createHash('sha256').update(authHeader).digest('hex').slice(0, 16); // short fingerprint
69+
return `${authFingerprint}:${rawPath}${finalQueryString ? `?${finalQueryString}` : ''}`;
70+
}
71+
2772
export async function getMetricsData(event: H3Event<EventHandlerRequest>): Promise<CopilotMetrics[]> {
2873
const logger = console;
2974
const query = getQuery(event);
@@ -52,26 +97,33 @@ export async function getMetricsData(event: H3Event<EventHandlerRequest>): Promi
5297
return usageData;
5398
}
5499

55-
if (cache.has(event.path)) {
56-
const cachedData = cache.get(event.path);
57-
if (cachedData && cachedData.valid_until > Date.now() / 1000) {
58-
logger.info(`Returning cached data for ${event.path}`);
100+
// Authorization must be validated BEFORE any cache lookup to prevent leakage of cached data
101+
const authHeader = event.context.headers.get('Authorization');
102+
if (!authHeader) {
103+
logger.error('No Authentication provided');
104+
throw new MetricsError('No Authentication provided', 401);
105+
}
106+
107+
// Build auth-bound cache key
108+
const path = event.path || '/api/metrics'; // fallback path (should always exist in practice)
109+
const cacheKey = buildMetricsCacheKey(path, query as QueryParams, authHeader);
110+
111+
// Attempt cache lookup with auth fingerprint validation
112+
const cachedData = cache.get(cacheKey);
113+
if (cachedData) {
114+
if (cachedData.valid_until > Date.now() / 1000) {
115+
logger.info(`Returning cached data for ${cacheKey}`);
59116
return cachedData.data;
60117
} else {
61-
logger.info(`Cached data for ${event.path} is expired, fetching new data`);
62-
cache.delete(event.path);
118+
logger.info(`Cached data for ${cacheKey} is expired or fingerprint mismatch, fetching new data`);
119+
cache.delete(cacheKey);
63120
}
64121
}
65122

66-
if (!event.context.headers.has('Authorization')) {
67-
logger.error('No Authentication provided');
68-
throw new MetricsError('No Authentication provided', 401);
69-
}
70-
71123
logger.info(`Fetching metrics data from ${apiUrl}`);
72124

73125
try {
74-
const response = await $fetch(apiUrl, {
126+
const response = await $fetch(apiUrl, {
75127
headers: event.context.headers
76128
}) as unknown[];
77129

@@ -81,10 +133,12 @@ export async function getMetricsData(event: H3Event<EventHandlerRequest>): Promi
81133
const filteredUsageData = filterHolidaysFromMetrics(usageData, options.excludeHolidays || false, options.locale);
82134
// metrics is the old API format
83135
const validUntil = Math.floor(Date.now() / 1000) + 5 * 60; // Cache for 5 minutes
84-
cache.set(event.path, { data: filteredUsageData, valid_until: validUntil });
136+
cache.set(cacheKey, { data: filteredUsageData, valid_until: validUntil });
85137
return filteredUsageData;
86138
} catch (error: unknown) {
87139
logger.error('Error fetching metrics data:', error);
140+
// Clear any cached data for this request to prevent stale data on retry
141+
cache.delete(cacheKey);
88142
const errorMessage = error instanceof Error ? error.message : String(error);
89143
const statusCode = (error && typeof error === 'object' && 'statusCode' in error)
90144
? (error as { statusCode: number }).statusCode
@@ -125,17 +179,12 @@ function updateMockDataDates(originalData: CopilotMetrics[], since?: string, unt
125179
}
126180

127181
// Update dates in the dataset, copying existing entries when needed
128-
const result = dateRange.map((date, index) => {
129-
// Use existing data entries, cycling through them
182+
const result: CopilotMetrics[] = dateRange.map((date, index) => {
130183
const dataIndex = index % originalData.length;
131-
const entry = { ...originalData[dataIndex] };
132-
133-
// Update the date
134-
entry.date = date.toISOString().split('T')[0];
135-
136-
return entry;
184+
const src = originalData[dataIndex];
185+
const newDate = date.toISOString().split('T')[0];
186+
return { ...src, date: newDate };
137187
});
138-
139188
return result;
140189
}
141190

tests/metrics-auth-cache.nuxt.spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// @vitest-environment nuxt
2+
import { describe, it, expect } from 'vitest'
3+
import { buildMetricsCacheKey } from '../shared/utils/metrics-util'
4+
5+
/**
6+
* These tests validate that the new cache key generation logic binds cache entries
7+
* to the caller's Authorization header fingerprint and that different auth headers
8+
* produce distinct keys even when path + query are identical.
9+
*/
10+
11+
describe('Metrics Auth-Bound Cache Key', () => {
12+
const path = '/api/metrics'
13+
const query = { since: '2024-01-01', until: '2024-01-31', scope: 'organization', githubOrg: 'test-org' }
14+
15+
it('produces different keys for different auth headers', () => {
16+
const keyA1 = buildMetricsCacheKey(path, query, 'token userA-1')
17+
const keyA2 = buildMetricsCacheKey(path, query, 'token userA-2')
18+
const keyB = buildMetricsCacheKey(path, query, 'token userB-1')
19+
20+
expect(keyA1).not.toBe(keyA2)
21+
expect(keyA1.split(':')[0]).not.toBe(keyA2.split(':')[0])
22+
expect(keyA1.split(':')[0]).not.toBe(keyB.split(':')[0])
23+
})
24+
25+
it('is stable for same auth header + query', () => {
26+
const key1 = buildMetricsCacheKey(path, query, 'token stable-user')
27+
const key2 = buildMetricsCacheKey(path, query, 'token stable-user')
28+
expect(key1).toBe(key2)
29+
})
30+
31+
it('filters out undefined/empty query params', () => {
32+
const key = buildMetricsCacheKey(path, { since: '2024-01-01', empty: '', undef: undefined }, 'token x')
33+
expect(key).toContain('since=2024-01-01')
34+
expect(key).not.toContain('empty=')
35+
expect(key).not.toContain('undef=')
36+
})
37+
38+
it('joins array query params deterministically', () => {
39+
const key = buildMetricsCacheKey(path, { since: ['2024-01-01'], tag: ['a','b'] }, 'token y')
40+
expect(key).toContain('since=2024-01-01')
41+
expect(key).toMatch(/tag=a%2Cb|tag=b%2Ca/) // order preserved from input array join
42+
})
43+
44+
it('merges existing path query params with provided query object without duplication', () => {
45+
const pathWithQuery = '/api/metrics?since=2024-01-01&scope=organization'
46+
const key = buildMetricsCacheKey(pathWithQuery, { githubOrg: 'test-org', since: '2024-01-01' }, 'token z')
47+
// should not duplicate since param and should include githubOrg
48+
const pieces = key.split(':')
49+
expect(pieces.length).toBeGreaterThan(1)
50+
const qs = pieces.slice(1).join(':').split('?')[1]
51+
expect(qs?.match(/since=/g)?.length).toBe(1)
52+
expect(qs).toContain('githubOrg=test-org')
53+
})
54+
55+
it('sorts final query params for stable ordering', () => {
56+
const key1 = buildMetricsCacheKey('/api/metrics?b=2&a=1', { c: '3' }, 'token sort')
57+
const key2 = buildMetricsCacheKey('/api/metrics?a=1&b=2', { c: '3' }, 'token sort')
58+
expect(key1).toBe(key2)
59+
const qs = key1.split('?')[1]
60+
expect(qs).toBeDefined()
61+
expect(qs!.startsWith('a=1&b=2&c=3')).toBe(true)
62+
})
63+
})

tests/metrics-cache.nuxt.spec.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// @vitest-environment nuxt
2+
import { describe, it, expect, beforeEach } from 'vitest'
3+
4+
describe('Metrics Cache Key Generation', () => {
5+
it('should create unique cache keys for different query parameters', () => {
6+
// Test the cache key logic that was implemented
7+
const createCacheKey = (path: string, query: Record<string, string>) => {
8+
const queryString = new URLSearchParams(query).toString()
9+
return queryString ? `${path}?${queryString}` : path
10+
}
11+
12+
const path = '/api/metrics'
13+
14+
// Different date ranges should create different cache keys
15+
const query1 = { since: '2024-01-01', until: '2024-01-31', scope: 'organization', githubOrg: 'test-org' }
16+
const query2 = { since: '2024-02-01', until: '2024-02-28', scope: 'organization', githubOrg: 'test-org' }
17+
const query3 = { since: '2024-01-01', until: '2024-01-31', scope: 'organization', githubOrg: 'test-org' }
18+
19+
const key1 = createCacheKey(path, query1)
20+
const key2 = createCacheKey(path, query2)
21+
const key3 = createCacheKey(path, query3)
22+
23+
// Different date ranges should have different keys
24+
expect(key1).not.toBe(key2)
25+
26+
// Same parameters should have same key
27+
expect(key1).toBe(key3)
28+
29+
// Keys should include query parameters
30+
expect(key1).toContain('since=2024-01-01')
31+
expect(key1).toContain('until=2024-01-31')
32+
expect(key2).toContain('since=2024-02-01')
33+
expect(key2).toContain('until=2024-02-28')
34+
})
35+
36+
it('should handle empty query parameters', () => {
37+
const createCacheKey = (path: string, query: Record<string, string>) => {
38+
const queryString = new URLSearchParams(query).toString()
39+
return queryString ? `${path}?${queryString}` : path
40+
}
41+
42+
const path = '/api/metrics'
43+
const emptyQuery = {}
44+
45+
const key = createCacheKey(path, emptyQuery)
46+
expect(key).toBe(path)
47+
})
48+
49+
it('should handle undefined query values', () => {
50+
const createCacheKey = (path: string, query: Record<string, any>) => {
51+
// Filter out undefined values before creating query string
52+
const filteredQuery = Object.fromEntries(
53+
Object.entries(query).filter(([_, value]) => value !== undefined)
54+
)
55+
const queryString = new URLSearchParams(filteredQuery).toString()
56+
return queryString ? `${path}?${queryString}` : path
57+
}
58+
59+
const path = '/api/metrics'
60+
const queryWithUndefined = { since: '2024-01-01', until: undefined, scope: 'organization' }
61+
62+
const key = createCacheKey(path, queryWithUndefined)
63+
expect(key).toContain('since=2024-01-01')
64+
expect(key).toContain('scope=organization')
65+
expect(key).not.toContain('until=')
66+
})
67+
})

tests/test.setup.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as urlLib from 'url';
2+
3+
// Global test setup
4+
// Force mock mode so server-side handlers use local mock data instead of hitting GitHub APIs.
5+
process.env.NUXT_PUBLIC_IS_DATA_MOCKED = 'true';
6+
7+
function isGitHub(url: string): boolean {
8+
const host = urlLib.parse(url).host;
9+
return host === 'api.github.com' || host === 'api.github.com:443';
10+
}
11+
12+
// Block accidental real GitHub API calls if mock mode logic fails.
13+
const originalFetch: typeof globalThis.fetch | undefined = globalThis.fetch;
14+
if (originalFetch) {
15+
globalThis.fetch = (async (...args: Parameters<typeof fetch>): Promise<Response> => {
16+
const url = String(args[0]);
17+
if (isGitHub(url)) {
18+
throw new Error(`Blocked external GitHub API call during tests: ${url}`);
19+
}
20+
return originalFetch(...args);
21+
}) as typeof fetch;
22+
}
23+
24+
// Stub $fetch (ofetch) similarly if present at runtime.
25+
if (typeof globalThis.$fetch === 'function') {
26+
const original$fetch = globalThis.$fetch;
27+
const wrapped: typeof globalThis.$fetch = ((url: unknown, opts: unknown) => {
28+
const str = String(url);
29+
if (isGitHub(str)) {
30+
return Promise.reject(new Error(`Blocked external GitHub API call during tests via $fetch: ${str}`));
31+
}
32+
return original$fetch(url as never, opts as never);
33+
}) as typeof globalThis.$fetch;
34+
// Preserve special properties if present
35+
(wrapped as unknown as { raw?: unknown }).raw = (original$fetch as unknown as { raw?: () => unknown }).raw?.bind(original$fetch);
36+
(wrapped as unknown as { create?: unknown }).create = (original$fetch as unknown as { create?: () => unknown }).create?.bind(original$fetch);
37+
globalThis.$fetch = wrapped;
38+
}

vitest.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default defineVitestConfig({
55
test: {
66
exclude: ['**/node_modules/**', '**/e2e-tests/**'],
77
environment: 'nuxt',
8-
globals: true // Use describe, test/expect, etc. without importing
8+
globals: true, // Use describe, test/expect, etc. without importing
9+
setupFiles: ['./tests/test.setup.ts']
910
}
1011
})

0 commit comments

Comments
 (0)