import type { MiddlewareKeyVariables } from '../supabase/functions/_backend/utils/hono.ts' import { Hono } from 'hono' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { changelogDigest, getReleases } from '../supabase/functions/_backend/private/changelog.ts' import { dependencySource, getDependency } from '../supabase/functions/_backend/utils/changelog_dependencies.ts' import { fetchAnonymousGithub } from '../supabase/functions/_backend/utils/changelog_github.ts' const edge = new Map() const shared = new Map() const kv = { get: vi.fn(async (key: string) => { const hit = shared.get(key) return hit && hit.expires > Date.now() ? JSON.parse(hit.value) : null }), put: vi.fn(async (key: string, value: string, options: { expirationTtl: number }) => { shared.set(key, { value, expires: Date.now() + options.expirationTtl * 1000 }) }), } const ai = { run: vi.fn() } const fetchMock = vi.fn() let notes: string const app = new Hono() app.use('*', async (c, next) => { c.set('auth', { userId: c.req.header('X-Test-User') ?? '00000000-0000-4000-8000-000000000001', authType: 'jwt', apikey: null, jwt: null }) c.set('requestId', 'changelog-test') await next() }) app.post('/digest', async c => c.json(await changelogDigest(c, await c.req.json()))) app.get('/dependency', async c => c.json(await getDependency(c, c.req.query('name')))) app.get('/releases', async c => c.json(await getReleases(c, { owner: 'ionic-team', repo: 'capacitor', tagPrefix: '' }))) app.get('/console-releases', async c => c.json(await getReleases(c, { repo: 'capgo.app', tagPrefix: 'capgo-' }))) app.get('/public-github', c => fetchAnonymousGithub(c, 'https://api.github.com/repos/example/plugin/releases', { 'User-Agent': 'capgo-app' })) app.get('/plugin-releases', async (c) => { const dependency = await getDependency(c, '@capacitor/camera') if (!dependency.source) throw new Error('Missing test repository') return c.json(await getReleases(c, dependency.source)) }) function digest(body: Record = {}, env: Record = {}, user?: string) { return app.request('https://api.example.com/digest', { method: 'POST', headers: { 'Content-Type': 'application/json', ...user ? { 'X-Test-User': user } : {} }, body: JSON.stringify({ product: 'capacitor', fromVersion: '8.0.0', toVersion: '8.1.0', ...body }), }, { AI: ai, CHANNEL_SELF_STORE: kv, ...env }) } beforeEach(() => { edge.clear() shared.clear() vi.clearAllMocks() notes = '### Bug Fixes\n* **android:** fix startup crash' vi.stubGlobal('caches', { open: async () => ({ match: async (key: Request) => { const hit = edge.get(key.url) return hit && hit.expires > Date.now() ? hit.response.clone() : undefined }, put: async (key: Request, response: Response) => { const ttl = Number(/s-maxage=(\d+)/.exec(response.headers.get('Cache-Control') ?? '')?.[1]) edge.set(key.url, { response: response.clone(), expires: Date.now() + ttl * 1000 }) }, }), }) fetchMock.mockImplementation(async (url: string) => { if (url.startsWith('https://registry.npmjs.org/')) return Response.json({ name: '@capacitor/camera', version: '8.1.0', repository: { url: 'git+https://github.com/ionic-team/capacitor-camera.git' } }) return Response.json([{ tag_name: '8.1.0', body: notes, html_url: 'https://github.com/ionic-team/capacitor/releases/tag/8.1.0' }]) }) vi.stubGlobal('fetch', fetchMock) ai.run.mockResolvedValue({ response: { headline: 'Android startup fixes.', highlights: [{ title: 'Startup crash', detail: 'Fixed on Android.', version: '8.1.0', type: 'fixed', impact: 'medium', relevant: false }], actionRequired: [] } }) }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllEnvs() vi.unstubAllGlobals() vi.useRealTimers() }) describe('shared changelog caching', () => { it('shares concurrent release and AI misses, then reuses the summary for another user and colo', async () => { const responses = await Promise.all([digest(), digest(), digest()]) for (const response of responses) expect(await response.json()).toMatchObject({ aiGenerated: true }) expect(fetchMock).toHaveBeenCalledOnce() expect(ai.run).toHaveBeenCalledOnce() expect(kv.put).toHaveBeenCalledWith(expect.stringMatching(/^changelog:digest:/), expect.any(String), { expirationTtl: 604800 }) edge.clear() expect(await (await digest({}, {}, '00000000-0000-4000-8000-000000000002')).json()).toMatchObject({ aiGenerated: true }) expect(ai.run).toHaveBeenCalledOnce() expect(fetchMock).toHaveBeenCalledOnce() }) it('invalidates summaries for edited notes, models, locales and version bounds', async () => { await digest() notes = '### Bug Fixes\n* **android:** fix startup crash and preserve settings' vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 901_000) edge.clear() await digest() vi.stubEnv('CHANGELOG_MODEL', 'test-model') await digest() vi.unstubAllEnvs() await digest({ locale: 'fr' }) await digest({ fromVersion: '8.0.1' }) expect(ai.run).toHaveBeenCalledTimes(5) }) it('canonicalizes context order so equivalent requests reuse one AI summary', async () => { await digest({ context: { platforms: ['ios', 'android'], features: ['delta', 'encryption'] } }) await digest({ context: { features: ['encryption', 'delta'], platforms: ['android', 'ios', 'android'] } }) expect(ai.run).toHaveBeenCalledOnce() }) it('backs off briefly after AI failure and recovers instead of caching a fallback for a week', async () => { ai.run.mockResolvedValueOnce({ response: '' }) expect(await (await digest()).json()).toMatchObject({ aiGenerated: false }) expect(await (await digest()).json()).toMatchObject({ aiGenerated: false }) expect(ai.run).toHaveBeenCalledOnce() vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000) expect(await (await digest()).json()).toMatchObject({ aiGenerated: true }) expect(ai.run).toHaveBeenCalledTimes(2) }) }) describe('release fetch reliability', () => { it('returns missing repositories as 404 without reporting server errors', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}) fetchMock.mockResolvedValueOnce(Response.json({ message: 'Not Found' }, { status: 404 })) expect((await app.request('https://api.example.com/releases')).status).toBe(404) expect(log).not.toHaveBeenCalled() }) it('backs off across anonymous repos while cached and authenticated releases remain available', async () => { vi.stubEnv('GITHUB_TOKEN', 'fake-github-token') const now = Date.now() const clock = vi.spyOn(Date, 'now').mockReturnValue(now) expect((await app.request('https://api.example.com/releases')).status).toBe(200) fetchMock.mockResolvedValueOnce(Response.json({}, { status: 403, headers: { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(Math.ceil(now / 1000) + 120) }, })) const limited = await app.request('https://api.example.com/public-github') expect(limited.status).toBe(429) expect(Number(limited.headers.get('retry-after'))).toBeGreaterThanOrEqual(120) expect((await app.request('https://api.example.com/public-github')).status).toBe(429) expect((await app.request('https://api.example.com/releases')).status).toBe(200) expect(fetchMock).toHaveBeenCalledTimes(2) // A different uncached dependency shares the anonymous quota, but not the private token. expect((await app.request('https://api.example.com/plugin-releases')).status).toBe(429) expect(fetchMock).toHaveBeenCalledTimes(3) // npm metadata only expect((await app.request('https://api.example.com/console-releases')).status).toBe(200) expect(fetchMock).toHaveBeenCalledTimes(4) clock.mockReturnValue(now + 122_000) expect((await app.request('https://api.example.com/public-github')).status).toBe(200) expect(fetchMock).toHaveBeenCalledTimes(5) for (const [url, options] of fetchMock.mock.calls) { const parsed = new URL(url) if (parsed.hostname === 'api.github.com' && !parsed.pathname.startsWith('/repos/Cap-go/')) expect(options.headers).not.toHaveProperty('Authorization') } }) it.each<{ status: number, headers: Record, message: string, seconds: number }>([ { status: 429, headers: { 'retry-after': '120', 'x-ratelimit-remaining': '20', 'x-ratelimit-reset': '9999999999' }, message: '', seconds: 120 }, { status: 429, headers: { 'retry-after': 'invalid' }, message: '', seconds: 60 }, { status: 403, headers: {}, message: 'You have exceeded a secondary rate limit.', seconds: 60 }, ])('respects secondary backoff for $status and $seconds seconds', async ({ status, headers, message, seconds }) => { const now = Date.now() const clock = vi.spyOn(Date, 'now').mockReturnValue(now) fetchMock.mockResolvedValueOnce(Response.json({ message }, { status, headers })) const response = await app.request('https://api.example.com/public-github') expect(response.status).toBe(429) expect(response.headers.get('retry-after')).toBe(String(seconds)) clock.mockReturnValue(now + (seconds - 1) * 1000) expect((await app.request('https://api.example.com/public-github')).status).toBe(429) expect(fetchMock).toHaveBeenCalledOnce() clock.mockReturnValue(now + (seconds + 1) * 1000) expect((await app.request('https://api.example.com/public-github')).status).toBe(200) expect(fetchMock).toHaveBeenCalledTimes(2) }) it('does not treat permission failures as a shared anonymous rate limit', async () => { fetchMock.mockResolvedValueOnce(Response.json({ message: 'Forbidden' }, { status: 403 })) expect((await app.request('https://api.example.com/public-github')).status).toBe(403) expect((await app.request('https://api.example.com/public-github')).status).toBe(200) expect(fetchMock).toHaveBeenCalledTimes(2) }) it.each(['network', 'server', 'rate-limit'])('does not cache partial history after a later page has a %s failure', async (failure) => { const now = Date.now() const clock = vi.spyOn(Date, 'now').mockReturnValue(now) const firstPage = Array.from({ length: 100 }, (_, i) => ({ tag_name: `8.1.${i}`, body: notes })) const request = () => app.request('https://api.example.com/releases', {}, { CHANNEL_SELF_STORE: kv }) fetchMock.mockResolvedValueOnce(Response.json(firstPage)) if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('Connection failed')) else fetchMock.mockResolvedValueOnce(Response.json({}, { status: failure === 'server' ? 500 : 429 })) const response = await request() const payload = await response.json() as { truncated: boolean, releases: unknown[] } expect(payload.truncated).toBe(true) expect(payload.releases).toHaveLength(100) expect(fetchMock).toHaveBeenCalledTimes(2) expect(kv.put).not.toHaveBeenCalled() expect([...edge.keys()].some(key => new URL(key).pathname === '/private/changelog/releases')).toBe(false) // Recovery must be visible before the normal 15-minute release TTL expires. clock.mockReturnValue(now + 61_000) fetchMock.mockResolvedValueOnce(Response.json(firstPage)) fetchMock.mockResolvedValueOnce(Response.json([{ tag_name: '8.0.0', body: notes }])) const recovered = await (await request()).json() as { truncated: boolean, releases: unknown[] } expect(recovered.truncated).toBe(false) expect(recovered.releases).toHaveLength(101) expect(fetchMock).toHaveBeenCalledTimes(4) expect(kv.put).toHaveBeenCalledOnce() edge.clear() expect(await (await request()).json()).toEqual(recovered) expect(fetchMock).toHaveBeenCalledTimes(4) }) it('shares successful history truncated only by the page limit', async () => { fetchMock.mockImplementation(async () => Response.json(Array.from({ length: 100 }, (_, i) => ({ tag_name: `8.1.${i}`, body: notes })))) const request = () => app.request('https://api.example.com/releases', {}, { CHANNEL_SELF_STORE: kv }) const payload = await (await request()).json() expect(payload).toMatchObject({ truncated: true }) expect(fetchMock).toHaveBeenCalledTimes(3) expect(kv.put).toHaveBeenCalledOnce() edge.clear() expect(await (await request()).json()).toEqual(payload) expect(fetchMock).toHaveBeenCalledTimes(3) }) it('does not cache an empty success when the CHANGELOG fallback is rate limited', async () => { fetchMock.mockImplementation(async (url: string) => { if (new URL(url).hostname === 'registry.npmjs.org') return Response.json({ name: '@capacitor/camera', version: '8.1.0', repository: 'https://github.com/ionic-team/capacitor-camera' }) return url.includes('/contents/') ? Response.json({}, { status: 429 }) : Response.json([]) }) const response = await app.request('https://api.example.com/plugin-releases', {}, { CHANNEL_SELF_STORE: kv }) expect(response.status).toBe(429) expect(kv.put).not.toHaveBeenCalled() expect(fetchMock).toHaveBeenCalledTimes(3) }) it('authenticates Capgo release requests and reuses public notes across locations until expiry', async () => { vi.stubEnv('GITHUB_TOKEN', 'fake-github-token') fetchMock.mockResolvedValue(Response.json([{ tag_name: 'capgo-8.1.0', body: notes }])) const request = () => app.request('https://api.example.com/console-releases', {}, { CHANNEL_SELF_STORE: kv }) expect(await (await request()).json()).toMatchObject({ releases: [{ version: '8.1.0' }] }) expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/Cap-go/capgo.app/releases'), expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer fake-github-token' }), })) expect(kv.put).toHaveBeenCalledWith(expect.any(String), expect.any(String), { expirationTtl: 900 }) edge.clear() expect(await (await request()).json()).toMatchObject({ releases: [{ version: '8.1.0' }] }) expect(fetchMock).toHaveBeenCalledOnce() // Neither the shared cache nor its edge copy may keep notes fresh indefinitely. vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 901_000) fetchMock.mockResolvedValue(Response.json([{ tag_name: 'capgo-8.2.0', body: notes }])) expect(await (await request()).json()).toMatchObject({ releases: [{ version: '8.2.0' }] }) expect(fetchMock).toHaveBeenCalledTimes(2) }) it('logs rate-limit diagnostics without secrets and does not cache failed requests', async () => { vi.stubEnv('GITHUB_TOKEN', 'fake-github-token') const log = vi.spyOn(console, 'error').mockImplementation(() => {}) fetchMock.mockResolvedValueOnce(Response.json({ message: 'untrusted upstream body' }, { status: 403, headers: { 'x-ratelimit-limit': '5000', 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1800000000', 'x-github-request-id': 'test-request' }, })) const request = () => app.request('https://api.example.com/console-releases', {}, { CHANNEL_SELF_STORE: kv }) expect((await request()).status).toBe(502) expect(log.mock.calls.flat()).toEqual(expect.arrayContaining([ 'Changelog GitHub release fetch failed', 'authenticated', true, 'rateLimitRemaining', '0', 'githubRequestId', 'test-request', ])) expect(JSON.stringify(log.mock.calls)).not.toContain('fake-github-token') expect(JSON.stringify(log.mock.calls)).not.toContain('untrusted upstream body') expect(kv.put).not.toHaveBeenCalled() fetchMock.mockResolvedValueOnce(Response.json([{ tag_name: 'capgo-8.1.0', body: notes }])) expect(await (await request()).json()).toMatchObject({ releases: [{ version: '8.1.0' }] }) expect(fetchMock).toHaveBeenCalledTimes(2) }) it('keeps fetching releases when the shared cache is unavailable', async () => { kv.get.mockRejectedValueOnce(new Error('KV unavailable')) kv.put.mockRejectedValueOnce(new Error('KV unavailable')) const response = await app.request('https://api.example.com/releases', {}, { CHANNEL_SELF_STORE: kv }) expect(await response.json()).toMatchObject({ releases: [{ version: '8.1.0' }] }) }) }) describe('public dependency sources', () => { it('summarizes third-party releases as their own dependency rather than a Capgo plugin', async () => { await digest({ product: 'dependency', repo: '@capacitor/camera' }) expect(ai.run).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ messages: expect.arrayContaining([expect.objectContaining({ role: 'system', content: expect.stringContaining('a Capacitor dependency (ionic-team/capacitor-camera)') })]), })) }) it('uses and caches CHANGELOG.md when a plugin has no GitHub Releases', async () => { fetchMock.mockImplementation(async (url: string) => { if (new URL(url).hostname === 'registry.npmjs.org') return Response.json({ name: '@capacitor/camera', version: '8.1.0', repository: { url: 'git+https://github.com/ionic-team/capacitor-camera.git' } }) if (url.includes('/contents/')) return new Response('## [8.1.0] (2026-11-01)\n### Bug Fixes\n* Fix Android startup crash') return Response.json([]) }) const request = () => app.request('https://api.example.com/plugin-releases', {}, { CHANNEL_SELF_STORE: kv }) const payload = await (await request()).json() expect(payload).toMatchObject({ truncated: true, releases: [{ version: '8.1.0', sections: [{ type: 'fixed', items: ['Fix Android startup crash'] }] }] }) expect(kv.put).toHaveBeenCalledOnce() await request() expect(fetchMock).toHaveBeenCalledTimes(3) edge.clear() expect(await (await request()).json()).toEqual(payload) expect(fetchMock).toHaveBeenCalledTimes(4) // npm metadata is fetched again, release notes come from KV expect(ai.run).not.toHaveBeenCalled() }) it('accepts npm GitHub coordinates and rejects credentials, other hosts and traversals', () => { expect(dependencySource({ name: '@capacitor/camera', version: '8.0.0', repository: { url: 'git+https://github.com/ionic-team/capacitor-camera.git' } })).toMatchObject({ owner: 'ionic-team', repo: 'capacitor-camera' }) for (const url of ['https://github.com.evil.example/a/b', 'https://user:password@github.com/a/b', 'https://github.com/a/../../b', 'https://example.com/a/b']) expect(dependencySource({ name: 'test-plugin', version: '1.0.0', repository: url })).toBeNull() }) it('caches npm lookups and never forwards the GitHub token to external repositories', async () => { const url = 'https://api.example.com/dependency?name=%40capacitor%2Fcamera' await app.request(url) await app.request(url) expect(fetchMock).toHaveBeenCalledOnce() vi.stubEnv('GITHUB_TOKEN', 'fake-github-token') await app.request('https://api.example.com/releases') expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('/ionic-team/capacitor/releases'), expect.objectContaining({ headers: expect.not.objectContaining({ Authorization: expect.anything() }) })) }) it('rejects non-package input before making an outbound request', async () => { await app.request('https://api.example.com/dependency?name=..%2Fsecret') expect(fetchMock).not.toHaveBeenCalled() }) })