Files
services/apps/version/src/index.test.ts
T
2026-05-14 19:44:44 +02:00

726 lines
25 KiB
TypeScript

import { env, exports } from 'cloudflare:workers';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryCache } from './memory-cache.js';
import { revalidationState, versionCache } from './version-service.js';
import { compareSemVer, isGreaterThan, parseSemVer } from './version.js';
import { verifyWebhookSignature } from './webhook.js';
async function createSchema() {
await env.VERSION_DB.prepare(
"CREATE TABLE IF NOT EXISTS releases (id INTEGER PRIMARY KEY, tag_name TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT '', published_at TEXT NOT NULL DEFAULT '', major INTEGER NOT NULL, minor INTEGER NOT NULL, patch INTEGER NOT NULL)",
).run();
await env.VERSION_DB.prepare(
'CREATE INDEX IF NOT EXISTS idx_releases_semver ON releases (major DESC, minor DESC, patch DESC)',
).run();
}
const mockReleases = [
{
id: 3,
tag_name: 'v1.120.0',
name: 'v1.120.0',
url: 'https://api.github.com/repos/immich-app/immich/releases/3',
created_at: '2025-03-01T00:00:00Z',
published_at: '2025-03-01T00:00:00Z',
body: '## Changes in v1.120.0\n- Feature C',
},
{
id: 2,
tag_name: 'v1.110.0',
name: 'v1.110.0',
url: 'https://api.github.com/repos/immich-app/immich/releases/2',
created_at: '2025-02-01T00:00:00Z',
published_at: '2025-02-01T00:00:00Z',
body: '## Changes in v1.110.0\n- Feature B',
},
{
id: 1,
tag_name: 'v1.100.0',
name: 'v1.100.0',
url: 'https://api.github.com/repos/immich-app/immich/releases/1',
created_at: '2025-01-01T00:00:00Z',
published_at: '2025-01-01T00:00:00Z',
body: '## Changes in v1.100.0\n- Feature A',
},
];
async function seedReleases() {
for (const release of mockReleases) {
const semver = parseSemVer(release.tag_name)!;
await env.VERSION_DB.prepare(
`INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
release.id,
release.tag_name,
release.name,
release.url,
release.body,
release.created_at,
release.published_at,
semver.major,
semver.minor,
semver.patch,
)
.run();
}
}
async function createWebhookSignature(body: string, secret: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
return `sha256=${hex}`;
}
describe('MemoryCache', () => {
it('returns null when empty', () => {
const cache = new MemoryCache<string>(1000);
expect(cache.get()).toBeNull();
});
it('stores and retrieves a fresh value', () => {
const cache = new MemoryCache<string>(1000);
cache.set('hello');
const result = cache.get();
expect(result).not.toBeNull();
expect(result!.value).toBe('hello');
expect(result!.stale).toBe(false);
});
it('returns stale after TTL expires', () => {
const cache = new MemoryCache<string>(0); // 0ms TTL = immediately stale
cache.set('hello');
const result = cache.get();
expect(result).not.toBeNull();
expect(result!.value).toBe('hello');
expect(result!.stale).toBe(true);
});
it('invalidates the cache', () => {
const cache = new MemoryCache<string>(1000);
cache.set('hello');
cache.invalidate();
expect(cache.get()).toBeNull();
});
});
describe('Webhook signature verification', () => {
it('verifies a valid signature', async () => {
const body = 'test body';
const secret = 'test-secret';
const signature = await createWebhookSignature(body, secret);
expect(await verifyWebhookSignature(body, signature, secret)).toBe(true);
});
it('rejects an invalid signature', async () => {
expect(await verifyWebhookSignature('body', 'sha256=invalid', 'secret')).toBe(false);
});
it('rejects a signature without sha256 prefix', async () => {
expect(await verifyWebhookSignature('body', 'md5=abc', 'secret')).toBe(false);
});
it('rejects when body is tampered', async () => {
const signature = await createWebhookSignature('original', 'secret');
expect(await verifyWebhookSignature('tampered', signature, 'secret')).toBe(false);
});
});
describe('Version utilities', () => {
describe('parseSemVer', () => {
it('parses version with v prefix', () => {
expect(parseSemVer('v1.100.0')).toEqual({ major: 1, minor: 100, patch: 0 });
});
it('parses version without v prefix', () => {
expect(parseSemVer('1.100.0')).toEqual({ major: 1, minor: 100, patch: 0 });
});
it('returns null for invalid version', () => {
expect(parseSemVer('invalid')).toBeNull();
expect(parseSemVer('')).toBeNull();
});
it('returns null for version with pre-release suffix', () => {
expect(parseSemVer('v1.100.0-rc.1')).toBeNull();
expect(parseSemVer('1.100.0-beta')).toBeNull();
});
it('returns null for version with extra segments', () => {
expect(parseSemVer('v1.100.0.1')).toBeNull();
});
});
describe('compareSemVer', () => {
it('returns positive when first is greater', () => {
expect(compareSemVer({ major: 2, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 0 })).toBeGreaterThan(0);
});
it('returns negative when first is smaller', () => {
expect(compareSemVer({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBeLessThan(0);
});
it('returns 0 when equal', () => {
expect(compareSemVer({ major: 1, minor: 2, patch: 3 }, { major: 1, minor: 2, patch: 3 })).toBe(0);
});
});
describe('isGreaterThan', () => {
it('compares major versions', () => {
expect(isGreaterThan('v2.0.0', 'v1.0.0')).toBe(true);
expect(isGreaterThan('v1.0.0', 'v2.0.0')).toBe(false);
});
it('compares minor versions', () => {
expect(isGreaterThan('v1.2.0', 'v1.1.0')).toBe(true);
expect(isGreaterThan('v1.1.0', 'v1.2.0')).toBe(false);
});
it('compares patch versions', () => {
expect(isGreaterThan('v1.1.2', 'v1.1.1')).toBe(true);
expect(isGreaterThan('v1.1.1', 'v1.1.2')).toBe(false);
});
it('returns false for equal versions', () => {
expect(isGreaterThan('v1.1.1', 'v1.1.1')).toBe(false);
});
it('handles mixed v prefix', () => {
expect(isGreaterThan('v1.2.0', '1.1.0')).toBe(true);
expect(isGreaterThan('1.2.0', 'v1.1.0')).toBe(true);
});
});
});
describe('Version Worker', () => {
beforeAll(async () => {
await createSchema();
});
beforeEach(async () => {
versionCache.invalidate();
revalidationState.inFlight = false;
await env.VERSION_DB.exec('DELETE FROM releases');
await seedReleases();
});
describe('OPTIONS preflight', () => {
it('returns correct CORS headers', async () => {
const response = await exports.default.fetch('https://example.com/version', { method: 'OPTIONS' });
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, POST, OPTIONS');
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type');
expect(response.headers.get('Access-Control-Max-Age')).toBe('86400');
});
});
describe('GET /health', () => {
it('returns healthy status', async () => {
const response = await exports.default.fetch('https://example.com/health');
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({ status: 'ok' });
});
});
describe('GET /version', () => {
it('returns the latest version', async () => {
const response = await exports.default.fetch('https://example.com/version');
expect(response.status).toBe(200);
const body = (await response.json()) as any;
expect(body.version).toBe('v1.120.0');
expect(body.published_at).toBe('2025-03-01T00:00:00Z');
});
it('awaits D1 on cold start rather than deferring', async () => {
// Cache is empty (invalidated in beforeEach), D1 has data
// The first request must return real data, not 404 or empty
const response = await exports.default.fetch('https://example.com/version');
expect(response.status).toBe(200);
const body = (await response.json()) as any;
expect(body.version).toBe('v1.120.0');
// Now delete D1 data — second request should come from cache, proving
// the first request populated the cache synchronously
await env.VERSION_DB.exec('DELETE FROM releases');
const second = await exports.default.fetch('https://example.com/version');
expect(second.status).toBe(200);
const secondBody = (await second.json()) as any;
expect(secondBody.version).toBe('v1.120.0');
});
it('does not include Cache-Control header (memory cached, not CDN)', async () => {
const response = await exports.default.fetch('https://example.com/version');
expect(response.headers.get('Cache-Control')).toBeNull();
});
it('returns 404 when no releases exist', async () => {
await env.VERSION_DB.exec('DELETE FROM releases');
const response = await exports.default.fetch('https://example.com/version');
expect(response.status).toBe(404);
});
it('serves from in-memory cache on second request', async () => {
const first = await exports.default.fetch('https://example.com/version');
expect(first.status).toBe(200);
// Delete from D1 - second request should still work from cache
await env.VERSION_DB.exec('DELETE FROM releases');
const second = await exports.default.fetch('https://example.com/version');
expect(second.status).toBe(200);
const body = (await second.json()) as any;
expect(body.version).toBe('v1.120.0');
});
it('includes CORS header', async () => {
const response = await exports.default.fetch('https://example.com/version');
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
it('serves stale data while revalidating in the background', async () => {
// Prime the cache
const first = await exports.default.fetch('https://example.com/version');
expect(first.status).toBe(200);
// Expire the cache by invalidating and setting with 0 TTL
versionCache.invalidate();
versionCache.set({ version: 'v1.120.0', published_at: '2025-03-01T00:00:00Z' });
// Manually expire it
Object.assign(versionCache, { expiresAt: 0 });
// Update D1 with a new version
const semver = parseSemVer('v1.130.0')!;
await env.VERSION_DB.prepare(
`INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
4,
'v1.130.0',
'v1.130.0',
'',
'',
'2025-04-01T00:00:00Z',
'2025-04-01T00:00:00Z',
semver.major,
semver.minor,
semver.patch,
)
.run();
// This request should get stale v1.120.0 while triggering background refresh
const stale = await exports.default.fetch('https://example.com/version');
expect(stale.status).toBe(200);
const staleBody = (await stale.json()) as any;
expect(staleBody.version).toBe('v1.120.0');
// Wait for background refresh to complete
await new Promise((resolve) => setTimeout(resolve, 50));
// Next request should get the updated version from refreshed cache
const fresh = await exports.default.fetch('https://example.com/version');
expect(fresh.status).toBe(200);
const freshBody = (await fresh.json()) as any;
expect(freshBody.version).toBe('v1.130.0');
});
it('deduplicates concurrent revalidation requests', async () => {
// Set up stale cache
versionCache.set({ version: 'v1.120.0', published_at: '2025-03-01T00:00:00Z' });
Object.assign(versionCache, { expiresAt: 0 });
expect(revalidationState.inFlight).toBe(false);
// Fire two concurrent requests while stale
const [r1, r2] = await Promise.all([
exports.default.fetch('https://example.com/version'),
exports.default.fetch('https://example.com/version'),
]);
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
// Both should return stale data immediately
const b1 = (await r1.json()) as any;
const b2 = (await r2.json()) as any;
expect(b1.version).toBe('v1.120.0');
expect(b2.version).toBe('v1.120.0');
});
});
describe('GET /changelog', () => {
it('returns 400 when version is missing', async () => {
const response = await exports.default.fetch('https://example.com/changelog');
expect(response.status).toBe(400);
const body = (await response.json()) as { error: string };
expect(body.error).toContain('version');
});
it('returns 400 for invalid version format', async () => {
const response = await exports.default.fetch('https://example.com/changelog?version=invalid');
expect(response.status).toBe(400);
const body = (await response.json()) as { error: string };
expect(body.error).toContain('Invalid version');
});
it('returns releases newer than the provided version', async () => {
const response = await exports.default.fetch('https://example.com/changelog?version=v1.100.0');
expect(response.status).toBe(200);
const body = (await response.json()) as any;
expect(body.current).toBe('v1.100.0');
expect(body.latest.tag_name).toBe('v1.120.0');
expect(body.releases).toHaveLength(2);
expect(body.releases[0].tag_name).toBe('v1.120.0');
expect(body.releases[1].tag_name).toBe('v1.110.0');
});
it('returns empty releases when on latest version', async () => {
const response = await exports.default.fetch('https://example.com/changelog?version=v1.120.0');
expect(response.status).toBe(200);
const body = (await response.json()) as any;
expect(body.releases).toHaveLength(0);
});
it('handles version without v prefix', async () => {
const response = await exports.default.fetch('https://example.com/changelog?version=1.100.0');
expect(response.status).toBe(200);
const body = (await response.json()) as any;
expect(body.current).toBe('1.100.0');
expect(body.releases).toHaveLength(2);
});
it('returns all releases when version is very old', async () => {
const response = await exports.default.fetch('https://example.com/changelog?version=v1.0.0');
expect(response.status).toBe(200);
const body = (await response.json()) as any;
expect(body.releases).toHaveLength(3);
});
it('includes Cache-Control header for CDN caching', async () => {
const response = await exports.default.fetch('https://example.com/changelog?version=v1.100.0');
expect(response.headers.get('Cache-Control')).toBe('public, max-age=86400');
});
it('returns empty results when D1 has no data', async () => {
await env.VERSION_DB.exec('DELETE FROM releases');
const response = await exports.default.fetch('https://example.com/changelog?version=v1.0.0');
expect(response.status).toBe(200);
const body = (await response.json()) as any;
expect(body.releases).toHaveLength(0);
expect(body.latest).toBeNull();
});
});
describe('POST /webhook', () => {
const webhookSecret = 'test-secret';
it('returns 401 without signature', async () => {
const response = await exports.default.fetch('https://example.com/webhook', {
method: 'POST',
body: '{}',
});
expect(response.status).toBe(401);
});
it('returns 401 with invalid signature', async () => {
const response = await exports.default.fetch('https://example.com/webhook', {
method: 'POST',
body: '{}',
headers: { 'X-Hub-Signature-256': 'sha256=invalid' },
});
expect(response.status).toBe(401);
});
it('returns 405 for non-POST requests', async () => {
const response = await exports.default.fetch('https://example.com/webhook');
expect(response.status).toBe(405);
});
it('ignores non-release events', async () => {
const body = JSON.stringify({ action: 'opened' });
const signature = await createWebhookSignature(body, webhookSecret);
const response = await exports.default.fetch('https://example.com/webhook', {
method: 'POST',
body,
headers: {
'X-Hub-Signature-256': signature,
'X-GitHub-Event': 'pull_request',
},
});
expect(response.status).toBe(200);
const result = (await response.json()) as any;
expect(result.ignored).toBe(true);
});
it('ignores non-published release actions', async () => {
const body = JSON.stringify({ action: 'created', release: {} });
const signature = await createWebhookSignature(body, webhookSecret);
const response = await exports.default.fetch('https://example.com/webhook', {
method: 'POST',
body,
headers: {
'X-Hub-Signature-256': signature,
'X-GitHub-Event': 'release',
},
});
expect(response.status).toBe(200);
const result = (await response.json()) as any;
expect(result.ignored).toBe(true);
});
it('upserts a published release and invalidates cache', async () => {
// Prime the cache
await exports.default.fetch('https://example.com/version');
const releasePayload = {
action: 'published',
release: {
id: 4,
tag_name: 'v1.130.0',
name: 'v1.130.0',
url: 'https://api.github.com/repos/immich-app/immich/releases/4',
body: 'New release',
created_at: '2025-04-01T00:00:00Z',
published_at: '2025-04-01T00:00:00Z',
},
};
const body = JSON.stringify(releasePayload);
const signature = await createWebhookSignature(body, webhookSecret);
const response = await exports.default.fetch('https://example.com/webhook', {
method: 'POST',
body,
headers: {
'X-Hub-Signature-256': signature,
'X-GitHub-Event': 'release',
},
});
expect(response.status).toBe(200);
const result = (await response.json()) as any;
expect(result.success).toBe(true);
// Verify the new release is now the latest (cache was invalidated)
const versionResponse = await exports.default.fetch('https://example.com/version');
const versionBody = (await versionResponse.json()) as any;
expect(versionBody.version).toBe('v1.130.0');
});
it('updates an existing release body', async () => {
const releasePayload = {
action: 'published',
release: {
id: 3,
tag_name: 'v1.120.0',
name: 'v1.120.0',
url: 'https://api.github.com/repos/immich-app/immich/releases/3',
body: 'Updated release notes',
created_at: '2025-03-01T00:00:00Z',
published_at: '2025-03-01T00:00:00Z',
},
};
const body = JSON.stringify(releasePayload);
const signature = await createWebhookSignature(body, webhookSecret);
await exports.default.fetch('https://example.com/webhook', {
method: 'POST',
body,
headers: {
'X-Hub-Signature-256': signature,
'X-GitHub-Event': 'release',
},
});
const response = await exports.default.fetch('https://example.com/changelog?version=v1.110.0');
const changelog = (await response.json()) as any;
expect(changelog.releases[0].body).toBe('Updated release notes');
});
it('returns 400 for invalid release payload', async () => {
const releasePayload = {
action: 'published',
release: { no_id: true },
};
const body = JSON.stringify(releasePayload);
const signature = await createWebhookSignature(body, webhookSecret);
const response = await exports.default.fetch('https://example.com/webhook', {
method: 'POST',
body,
headers: {
'X-Hub-Signature-256': signature,
'X-GitHub-Event': 'release',
},
});
expect(response.status).toBe(400);
});
it('ignores draft releases', async () => {
const releasePayload = {
action: 'published',
release: {
id: 5,
tag_name: 'v1.140.0',
name: 'v1.140.0',
url: '',
body: '',
created_at: '',
published_at: '',
draft: true,
},
};
const body = JSON.stringify(releasePayload);
const signature = await createWebhookSignature(body, webhookSecret);
const response = await exports.default.fetch('https://example.com/webhook', {
method: 'POST',
body,
headers: {
'X-Hub-Signature-256': signature,
'X-GitHub-Event': 'release',
},
});
expect(response.status).toBe(200);
const result = (await response.json()) as any;
expect(result.ignored).toBe(true);
});
it('ignores prerelease releases', async () => {
const releasePayload = {
action: 'published',
release: {
id: 6,
tag_name: 'v1.121.0-rc.1',
name: 'v1.121.0-rc.1',
url: '',
body: '',
created_at: '',
published_at: '',
prerelease: true,
},
};
const body = JSON.stringify(releasePayload);
const signature = await createWebhookSignature(body, webhookSecret);
const response = await exports.default.fetch('https://example.com/webhook', {
method: 'POST',
body,
headers: {
'X-Hub-Signature-256': signature,
'X-GitHub-Event': 'release',
},
});
expect(response.status).toBe(200);
const result = (await response.json()) as any;
expect(result.ignored).toBe(true);
});
});
describe('Unknown routes', () => {
it('returns 404 for unknown paths', async () => {
const response = await exports.default.fetch('https://example.com/unknown');
expect(response.status).toBe(404);
});
});
});
describe('Cron sync', () => {
beforeAll(async () => {
await createSchema();
});
beforeEach(async () => {
versionCache.invalidate();
revalidationState.inFlight = false;
await env.VERSION_DB.exec('DELETE FROM releases');
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
const request = new Request(input, init);
const url = new URL(request.url);
if (
request.method === 'GET' &&
url.origin === 'https://api.github.com' &&
url.pathname === '/repos/immich-app/immich/releases' &&
url.searchParams.get('per_page') === '100' &&
url.searchParams.get('page') === '1'
) {
return Promise.resolve(Response.json(mockReleases));
}
return fetch(input, init);
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('fetches from GitHub and populates D1', async () => {
const response = await exports.default.fetch('https://example.com/changelog?version=v1.100.0');
expect(response.status).toBe(200);
const body = (await response.json()) as any;
// D1 is empty, so no releases
expect(body.releases).toHaveLength(0);
// Seed directly to simulate cron populating D1
await seedReleases();
const response2 = await exports.default.fetch('https://example.com/changelog?version=v1.100.0');
const body2 = (await response2.json()) as any;
expect(body2.releases).toHaveLength(2);
expect(body2.latest.tag_name).toBe('v1.120.0');
});
it('returns latest version from GitHub when cache is empty', async () => {
// Seed D1 directly
await seedReleases();
const response = await exports.default.fetch('https://example.com/version');
expect(response.status).toBe(200);
const body = (await response.json()) as any;
expect(body.version).toBe('v1.120.0');
expect(body.published_at).toBe('2025-03-01T00:00:00Z');
});
});
describe('CDN cache immutable headers fix', () => {
it('cached responses must be wrapped to allow header mutation', async () => {
const cache = caches.default;
const key = new Request('https://example.com/test-immutable-headers');
const original = Response.json(
{ data: 'test' },
{
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' },
},
);
await cache.put(key, original.clone());
const cached = await cache.match(key);
expect(cached).not.toBeNull();
// Cached responses have immutable headers - setting directly would throw
expect(() => cached!.headers.set('Server-Timing', 'test;dur=1')).toThrow();
// The fix: wrapping in new Response() creates mutable headers
const mutable = new Response(cached!.body, cached!);
expect(() => mutable.headers.set('Server-Timing', 'test;dur=1')).not.toThrow();
expect(mutable.headers.get('Server-Timing')).toBe('test;dur=1');
// Verify body is preserved
const body = (await mutable.json()) as any;
expect(body.data).toBe('test');
await cache.delete(key);
});
});