mirror of
https://github.com/immich-app/services.git
synced 2026-09-30 13:23:10 +08:00
726 lines
25 KiB
TypeScript
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);
|
|
});
|
|
});
|