Files
electric-cherry/tests/unit/github-plugin-import.test.ts
2026-10-12 18:46:57 -04:00

388 lines
17 KiB
TypeScript

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<string, unknown> = {}) => ({
id: 100,
tag_name: 'v3.1.0',
name: 'Utilities 3.1',
body: 'Release notes <script>untrusted</script>',
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<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
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: 32 * 1024 * 1024 + 1 },
{ ...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',
notes: metadata().body,
state: 'untested'
});
expect(
(await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(
imported.versionId
)
).toEqual(content);
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({
releaseVersion: '1.0.0',
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' });
});
});