90 lines
5.2 KiB
JavaScript
90 lines
5.2 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
import test from 'node:test';
|
|
import { deploymentInputs, parseArgs, portalInput, releaseManifest, rsyncArguments, rsyncTransport, sshTarget } from './deploy.mjs';
|
|
|
|
const fixturePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'contabo-release-manifest-v1.json');
|
|
const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8'));
|
|
|
|
async function writeContractArtifact(root) {
|
|
for (const [relative, contents] of Object.entries(fixture.input.files)) {
|
|
const target = path.join(root, relative);
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
await fs.writeFile(target, contents);
|
|
}
|
|
}
|
|
|
|
test('site manifest follows the pinned merged-Contabo release contract and detects unsafe public output', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-manifest-'));
|
|
try {
|
|
await writeContractArtifact(root);
|
|
const manifest = await releaseManifest(root, fixture.input.sourceCommit);
|
|
assert.deepEqual(manifest, fixture.expected);
|
|
const serverTool = process.env.AIA_CONTABO_RELEASE_TOOL;
|
|
if (serverTool) {
|
|
const parity = spawnSync('python3', [serverTool, 'manifest', root, fixture.input.sourceCommit], { encoding: 'utf8' });
|
|
assert.equal(parity.status, 0, `Configured Contabo release tool failed: ${parity.stderr}`);
|
|
assert.deepEqual(manifest, JSON.parse(parity.stdout));
|
|
} else {
|
|
t.diagnostic(`Live Contabo-tool parity was not run; this test validates the checked-in schema fixture generated from ${fixture.contaboCommit}. Set AIA_CONTABO_RELEASE_TOOL to the merged website-release.py path for direct parity.`);
|
|
}
|
|
await fs.mkdir(path.join(root, 'private'));
|
|
await assert.rejects(releaseManifest(root, fixture.input.sourceCommit), /Unexpected private artifact/);
|
|
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('deployment transfer uses the Contabo ownership and mode interface', () => {
|
|
assert.deepEqual(rsyncArguments('/tmp/source', 'host:/srv/stage', { remove: true }).slice(0, 6), ['-rlt','--delete','--links','--chown=root:root','--chmod=Du=rwx,Dgo=,Fu=rw,Fgo=','/tmp/source/']);
|
|
assert.deepEqual(sshTarget('root@host.example', '/absolute/key').slice(0, 3), ['ssh','-i','/absolute/key']);
|
|
assert.throws(() => sshTarget('host;rm -rf /', '/absolute/key'), /invalid/);
|
|
});
|
|
|
|
test('deployment accepts a positional OpenSSH alias and ciphertext ahead of environment settings', () => {
|
|
const options = parseArgs(['--dry-run', 'aiar', '/private files/portal ciphertext.json']);
|
|
const inputs = deploymentInputs(options, {
|
|
AIA_DEPLOY_HOST: 'wrong.example',
|
|
AIA_DEPLOY_SSH_IDENTITY: '/wrong/key',
|
|
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: '/wrong/payload.json',
|
|
});
|
|
assert.deepEqual(inputs, {
|
|
host: 'aiar', identity: undefined, ciphertext: '/private files/portal ciphertext.json', allowSshConfig: true, source: 'positional',
|
|
});
|
|
assert.deepEqual(sshTarget(inputs.host, inputs.identity, { allowSshConfig: inputs.allowSshConfig }), ['ssh']);
|
|
assert.equal(rsyncTransport(['ssh']), "'ssh'");
|
|
});
|
|
|
|
test('deployment accepts positional explicit identities without shell-splitting paths', () => {
|
|
const options = parseArgs(['--confirm', 'root@66.94.121.180', '/keys/AIA Marketing', '/private files/portal ciphertext.json']);
|
|
const inputs = deploymentInputs(options, {});
|
|
assert.equal(inputs.allowSshConfig, false);
|
|
assert.equal(inputs.identity, '/keys/AIA Marketing');
|
|
assert.deepEqual(sshTarget(inputs.host, inputs.identity), ['ssh', '-i', '/keys/AIA Marketing', '-o', 'IdentitiesOnly=yes']);
|
|
assert.equal(rsyncTransport(sshTarget(inputs.host, inputs.identity)), "'ssh' '-i' '/keys/AIA Marketing' '-o' 'IdentitiesOnly=yes'");
|
|
});
|
|
|
|
test('deployment retains environment-only compatibility and rejects malformed positional input', () => {
|
|
const env = {
|
|
AIA_DEPLOY_HOST: 'root@host.example',
|
|
AIA_DEPLOY_SSH_IDENTITY: '/absolute/key',
|
|
AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE: '/private/payload.json',
|
|
};
|
|
assert.deepEqual(deploymentInputs(parseArgs(['--confirm']), env), {
|
|
host: env.AIA_DEPLOY_HOST, identity: env.AIA_DEPLOY_SSH_IDENTITY, ciphertext: env.AIA_PORTAL_PRODUCTION_CIPHERTEXT_FILE, allowSshConfig: false, source: 'environment',
|
|
});
|
|
assert.throws(() => parseArgs(['--dry-run', 'aiar']), /zero, two, or three positional/);
|
|
assert.throws(() => parseArgs(['--dry-run', '--confirm']), /Choose exactly one/);
|
|
assert.throws(() => deploymentInputs(parseArgs(['--dry-run', 'aiar', 'relative.json']), {}), /must be absolute/);
|
|
assert.throws(() => deploymentInputs(parseArgs(['--dry-run', 'aiar', 'relative-key', '/private/payload.json']), {}), /must be an absolute path/);
|
|
});
|
|
|
|
test('a supplied ciphertext path must exist and be safe before a deployment build starts', async () => {
|
|
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-deploy-missing-ciphertext-'));
|
|
try {
|
|
await assert.rejects(portalInput(path.join(temporary, 'missing.json'), temporary), /Portal ciphertext is unavailable or unsafe/);
|
|
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
|
});
|