feat: automate extension updates across Windows hosts
This commit is contained in:
1 parent
500034bb63
commit
f08aac0c9e
36 files changed
+2996
-71
No files matched your search
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
import JSZip from 'jszip';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import {
|
||||
ExtensionUpdateError,
|
||||
buildExtensionUpdatePowerShell,
|
||||
compareExtensionVersions,
|
||||
createExtensionDownloadToken,
|
||||
inspectExtensionPackage,
|
||||
interpretCloudAssistantInvocation,
|
||||
verifyExtensionDownloadToken
|
||||
} from '../src/extension-updates.js';
|
||||
|
||||
async function extensionZip(version = '0.5.167'): Promise<Buffer> {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.json', JSON.stringify({
|
||||
manifest_version: 3,
|
||||
name: '联泰下单助手',
|
||||
version,
|
||||
background: { service_worker: 'background.js' },
|
||||
content_scripts: [{ matches: ['https://business.example.test/*'], js: ['business-bridge.js'] }]
|
||||
}));
|
||||
zip.file('background.js', 'chrome.runtime.onMessage.addListener(() => {});');
|
||||
zip.file('business-bridge.js', 'window.postMessage({ ok: true });');
|
||||
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
||||
}
|
||||
|
||||
test('extension versions compare numerically rather than lexically', () => {
|
||||
assert.equal(compareExtensionVersions('0.5.167', '0.5.166'), 1);
|
||||
assert.equal(compareExtensionVersions('0.10.0', '0.9.99'), 1);
|
||||
assert.equal(compareExtensionVersions('1.0.0', '1.0.0.0'), 0);
|
||||
assert.equal(compareExtensionVersions('0.5.166', '0.5.167'), -1);
|
||||
assert.throws(
|
||||
() => compareExtensionVersions('latest', '0.5.167'),
|
||||
(error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_version_invalid'
|
||||
);
|
||||
});
|
||||
|
||||
test('extension release inspection binds identity, version, required files, and SHA-256', async () => {
|
||||
const content = await extensionZip();
|
||||
const inspected = await inspectExtensionPackage(content);
|
||||
assert.equal(inspected.version, '0.5.167');
|
||||
assert.equal(inspected.manifest.name, '联泰下单助手');
|
||||
assert.equal(inspected.entryCount, 3);
|
||||
assert.equal(inspected.sha256, createHash('sha256').update(content).digest('hex'));
|
||||
|
||||
const invalid = new JSZip();
|
||||
invalid.file('manifest.json', JSON.stringify({
|
||||
manifest_version: 3,
|
||||
name: '其他插件',
|
||||
version: '0.5.167',
|
||||
background: { service_worker: 'background.js' },
|
||||
content_scripts: [{ matches: ['https://business.example.test/*'], js: ['business-bridge.js'] }]
|
||||
}));
|
||||
invalid.file('background.js', '');
|
||||
invalid.file('business-bridge.js', '');
|
||||
await assert.rejects(
|
||||
inspectExtensionPackage(await invalid.generateAsync({ type: 'nodebuffer' })),
|
||||
(error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_manifest_identity_mismatch'
|
||||
);
|
||||
|
||||
const unsafePath = await extensionZip();
|
||||
const unsafeZip = await JSZip.loadAsync(unsafePath);
|
||||
unsafeZip.file('asset.js:alternate-stream', 'forbidden');
|
||||
await assert.rejects(
|
||||
inspectExtensionPackage(await unsafeZip.generateAsync({ type: 'nodebuffer' })),
|
||||
(error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_package_path_invalid'
|
||||
);
|
||||
});
|
||||
|
||||
test('short-lived host-scoped package tokens reject tampering and expiry', () => {
|
||||
const key = Buffer.alloc(32, 7);
|
||||
const payload = {
|
||||
releaseId: 'release-a',
|
||||
organizationId: 'organization-a',
|
||||
regionId: 'cn-hangzhou',
|
||||
instanceId: 'i-12345678',
|
||||
expiresAt: 2_000
|
||||
};
|
||||
const token = createExtensionDownloadToken(payload, key);
|
||||
assert.deepEqual(verifyExtensionDownloadToken(token, key, 1_999), payload);
|
||||
assert.throws(
|
||||
() => verifyExtensionDownloadToken(`${token}x`, key, 1_999),
|
||||
(error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_download_token_invalid'
|
||||
);
|
||||
assert.throws(
|
||||
() => verifyExtensionDownloadToken(token, key, 2_001),
|
||||
(error: unknown) => error instanceof ExtensionUpdateError && error.code === 'extension_download_token_expired'
|
||||
);
|
||||
});
|
||||
|
||||
test('PowerShell updater carries encoded values, enforces ProgramData, hash, staging, and rollback', () => {
|
||||
const script = buildExtensionUpdatePowerShell({
|
||||
downloadUrl: 'https://business.example.test/api/extension-updates/package/token',
|
||||
sha256: 'a'.repeat(64),
|
||||
version: '0.5.167',
|
||||
installPath: 'C:\\ProgramData\\LTJT\\chrome-extension\\ltjt-order-assistant',
|
||||
releaseId: 'release-a'
|
||||
});
|
||||
assert.match(script, /Get-FileHash/);
|
||||
assert.match(script, /PackageHashMismatch/);
|
||||
assert.match(script, /InstallPathOutsideAllowedRoot/);
|
||||
assert.match(script, /LTJT_EXTENSION_NEWER_PRESENT/);
|
||||
assert.match(script, /\.previous/);
|
||||
assert.match(script, /Move-Item -LiteralPath \$backupPath -Destination \$installPath/);
|
||||
assert.ok(Buffer.byteLength(Buffer.from(script, 'utf8').toString('base64')) < 24 * 1024);
|
||||
assert.doesNotMatch(script, /business\.example\.test/);
|
||||
});
|
||||
|
||||
test('Cloud Assistant success requires exit zero and the updater completion marker', () => {
|
||||
assert.deepEqual(
|
||||
interpretCloudAssistantInvocation({
|
||||
invocationStatus: 'Success',
|
||||
exitCode: 0,
|
||||
output: 'LTJT_EXTENSION_UPDATED 0.5.167\n'
|
||||
}),
|
||||
{
|
||||
status: 'success',
|
||||
exitCode: 0,
|
||||
output: 'LTJT_EXTENSION_UPDATED 0.5.167\n'
|
||||
}
|
||||
);
|
||||
assert.equal(
|
||||
interpretCloudAssistantInvocation({ invocationStatus: 'Success', exitCode: 0, output: '' }).errorCode,
|
||||
'extension_update_marker_missing'
|
||||
);
|
||||
assert.equal(
|
||||
interpretCloudAssistantInvocation({ invocationStatus: 'Running', output: '' }).status,
|
||||
'running'
|
||||
);
|
||||
assert.equal(
|
||||
interpretCloudAssistantInvocation({
|
||||
invocationStatus: 'Success',
|
||||
exitCode: 0,
|
||||
output: 'LTJT_EXTENSION_NEWER_PRESENT 0.5.168\r\n'
|
||||
}).status,
|
||||
'success'
|
||||
);
|
||||
assert.equal(
|
||||
interpretCloudAssistantInvocation({ invocationStatus: 'Aborted', errorCode: 'ClientNotRunning' }).status,
|
||||
'failed'
|
||||
);
|
||||
});
|
||||
|
||||
test('extension updater is off by default and enabled production config requires HTTPS plus OSS and ECS credentials', () => {
|
||||
const disabled = loadConfig({ NODE_ENV: 'test' });
|
||||
assert.equal(disabled.EXTENSION_AUTO_UPDATE_ENABLED, false);
|
||||
assert.equal(disabled.EXTENSION_WINDOWS_INSTALL_PATH, 'C:\\ProgramData\\LTJT\\chrome-extension\\ltjt-order-assistant');
|
||||
assert.throws(() => loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString('base64'),
|
||||
APP_ORIGIN: 'https://business.example.test',
|
||||
EXTENSION_AUTO_UPDATE_ENABLED: 'true'
|
||||
}), /missing configuration/);
|
||||
assert.throws(() => loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
FIELD_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString('base64'),
|
||||
APP_ORIGIN: 'http://business.example.test',
|
||||
EXTENSION_AUTO_UPDATE_ENABLED: 'true',
|
||||
OSS_ACCESS_KEY_ID: 'oss-id',
|
||||
OSS_ACCESS_KEY_SECRET: 'oss-secret',
|
||||
OSS_ENDPOINT: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
OSS_BUCKET_NAME: 'bucket',
|
||||
OSS_REGION: 'cn-hangzhou',
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: 'ecs-id',
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: 'ecs-secret'
|
||||
}), /must use HTTPS/);
|
||||
});
|
||||
Reference in new issue
Block a user