383 lines
16 KiB
TypeScript
383 lines
16 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({ 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' });
|
|
});
|
|
});
|