137 lines
5.0 KiB
TypeScript
137 lines
5.0 KiB
TypeScript
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { generateKeyPairSync, verify, createPublicKey } from 'node:crypto';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { writeCompanionReleaseBundle } from './releaseBundle.js';
|
|
|
|
describe('writeCompanionReleaseBundle', () => {
|
|
it('writes manifest, launcher script, and README', async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'flynn-companion-release-'));
|
|
const outputDir = join(tempDir, 'bundle');
|
|
|
|
const result = await writeCompanionReleaseBundle({
|
|
outputDir,
|
|
manifest: {
|
|
schemaVersion: 1,
|
|
generatedAt: '2026-02-27T00:00:00.000Z',
|
|
gateway: { url: 'ws://127.0.0.1:18800', token: 'token-123' },
|
|
node: {
|
|
nodeId: 'ios-node',
|
|
role: 'companion',
|
|
platform: 'ios',
|
|
capabilities: ['ui.canvas', 'node.push.register'],
|
|
},
|
|
runtime: {
|
|
heartbeatSeconds: 30,
|
|
handoffTimeoutMs: 120000,
|
|
autoReconnect: true,
|
|
},
|
|
status: {
|
|
appVersion: '1.2.3',
|
|
statusText: 'ready',
|
|
powerSource: 'battery',
|
|
},
|
|
location: {
|
|
latitude: 37.3349,
|
|
longitude: -122.009,
|
|
source: 'gps',
|
|
},
|
|
push: {
|
|
provider: 'apns',
|
|
token: '0123456789abcdef0123456789abcdef',
|
|
topic: 'com.flynn.mobile',
|
|
environment: 'production',
|
|
},
|
|
},
|
|
});
|
|
|
|
const manifestRaw = await readFile(result.manifestPath, 'utf8');
|
|
const launcherRaw = await readFile(result.launcherPath, 'utf8');
|
|
const readmeRaw = await readFile(result.readmePath, 'utf8');
|
|
const checksumsRaw = await readFile(result.checksumsPath, 'utf8');
|
|
const bundleManifestRaw = await readFile(result.releaseManifestPath, 'utf8');
|
|
const launcherStat = await stat(result.launcherPath);
|
|
|
|
expect(JSON.parse(manifestRaw)).toMatchObject({
|
|
node: { nodeId: 'ios-node', platform: 'ios' },
|
|
push: { provider: 'apns' },
|
|
});
|
|
expect(launcherRaw).toContain('exec flynn');
|
|
expect(launcherRaw).toContain('--push-token');
|
|
expect(launcherRaw).toContain('--latitude');
|
|
expect(launcherRaw).toContain('sha256sum --check CHECKSUMS.sha256');
|
|
expect(readmeRaw).toContain('Flynn Companion Release Bundle');
|
|
expect(checksumsRaw).toContain('companion.bootstrap.json');
|
|
expect(checksumsRaw).toContain('run-companion.sh');
|
|
expect(checksumsRaw).toContain('README.md');
|
|
expect(JSON.parse(bundleManifestRaw)).toMatchObject({
|
|
schemaVersion: 1,
|
|
bundle: { nodeId: 'ios-node', platform: 'ios' },
|
|
files: expect.arrayContaining([
|
|
expect.objectContaining({ path: 'CHECKSUMS.sha256' }),
|
|
]),
|
|
});
|
|
expect((launcherStat.mode & 0o111) !== 0).toBe(true);
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('writes signature artifact when signing key is provided', async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), 'flynn-companion-release-sign-'));
|
|
const outputDir = join(tempDir, 'bundle');
|
|
const keyPair = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
const privatePem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
const publicPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
|
|
const result = await writeCompanionReleaseBundle({
|
|
outputDir,
|
|
signingKeyPem: privatePem,
|
|
signingKeyId: 'test-key',
|
|
manifest: {
|
|
schemaVersion: 1,
|
|
generatedAt: '2026-02-27T00:00:00.000Z',
|
|
gateway: { url: 'ws://127.0.0.1:18800' },
|
|
node: {
|
|
nodeId: 'macos-node',
|
|
role: 'companion',
|
|
platform: 'macos',
|
|
capabilities: ['ui.canvas'],
|
|
},
|
|
runtime: {
|
|
heartbeatSeconds: 30,
|
|
handoffTimeoutMs: 120000,
|
|
autoReconnect: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.signaturePath).toBe(`${outputDir}/CHECKSUMS.sha256.sig`);
|
|
expect(result.releaseManifestPath).toBe(`${outputDir}/RELEASE_MANIFEST.json`);
|
|
|
|
const checksumsRaw = await readFile(result.checksumsPath, 'utf8');
|
|
const signatureRaw = await readFile(result.signaturePath!, 'utf8');
|
|
const bundleManifestRaw = await readFile(result.releaseManifestPath, 'utf8');
|
|
const signatureLine = signatureRaw.split('\n').find((line) => line.startsWith('signature='));
|
|
expect(signatureLine).toBeTruthy();
|
|
expect(signatureRaw).toContain('key_id=test-key');
|
|
expect(JSON.parse(bundleManifestRaw)).toMatchObject({
|
|
signature: {
|
|
path: 'CHECKSUMS.sha256.sig',
|
|
keyId: 'test-key',
|
|
},
|
|
});
|
|
|
|
const signature = Buffer.from(String(signatureLine).replace('signature=', ''), 'base64');
|
|
const verified = verify(
|
|
'sha256',
|
|
Buffer.from(checksumsRaw, 'utf8'),
|
|
createPublicKey(publicPem),
|
|
signature,
|
|
);
|
|
expect(verified).toBe(true);
|
|
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
});
|
|
});
|