git0 0.3.66 → 0.3.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -1
- package/demo/git0-button.js +237 -0
- package/demo/index.html +71 -0
- package/dist/cli.js +1015 -4
- package/dist/cli.js.map +11 -5
- package/docs/content/docs/meta.json +1 -0
- package/docs/content/docs/service.mdx +51 -0
- package/package.json +1 -1
- package/src/args.ts +10 -0
- package/src/cli.ts +9 -0
- package/src/service/assets.ts +260 -0
- package/src/service/client.ts +61 -0
- package/src/service/index.ts +77 -0
- package/src/service/installer.ts +246 -0
- package/src/service/server.ts +540 -0
- package/src/service/system-service.ts +190 -0
- package/test/args.test.ts +11 -0
- package/test/service-assets.test.ts +181 -0
- package/test/service-installer.test.ts +201 -0
- package/test/service-server.test.ts +262 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import http from 'http';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
JobManager,
|
|
8
|
+
TrustStore,
|
|
9
|
+
createServiceServer,
|
|
10
|
+
jobPage,
|
|
11
|
+
type ServiceDeps,
|
|
12
|
+
} from '../src/service/server.ts';
|
|
13
|
+
import type { RawRelease } from '../src/service/assets.ts';
|
|
14
|
+
|
|
15
|
+
const linux = { os: 'linux', arch: 'x86_64', platform: 'linux', architecture: 'x64' };
|
|
16
|
+
|
|
17
|
+
const releases: RawRelease[] = [
|
|
18
|
+
{ tag_name: 'v1.0', assets: [{ name: 'app-linux-x86_64.AppImage', size: 1000, browser_download_url: 'https://dl/app' }] },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Deps that record instead of downloading, installing or launching anything. */
|
|
22
|
+
function fakeDeps(over: Partial<ServiceDeps> = {}) {
|
|
23
|
+
const calls = { downloads: [] as string[], installs: [] as string[], launches: [] as any[] };
|
|
24
|
+
const deps: ServiceDeps = {
|
|
25
|
+
platform: linux,
|
|
26
|
+
fetchReleases: async () => releases,
|
|
27
|
+
download: async (url, dest, onProgress, size) => {
|
|
28
|
+
calls.downloads.push(url);
|
|
29
|
+
onProgress(size / 2, size);
|
|
30
|
+
onProgress(size, size);
|
|
31
|
+
},
|
|
32
|
+
install: (file, asset) => {
|
|
33
|
+
calls.installs.push(asset.name);
|
|
34
|
+
return { installedPath: `/apps/${asset.name}`, launch: { cmd: '/apps/x', args: [] }, message: 'Installed' };
|
|
35
|
+
},
|
|
36
|
+
launch: l => { calls.launches.push(l); },
|
|
37
|
+
...over,
|
|
38
|
+
};
|
|
39
|
+
return { deps, calls };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const settle = () => new Promise(r => setTimeout(r, 20));
|
|
43
|
+
|
|
44
|
+
let home: string;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
home = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-svc-'));
|
|
47
|
+
process.env.GIT0_HOME = home;
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
delete process.env.GIT0_HOME;
|
|
51
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('JobManager', () => {
|
|
55
|
+
test('waits for approval before touching the network', async () => {
|
|
56
|
+
const { deps, calls } = fakeDeps();
|
|
57
|
+
const jobs = new JobManager(deps, new TrustStore(path.join(home, 's.json')));
|
|
58
|
+
const job = jobs.request('https://github.com/acme/app');
|
|
59
|
+
await settle();
|
|
60
|
+
expect(job.repo).toBe('acme/app');
|
|
61
|
+
expect(job.status).toBe('awaiting-approval');
|
|
62
|
+
expect(calls.downloads).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('runs resolve → download → install → launch once allowed', async () => {
|
|
66
|
+
const { deps, calls } = fakeDeps();
|
|
67
|
+
const jobs = new JobManager(deps, new TrustStore(path.join(home, 's.json')));
|
|
68
|
+
const job = jobs.request('acme/app');
|
|
69
|
+
expect(jobs.decide(job.id, job.approvalToken, true)).toBe(true);
|
|
70
|
+
await settle();
|
|
71
|
+
|
|
72
|
+
const view = jobs.view(jobs.get(job.id)!);
|
|
73
|
+
expect(view).toMatchObject({
|
|
74
|
+
status: 'done',
|
|
75
|
+
progress: 1,
|
|
76
|
+
asset: 'app-linux-x86_64.AppImage',
|
|
77
|
+
tag: 'v1.0',
|
|
78
|
+
receivedBytes: 1000,
|
|
79
|
+
totalBytes: 1000,
|
|
80
|
+
installedPath: '/apps/app-linux-x86_64.AppImage',
|
|
81
|
+
launched: true,
|
|
82
|
+
});
|
|
83
|
+
expect(view).not.toHaveProperty('approvalToken');
|
|
84
|
+
expect(calls.downloads).toEqual(['https://dl/app']);
|
|
85
|
+
expect(calls.launches).toEqual([{ cmd: '/apps/x', args: [] }]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a wrong token does not approve', async () => {
|
|
89
|
+
const { deps, calls } = fakeDeps();
|
|
90
|
+
const jobs = new JobManager(deps, new TrustStore(path.join(home, 's.json')));
|
|
91
|
+
const job = jobs.request('acme/app');
|
|
92
|
+
expect(jobs.decide(job.id, 'nope', true)).toBe(false);
|
|
93
|
+
await settle();
|
|
94
|
+
expect(job.status).toBe('awaiting-approval');
|
|
95
|
+
expect(calls.downloads).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('deny ends the job', () => {
|
|
99
|
+
const { deps } = fakeDeps();
|
|
100
|
+
const jobs = new JobManager(deps, new TrustStore(path.join(home, 's.json')));
|
|
101
|
+
const job = jobs.request('acme/app');
|
|
102
|
+
jobs.decide(job.id, job.approvalToken, false);
|
|
103
|
+
expect(job.status).toBe('denied');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('"always allow" skips the prompt next time', async () => {
|
|
107
|
+
const { deps } = fakeDeps();
|
|
108
|
+
const trust = new TrustStore(path.join(home, 's.json'));
|
|
109
|
+
const jobs = new JobManager(deps, trust);
|
|
110
|
+
const first = jobs.request('acme/app');
|
|
111
|
+
jobs.decide(first.id, first.approvalToken, true, true);
|
|
112
|
+
await settle();
|
|
113
|
+
expect(trust.has('Acme/App')).toBe(true);
|
|
114
|
+
|
|
115
|
+
const second = jobs.request('acme/app');
|
|
116
|
+
expect(second.id).not.toBe(first.id);
|
|
117
|
+
expect(second.status).not.toBe('awaiting-approval');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('returns the in-flight job for a repeated request', () => {
|
|
121
|
+
const { deps } = fakeDeps();
|
|
122
|
+
const jobs = new JobManager(deps, new TrustStore(path.join(home, 's.json')));
|
|
123
|
+
expect(jobs.request('acme/app').id).toBe(jobs.request('ACME/app').id);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('caps unanswered prompts', () => {
|
|
127
|
+
const { deps } = fakeDeps();
|
|
128
|
+
const jobs = new JobManager(deps, new TrustStore(path.join(home, 's.json')));
|
|
129
|
+
for (let i = 0; i < 5; i++) jobs.request(`acme/app${i}`);
|
|
130
|
+
expect(() => jobs.request('acme/one-too-many')).toThrow(/Too many/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('rejects anything that is not a repository', () => {
|
|
134
|
+
const { deps } = fakeDeps();
|
|
135
|
+
const jobs = new JobManager(deps, new TrustStore(path.join(home, 's.json')));
|
|
136
|
+
expect(() => jobs.request('../../etc')).toThrow(/owner\/repo/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('reports a missing build as an error', async () => {
|
|
140
|
+
const { deps } = fakeDeps({ platform: { ...linux, os: 'windows', platform: 'win32' } });
|
|
141
|
+
const jobs = new JobManager(deps, new TrustStore(path.join(home, 's.json')));
|
|
142
|
+
const job = jobs.request('acme/app');
|
|
143
|
+
jobs.decide(job.id, job.approvalToken, true);
|
|
144
|
+
await settle();
|
|
145
|
+
expect(job.status).toBe('error');
|
|
146
|
+
expect(job.error).toMatch(/windows x86_64/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('a CLI tool installs but does not launch', async () => {
|
|
150
|
+
const { deps, calls } = fakeDeps({
|
|
151
|
+
install: () => ({ installedPath: '/apps/tool', launch: null, message: 'Installed command-line tool' }),
|
|
152
|
+
});
|
|
153
|
+
const jobs = new JobManager(deps, new TrustStore(path.join(home, 's.json')));
|
|
154
|
+
const job = jobs.request('acme/app');
|
|
155
|
+
jobs.decide(job.id, job.approvalToken, true);
|
|
156
|
+
await settle();
|
|
157
|
+
expect(job.status).toBe('done');
|
|
158
|
+
expect(job.launched).toBe(false);
|
|
159
|
+
expect(calls.launches).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('jobPage', () => {
|
|
164
|
+
test('escapes the repository name and embeds the token only while waiting', () => {
|
|
165
|
+
const job: any = { id: 'id1', repo: 'a/<b>', status: 'awaiting-approval', message: 'm' };
|
|
166
|
+
const html = jobPage(job, 'tok123');
|
|
167
|
+
expect(html).toContain('a/<b>');
|
|
168
|
+
expect(html).not.toContain('a/<b>');
|
|
169
|
+
expect(html).toContain('value="tok123"');
|
|
170
|
+
expect(jobPage({ ...job, status: 'downloading' }, 'tok123')).not.toContain('tok123');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('HTTP server', () => {
|
|
175
|
+
let server: http.Server;
|
|
176
|
+
let base: string;
|
|
177
|
+
let calls: ReturnType<typeof fakeDeps>['calls'];
|
|
178
|
+
|
|
179
|
+
beforeEach(async () => {
|
|
180
|
+
const fake = fakeDeps();
|
|
181
|
+
calls = fake.calls;
|
|
182
|
+
({ server } = createServiceServer({
|
|
183
|
+
deps: fake.deps,
|
|
184
|
+
trust: new TrustStore(path.join(home, 's.json')),
|
|
185
|
+
version: '9.9.9',
|
|
186
|
+
}));
|
|
187
|
+
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
|
|
188
|
+
base = `http://127.0.0.1:${(server.address() as any).port}`;
|
|
189
|
+
});
|
|
190
|
+
afterEach(() => new Promise<void>(r => server.close(() => r())));
|
|
191
|
+
|
|
192
|
+
/** Raw request, so tests can set Host and Origin, which fetch will not. */
|
|
193
|
+
function raw(method: string, p: string, headers: Record<string, string> = {}, body = '') {
|
|
194
|
+
return new Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }>((resolve, reject) => {
|
|
195
|
+
const req = http.request(base + p, { method, headers }, res => {
|
|
196
|
+
let data = '';
|
|
197
|
+
res.on('data', c => (data += c));
|
|
198
|
+
res.on('end', () => resolve({ status: res.statusCode!, headers: res.headers, body: data }));
|
|
199
|
+
});
|
|
200
|
+
req.on('error', reject);
|
|
201
|
+
req.end(body);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
test('/health identifies the service, with CORS and Private Network Access headers', async () => {
|
|
206
|
+
const res = await fetch(`${base}/health`);
|
|
207
|
+
expect(await res.json()).toMatchObject({ ok: true, name: 'git0', version: '9.9.9', os: 'linux' });
|
|
208
|
+
expect(res.headers.get('access-control-allow-origin')).toBe('*');
|
|
209
|
+
|
|
210
|
+
const pre = await raw('OPTIONS', '/install', { 'Access-Control-Request-Private-Network': 'true' });
|
|
211
|
+
expect(pre.status).toBe(204);
|
|
212
|
+
expect(pre.headers['access-control-allow-private-network']).toBe('true');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('refuses a foreign Host header (DNS rebinding)', async () => {
|
|
216
|
+
const res = await raw('GET', '/health', { Host: 'evil.example:80' });
|
|
217
|
+
expect(res.status).toBe(403);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('install → approval page → approve → status shows done', async () => {
|
|
221
|
+
const res = await fetch(`${base}/install`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({ repo: 'acme/app' }),
|
|
225
|
+
});
|
|
226
|
+
expect(res.status).toBe(202);
|
|
227
|
+
const job = await res.json();
|
|
228
|
+
expect(job.status).toBe('awaiting-approval');
|
|
229
|
+
expect(job.approveUrl).toBe(`${base}/job/${job.id}`);
|
|
230
|
+
expect(job).not.toHaveProperty('approvalToken');
|
|
231
|
+
|
|
232
|
+
const page = await raw('GET', `/job/${job.id}`);
|
|
233
|
+
expect(page.headers['x-frame-options']).toBe('DENY');
|
|
234
|
+
expect(page.headers['access-control-allow-origin']).toBeUndefined();
|
|
235
|
+
const token = page.body.match(/name="token" value="([^"]+)"/)![1];
|
|
236
|
+
|
|
237
|
+
const evil = await raw('POST', `/approve/${job.id}`, { Origin: 'https://evil.example', 'Content-Type': 'application/x-www-form-urlencoded' }, `token=${token}&decision=allow`);
|
|
238
|
+
expect(evil.status).toBe(403);
|
|
239
|
+
|
|
240
|
+
const ok = await raw('POST', `/approve/${job.id}`, { Origin: base, 'Content-Type': 'application/x-www-form-urlencoded' }, `token=${token}&decision=allow`);
|
|
241
|
+
expect(ok.status).toBe(303);
|
|
242
|
+
await settle();
|
|
243
|
+
|
|
244
|
+
const status = await (await fetch(`${base}/status/${job.id}`)).json();
|
|
245
|
+
expect(status).toMatchObject({ status: 'done', progress: 1, launched: true });
|
|
246
|
+
expect(calls.installs).toEqual(['app-linux-x86_64.AppImage']);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('a plain link click is redirected to the approval page', async () => {
|
|
250
|
+
const res = await raw('GET', '/install?repo=acme/app', { Accept: 'text/html' });
|
|
251
|
+
expect(res.status).toBe(303);
|
|
252
|
+
expect(res.headers.location).toMatch(/^\/job\//);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('bad input and unknown routes', async () => {
|
|
256
|
+
expect((await fetch(`${base}/install?repo=nope`)).status).toBe(400);
|
|
257
|
+
expect((await fetch(`${base}/status/missing`)).status).toBe(404);
|
|
258
|
+
expect((await fetch(`${base}/elsewhere`)).status).toBe(404);
|
|
259
|
+
const jobs = await (await fetch(`${base}/jobs`)).json();
|
|
260
|
+
expect(Array.isArray(jobs)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
});
|