import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createHash } from 'node:crypto'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; const content = Buffer.from([0x4d, 0x5a, 1, 2, 3, 4]); const sha256 = createHash('sha256').update(content).digest('hex'); const url = 'https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0'; const metadata = (overrides: Record = {}) => ({ id: 100, tag_name: 'v3.1.0', name: 'Utilities 3.1', body: 'Release notes ', published_at: '2026-07-27T12:00:00Z', draft: false, prerelease: false, assets: [{ id: 200, name: 't6-gsc-utils.dll', size: content.length, digest: `sha256:${sha256}`, state: 'uploaded' }], ...overrides }); let fetchMock: ReturnType; beforeEach(async () => { vi.resetModules(); fixture = await newFixture(); delete process.env.KOMODO_ADDRESS; delete process.env.RCON_PASSWORD; fetchMock = vi.fn(async (input: string | URL) => String(input).startsWith('https://api.github.com/') ? Response.json(metadata()) : new Response(content)); vi.stubGlobal('fetch', fetchMock); }); afterEach(async () => { vi.unstubAllGlobals(); await fixture.cleanup(); }); describe('GitHub release lookup', () => { it('supports repository, latest, tag and the supplied release-anchor URLs', async () => { const { parseGithubReleaseUrl, getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js'); expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils/releases#release-v3.1.0')).toEqual({ repository: 'alicealys/t6-gsc-utils', tag: 'v3.1.0' }); expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils/releases/latest').tag).toBeNull(); expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils').tag).toBeNull(); expect(parseGithubReleaseUrl('https://github.com/alicealys/t6-gsc-utils/releases/tag/release%2F3.1').tag).toBe('release/3.1'); const release = await getGithubRelease(url); expect(release).toMatchObject({ repository: 'alicealys/t6-gsc-utils', tag: 'v3.1.0', assets: [{ name: 't6-gsc-utils.dll' }] }); expect(fetchMock.mock.calls[0][0]).toBe('https://api.github.com/repos/alicealys/t6-gsc-utils/releases/tags/v3.1.0'); }); it.each(['http://github.com/a/b', 'https://github.com.evil.test/a/b', 'https://github.com@127.0.0.1/a/b', 'https://github.com:444/a/b', 'https://github.com/a/b/issues/1', 'https://github.com/a/b/releases#unknown', 'https://github.com/a/b/releases/tag/', 'https://github.com/a/b/releases/tag/v1%0A'])('rejects unsupported URL %s without fetching it', async (input) => { const { getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js'); await expect(getGithubRelease(input)).rejects.toThrow(); expect(fetchMock).not.toHaveBeenCalled(); }); it('filters out archives, unsafe filenames and oversized assets; shares concurrent lookups', async () => { const normal = metadata().assets[0]; fetchMock.mockImplementation(async () => Response.json(metadata({ assets: [normal, { ...normal, id: 201, name: 'source.zip' }, { ...normal, id: 202, name: '../evil.dll' }, { ...normal, id: 203, size: 65 * 1024 * 1024 }, { ...normal, id: 204, state: 'new' }] }))); const { getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js'); const [first, second] = await Promise.all([getGithubRelease(url), getGithubRelease(url)]); expect(first.assets).toHaveLength(1); expect(second).toEqual(first); expect(fetchMock).toHaveBeenCalledTimes(1); }); it('reports rate limits and retries failed lookups', async () => { fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 })); const { getGithubRelease } = await import('../../src/lib/server/integrations/github-releases.js'); await expect(getGithubRelease(url)).rejects.toMatchObject({ status: 429 }); await expect(getGithubRelease(url)).resolves.toMatchObject({ tag: 'v3.1.0' }); }); }); describe('GitHub DLL imports', () => { it('captures provenance and notes, leaves promotion untouched, and deduplicates concurrent imports', async () => { const { importPluginRelease, pluginSource } = await import('../../src/lib/server/fleet/plugin-releases.js'); const { getScript, getVersion, listVersions } = await import('../../src/lib/server/fleet/scripts.js'); const imported = await importPluginRelease({ url, assetId: 200, name: 'Utilities', scopeType: 'game', scopeValue: 't6' }); expect(imported.sha256).toBe(sha256); expect(imported.betaUrl).toBe(`/betas?pluginId=${imported.pluginId}&versionId=${imported.versionId}`); expect(getScript(imported.pluginId)).toMatchObject({ kind: 'plugin', target_rel_path: 't6-gsc-utils.dll', scope_value: 't6', current_version_id: null }); expect(getVersion(imported.versionId)).toMatchObject({ release_version: 'v3.1.0', content, notes: metadata().body, state: 'untested' }); expect(pluginSource(imported.pluginId)).toMatchObject({ repository: 'alicealys/t6-gsc-utils', release_id: 100, asset_id: 200, release_url: url }); const duplicates = await Promise.all([1, 2].map(() => importPluginRelease({ url, assetId: 200, pluginId: imported.pluginId }))); expect(duplicates.every(r => r.alreadyImported && r.versionId === imported.versionId)).toBe(true); expect(listVersions(imported.pluginId)).toHaveLength(1); const { GET } = await import('../../src/routes/api/scripts/[id]/+server.js'); const response = await (await GET({ params: { id: String(imported.pluginId) } })).json(); expect(response.versions[0].source.asset_id).toBe(200); expect(response.versions[0]).not.toHaveProperty('content'); }); it('follows GitHub asset redirects and verifies the bytes', async () => { fetchMock.mockImplementation(async (input) => { if (String(input).startsWith('https://api.github.com/')) return Response.json(metadata()); if (String(input).startsWith('https://github.com/')) return new Response(null, { status: 302, headers: { location: 'https://release-assets.githubusercontent.com/blob/test' } }); return new Response(content); }); const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).resolves.toMatchObject({ sha256 }); expect(fetchMock).toHaveBeenCalledTimes(3); }); it.each(['http://127.0.0.1/internal', 'https://evil.test/file.dll', 'https://user:pass@github.com/file.dll'])('blocks unsafe download redirects to %s', async (location) => { fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') ? Response.json(metadata()) : new Response(null, { status: 302, headers: { location } })); const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); const { listScripts } = await import('../../src/lib/server/fleet/scripts.js'); await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).rejects.toThrow(/unsupported host/); expect(fetchMock).toHaveBeenCalledTimes(2); expect(listScripts()).toHaveLength(0); }); it('rejects checksum mismatches and truncated downloads without creating a plugin', async () => { const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); const { listScripts } = await import('../../src/lib/server/fleet/scripts.js'); fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') ? Response.json(metadata()) : new Response(Buffer.alloc(content.length))); await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).rejects.toThrow(/checksum/); fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') ? Response.json(metadata()) : new Response(Buffer.alloc(1))); await expect(importPluginRelease({ url, assetId: 200, name: 'Utilities' })).rejects.toThrow(/size differs/); expect(listScripts()).toHaveLength(0); }); it('rejects a missing asset and refuses to import into non-plugins or active betas', async () => { const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); await expect(importPluginRelease({ url, assetId: 999, name: 'Utilities' })).rejects.toThrow(/no longer available/); const script = createScript({ name: 'Script', targetRelPath: 's.gsc', content }); await expect(importPluginRelease({ url, assetId: 200, pluginId: script.script.id })).rejects.toMatchObject({ status: 404 }); const plugin = createScript({ name: 'Plugin', kind: 'plugin', targetRelPath: 'p.dll', content }); const beta = db().prepare("INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'version', 'running')") .run(plugin.script.id, plugin.version.id); db().prepare('INSERT INTO beta_scripts (beta_id, script_id, script_version_id, kind) VALUES (?, ?, ?, ?)') .run(beta.lastInsertRowid, plugin.script.id, plugin.version.id, 'plugin'); await expect(importPluginRelease({ url, assetId: 200, pluginId: plugin.script.id })).rejects.toMatchObject({ status: 409 }); }); }); describe('plugin update discovery', () => { it('finds newer stable releases, imports into the same plugin, and distinguishes imported from promoted', async () => { const { importPluginRelease, checkPluginUpdate } = await import('../../src/lib/server/fleet/plugin-releases.js'); const { getScript, listVersions } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); const first = await importPluginRelease({ url, assetId: 200, name: 'Utilities' }); const next = metadata({ id: 101, tag_name: 'v3.1.1', published_at: '2026-08-11T12:00:00Z', assets: [{ ...metadata().assets[0], id: 201 }] }); fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') ? Response.json(next) : new Response(content)); const update = await checkPluginUpdate(first.pluginId); expect(update).toMatchObject({ status: 'available', release: { tag: 'v3.1.1' } }); const second = await importPluginRelease({ url: update.release!.url, assetId: 201, pluginId: first.pluginId }); expect(listVersions(first.pluginId)).toHaveLength(2); expect(getScript(first.pluginId)!.current_version_id).toBeNull(); expect(await checkPluginUpdate(first.pluginId)).toMatchObject({ status: 'imported', versionId: second.versionId }); db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(second.versionId, first.pluginId); expect(await checkPluginUpdate(first.pluginId)).toMatchObject({ status: 'current', versionId: second.versionId }); }); it('does not recommend an older stable release after importing a newer prerelease', async () => { const { importPluginRelease, checkPluginUpdate } = await import('../../src/lib/server/fleet/plugin-releases.js'); const stable = await importPluginRelease({ url, assetId: 200, name: 'Utilities' }); fetchMock.mockImplementation(async (input) => String(input).startsWith('https://api.github.com/') ? Response.json(metadata({ id: 105, tag_name: 'v4.0-rc1', prerelease: true, published_at: '2026-09-01T12:00:00Z' })) : new Response(content)); const imported = await importPluginRelease({ url: url.replace('v3.1.0', 'v4.0-rc1'), assetId: 200, pluginId: stable.pluginId }); fetchMock.mockImplementation(async () => Response.json(metadata())); expect(await checkPluginUpdate(imported.pluginId)).toMatchObject({ status: 'ahead' }); }); it('reports untracked and GitHub failures per plugin without breaking the list', async () => { const { importPluginRelease } = await import('../../src/lib/server/fleet/plugin-releases.js'); const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); await importPluginRelease({ url, assetId: 200, name: 'Imported' }); createScript({ name: 'Manual', kind: 'plugin', targetRelPath: 'manual.dll', content }); fetchMock.mockResolvedValue(new Response(null, { status: 404 })); const { GET } = await import('../../src/routes/api/plugins/updates/+server.js'); const result = await (await GET({ url: new URL('http://localhost/api/plugins/updates') })).json(); expect(result.updates.map((u: { status: string }) => u.status)).toEqual(['error', 'untracked']); }); it('serves the preview and import endpoints and validates bad requests', async () => { const { GET } = await import('../../src/routes/api/plugins/github/release/+server.js'); const { POST } = await import('../../src/routes/api/plugins/github/import/+server.js'); const preview = await GET({ url: new URL(`http://localhost/api/plugins/github/release?url=${encodeURIComponent(url)}`) }); expect((await preview.json()).release.assets[0].id).toBe(200); const request = (body: unknown) => new Request('http://localhost/api/plugins/github/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); expect((await POST({ request: request(null) })).status).toBe(400); expect((await POST({ request: request({ url, assetId: 200, name: 'Test', scopeType: 'other' }) })).status).toBe(400); const response = await POST({ request: request({ url, assetId: 200, name: 'Test' }) }); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ ok: true, sha256, releaseVersion: 'v3.1.0' }); }); });