basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, beforeEach, afterEach } from 'vitest';8import * as fs from 'node:fs';9import * as path from 'node:path';10import * as os from 'node:os';11import {12 rollbackStandaloneUpdate,13 ensureBinWrapper,14 ensurePathInShellRc,15 performStandaloneUpdate,16 isSafeTarEntryPath,17} from './standalone-update.js';18 19describe('standalone-update', () => {20 let tempDir: string;21 22 beforeEach(() => {23 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-update-test-'));24 });25 26 afterEach(() => {27 fs.rmSync(tempDir, { recursive: true, force: true });28 });29 30 describe('rollbackStandaloneUpdate', () => {31 it('returns no-old when .old directory does not exist', () => {32 const standaloneDir = path.join(tempDir, 'qwen-code');33 fs.mkdirSync(standaloneDir);34 fs.writeFileSync(35 path.join(standaloneDir, 'manifest.json'),36 JSON.stringify({37 name: '@qwen-code/qwen-code',38 target: 'darwin-arm64',39 }),40 );41 42 const result = rollbackStandaloneUpdate(standaloneDir);43 expect(result.ok).toBe(false);44 expect(result).toHaveProperty('reason', 'no-old');45 });46 47 it('returns no-manifest when .old directory has no manifest.json', () => {48 const standaloneDir = path.join(tempDir, 'qwen-code');49 const oldDir = `${standaloneDir}.old`;50 fs.mkdirSync(standaloneDir);51 fs.mkdirSync(oldDir);52 fs.writeFileSync(53 path.join(standaloneDir, 'manifest.json'),54 JSON.stringify({55 name: '@qwen-code/qwen-code',56 target: 'darwin-arm64',57 }),58 );59 60 const result = rollbackStandaloneUpdate(standaloneDir);61 expect(result.ok).toBe(false);62 expect(result).toHaveProperty('reason', 'no-manifest');63 });64 65 it('swaps current with .old directory on valid rollback', () => {66 const standaloneDir = path.join(tempDir, 'qwen-code');67 const oldDir = `${standaloneDir}.old`;68 fs.mkdirSync(standaloneDir);69 fs.mkdirSync(oldDir);70 71 fs.writeFileSync(72 path.join(standaloneDir, 'manifest.json'),73 JSON.stringify({74 name: '@qwen-code/qwen-code',75 target: 'darwin-arm64',76 version: '0.17.0',77 }),78 );79 fs.writeFileSync(path.join(standaloneDir, 'marker.txt'), 'new');80 81 fs.writeFileSync(82 path.join(oldDir, 'manifest.json'),83 JSON.stringify({84 name: '@qwen-code/qwen-code',85 target: 'darwin-arm64',86 version: '0.16.2',87 }),88 );89 fs.writeFileSync(path.join(oldDir, 'marker.txt'), 'old');90 91 const result = rollbackStandaloneUpdate(standaloneDir);92 expect(result.ok).toBe(true);93 94 const manifest = JSON.parse(95 fs.readFileSync(path.join(standaloneDir, 'manifest.json'), 'utf-8'),96 );97 expect(manifest.version).toBe('0.16.2');98 expect(99 fs.readFileSync(path.join(standaloneDir, 'marker.txt'), 'utf-8'),100 ).toBe('old');101 expect(fs.existsSync(oldDir)).toBe(false);102 });103 104 it('succeeds even with minimal manifest in .old', () => {105 const standaloneDir = path.join(tempDir, 'qwen-code');106 const oldDir = `${standaloneDir}.old`;107 fs.mkdirSync(standaloneDir);108 fs.mkdirSync(oldDir);109 110 fs.writeFileSync(111 path.join(standaloneDir, 'manifest.json'),112 JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }),113 );114 fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}');115 116 const result = rollbackStandaloneUpdate(standaloneDir);117 expect(result.ok).toBe(true);118 });119 });120 121 describe('ensureBinWrapper', () => {122 // Unix wrapper test relies on POSIX file permissions (mode bits) and123 // the SHELL env var, neither of which behave consistently on Windows.124 it.skipIf(process.platform === 'win32')(125 'creates a Unix shell wrapper script',126 () => {127 const libDir = path.join(tempDir, '.local', 'lib');128 const standaloneDir = path.join(libDir, 'qwen-code');129 fs.mkdirSync(standaloneDir, { recursive: true });130 131 // Isolate HOME so ensurePathInShellRc doesn't touch real shell rc132 const origHome = process.env['HOME'];133 const origShell = process.env['SHELL'];134 process.env['HOME'] = tempDir;135 process.env['SHELL'] = '/bin/zsh';136 try {137 ensureBinWrapper(standaloneDir, 'darwin-arm64');138 } finally {139 process.env['HOME'] = origHome;140 process.env['SHELL'] = origShell;141 }142 143 const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen');144 expect(fs.existsSync(wrapperPath)).toBe(true);145 const content = fs.readFileSync(wrapperPath, 'utf-8');146 expect(content).toContain('#!/bin/sh');147 expect(content).toContain(standaloneDir);148 const mode = fs.statSync(wrapperPath).mode;149 expect(mode & 0o111).toBeGreaterThan(0);150 },151 );152 153 it('creates a Windows cmd wrapper', () => {154 const libDir = path.join(tempDir, '.local', 'lib');155 const standaloneDir = path.join(libDir, 'qwen-code');156 fs.mkdirSync(standaloneDir, { recursive: true });157 158 ensureBinWrapper(standaloneDir, 'win-x64');159 160 const wrapperPath = path.join(tempDir, '.local', 'bin', 'qwen.cmd');161 expect(fs.existsSync(wrapperPath)).toBe(true);162 const content = fs.readFileSync(wrapperPath, 'utf-8');163 expect(content).toContain('@echo off');164 });165 166 it.skipIf(process.platform === 'win32')(167 'does not overwrite existing wrapper',168 () => {169 const libDir = path.join(tempDir, '.local', 'lib');170 const standaloneDir = path.join(libDir, 'qwen-code');171 const binDir = path.join(tempDir, '.local', 'bin');172 fs.mkdirSync(standaloneDir, { recursive: true });173 fs.mkdirSync(binDir, { recursive: true });174 175 const origHome = process.env['HOME'];176 const origShell = process.env['SHELL'];177 process.env['HOME'] = tempDir;178 process.env['SHELL'] = '/bin/zsh';179 180 const wrapperPath = path.join(binDir, 'qwen');181 fs.writeFileSync(wrapperPath, 'existing-content', { mode: 0o755 });182 183 try {184 ensureBinWrapper(standaloneDir, 'linux-x64');185 expect(fs.readFileSync(wrapperPath, 'utf-8')).toBe(186 'existing-content',187 );188 } finally {189 process.env['HOME'] = origHome;190 process.env['SHELL'] = origShell;191 }192 },193 );194 });195 196 describe('performStandaloneUpdate', () => {197 it('rejects invalid version format', async () => {198 const standaloneDir = path.join(tempDir, 'qwen-code');199 fs.mkdirSync(standaloneDir);200 fs.writeFileSync(201 path.join(standaloneDir, 'manifest.json'),202 JSON.stringify({203 name: '@qwen-code/qwen-code',204 target: 'darwin-arm64',205 }),206 );207 208 await expect(209 performStandaloneUpdate(standaloneDir, 'not-a-version'),210 ).rejects.toThrow('Invalid version format');211 });212 213 it('rejects directory without manifest as non-managed install', async () => {214 const standaloneDir = path.join(tempDir, 'qwen-code');215 fs.mkdirSync(standaloneDir);216 // No manifest.json — could be user data217 218 await expect(219 performStandaloneUpdate(standaloneDir, '1.0.0'),220 ).rejects.toThrow('not a Qwen Code standalone install');221 });222 223 it('rejects unknown target in manifest', async () => {224 const standaloneDir = path.join(tempDir, 'qwen-code');225 fs.mkdirSync(standaloneDir);226 fs.writeFileSync(227 path.join(standaloneDir, 'manifest.json'),228 JSON.stringify({229 name: '@qwen-code/qwen-code',230 target: 'freebsd-mips',231 }),232 );233 234 await expect(235 performStandaloneUpdate(standaloneDir, '1.0.0'),236 ).rejects.toThrow('Unknown target');237 });238 239 it('fails gracefully when another update is in progress', async () => {240 const standaloneDir = path.join(tempDir, 'qwen-code');241 const parentDir = path.dirname(standaloneDir);242 fs.mkdirSync(standaloneDir, { recursive: true });243 fs.writeFileSync(244 path.join(standaloneDir, 'manifest.json'),245 JSON.stringify({246 name: '@qwen-code/qwen-code',247 target: 'darwin-arm64',248 }),249 );250 251 // Simulate held lock from a live process (current PID)252 const lockPath = path.join(parentDir, '.qwen-update.lock');253 fs.writeFileSync(lockPath, String(process.pid));254 255 await expect(256 performStandaloneUpdate(standaloneDir, '1.0.0'),257 ).rejects.toThrow('Another update is already in progress');258 259 // Clean up lock260 fs.unlinkSync(lockPath);261 });262 });263 264 describe('isSafeTarEntryPath', () => {265 it('allows double dots inside a filename segment', () => {266 expect(isSafeTarEntryPath('qwen-code/release..notes.md')).toBe(true);267 expect(isSafeTarEntryPath('qwen-code/node/lib/foo..bar')).toBe(true);268 expect(isSafeTarEntryPath('qwen-code/.../file.txt')).toBe(true);269 });270 271 it('rejects parent-directory segments and absolute paths', () => {272 expect(isSafeTarEntryPath('../qwen-code/manifest.json')).toBe(false);273 expect(isSafeTarEntryPath('qwen-code/../manifest.json')).toBe(false);274 expect(isSafeTarEntryPath('qwen-code\\..\\manifest.json')).toBe(false);275 expect(isSafeTarEntryPath('/tmp/qwen-code/manifest.json')).toBe(false);276 expect(isSafeTarEntryPath('C:\\tmp\\qwen-code\\manifest.json')).toBe(277 false,278 );279 expect(isSafeTarEntryPath('')).toBe(false);280 });281 });282 283 describe('rollbackStandaloneUpdate — concurrent lock protection', () => {284 it('returns error when an active update holds the lock', () => {285 const standaloneDir = path.join(tempDir, 'qwen-code');286 const oldDir = `${standaloneDir}.old`;287 const lockPath = path.join(tempDir, '.qwen-update.lock');288 fs.mkdirSync(standaloneDir);289 fs.mkdirSync(oldDir);290 fs.writeFileSync(path.join(standaloneDir, 'manifest.json'), '{}');291 fs.writeFileSync(path.join(oldDir, 'manifest.json'), '{}');292 fs.writeFileSync(lockPath, String(process.pid));293 const result = rollbackStandaloneUpdate(standaloneDir);294 expect(result.ok).toBe(false);295 if (!result.ok) {296 expect(result.detail).toContain('auto-update is currently in progress');297 }298 fs.unlinkSync(lockPath);299 });300 301 it('proceeds when lock has dead PID', () => {302 const standaloneDir = path.join(tempDir, 'qwen-code');303 const oldDir = `${standaloneDir}.old`;304 const lockPath = path.join(tempDir, '.qwen-update.lock');305 fs.mkdirSync(standaloneDir);306 fs.mkdirSync(oldDir);307 fs.writeFileSync(308 path.join(standaloneDir, 'manifest.json'),309 JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.17.0' }),310 );311 fs.writeFileSync(312 path.join(oldDir, 'manifest.json'),313 JSON.stringify({ name: '@qwen-code/qwen-code', version: '0.16.0' }),314 );315 fs.writeFileSync(lockPath, '999999999');316 const result = rollbackStandaloneUpdate(standaloneDir);317 expect(result.ok).toBe(true);318 });319 });320 321 describe.skipIf(process.platform === 'win32')('ensurePathInShellRc', () => {322 it('appends PATH export to zshrc when SHELL is zsh', () => {323 const binDir = path.join(tempDir, 'bin');324 const zshrc = path.join(tempDir, '.zshrc');325 fs.writeFileSync(zshrc, '# existing config\n');326 327 const origShell = process.env['SHELL'];328 const origHome = process.env['HOME'];329 process.env['SHELL'] = '/bin/zsh';330 process.env['HOME'] = tempDir;331 332 try {333 ensurePathInShellRc(binDir);334 const content = fs.readFileSync(zshrc, 'utf-8');335 expect(content).toContain('# Added by Qwen Code standalone installer');336 expect(content).toContain(`export PATH="${binDir}:$PATH"`);337 } finally {338 process.env['SHELL'] = origShell;339 process.env['HOME'] = origHome;340 }341 });342 343 it('skips if marker already in rc file', () => {344 const binDir = path.join(tempDir, 'bin');345 const zshrc = path.join(tempDir, '.zshrc');346 fs.writeFileSync(347 zshrc,348 `# Added by Qwen Code standalone installer\nexport PATH="${binDir}:$PATH"\n`,349 );350 351 const origShell = process.env['SHELL'];352 const origHome = process.env['HOME'];353 process.env['SHELL'] = '/bin/zsh';354 process.env['HOME'] = tempDir;355 356 try {357 ensurePathInShellRc(binDir);358 const content = fs.readFileSync(zshrc, 'utf-8');359 const matches = content.match(360 /# Added by Qwen Code standalone installer/g,361 );362 expect(matches).toHaveLength(1);363 } finally {364 process.env['SHELL'] = origShell;365 process.env['HOME'] = origHome;366 }367 });368 369 it('appends fish_add_path for fish shell', () => {370 const binDir = path.join(tempDir, 'bin');371 const fishDir = path.join(tempDir, '.config', 'fish');372 const fishConfig = path.join(fishDir, 'config.fish');373 fs.mkdirSync(fishDir, { recursive: true });374 fs.writeFileSync(fishConfig, '# existing config\n');375 const origShell = process.env['SHELL'];376 const origHome = process.env['HOME'];377 process.env['SHELL'] = '/usr/bin/fish';378 process.env['HOME'] = tempDir;379 try {380 ensurePathInShellRc(binDir);381 const content = fs.readFileSync(fishConfig, 'utf-8');382 expect(content).toContain('fish_add_path');383 expect(content).toContain(binDir);384 } finally {385 process.env['SHELL'] = origShell;386 process.env['HOME'] = origHome;387 }388 });389 390 it('rejects binDir with shell metacharacters', () => {391 const binDir = path.join(tempDir, 'bin$(evil)');392 const origShell = process.env['SHELL'];393 const origHome = process.env['HOME'];394 process.env['SHELL'] = '/bin/zsh';395 process.env['HOME'] = tempDir;396 try {397 expect(() => ensurePathInShellRc(binDir)).toThrow(398 'unsafe for shell embedding',399 );400 } finally {401 process.env['SHELL'] = origShell;402 process.env['HOME'] = origHome;403 }404 });405 406 it('does nothing for unknown shells', () => {407 const binDir = path.join(tempDir, 'bin');408 const origShell = process.env['SHELL'];409 const origHome = process.env['HOME'];410 process.env['SHELL'] = '/bin/csh';411 process.env['HOME'] = tempDir;412 413 try {414 ensurePathInShellRc(binDir);415 // No rc file should be created416 expect(417 fs.readdirSync(tempDir).filter((f) => f.startsWith('.')),418 ).toHaveLength(0);419 } finally {420 process.env['SHELL'] = origShell;421 process.env['HOME'] = origHome;422 }423 });424 });425});426 