diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index 60610a3..e787c8b 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -31,6 +31,7 @@ import { alignWindowsTask, checkWindowsArtifactUpdateSupport, normalizeWindowsPath, + probeInstallerUpdateSupport, probeWindowsAgentProcess, probeWindowsElevation, probeWindowsTask, @@ -481,9 +482,13 @@ describe('comparación de rutas de Windows', () => { * agente viejo y sin agente nuevo. */ describe('checkWindowsArtifactUpdateSupport', () => { + const SI = { answer: 'si', detail: '' } as const; + const NO = { answer: 'no', detail: '' } as const; + const NOSE = { answer: 'desconocido', detail: 'Get-Content: acceso denegado' } as const; + it('ACTUALIZAR con un instalador antiguo se rechaza en vez de romper el servidor', () => { try { - checkWindowsArtifactUpdateSupport('update', false, '1.1.3', 'Principal'); + checkWindowsArtifactUpdateSupport('update', NO, '1.1.3', 'Principal'); throw new Error('se esperaba que lanzara'); } catch (e) { const err = e as InstallError; @@ -500,14 +505,89 @@ describe('checkWindowsArtifactUpdateSupport', () => { it('INSTALAR limpio con el mismo artefacto solo avisa', () => { // Ahí no hay agente en marcha que perder, así que negarse solo estorbaría. - const aviso = checkWindowsArtifactUpdateSupport('install', false, '1.1.3', 'Principal'); + const aviso = checkWindowsArtifactUpdateSupport('install', NO, '1.1.3', 'Principal'); expect(aviso).toContain('1.1.3'); expect(aviso).toMatch(/Se instala igual/); }); it('un artefacto con los arreglos no dice nada, en ninguno de los dos modos', () => { - expect(checkWindowsArtifactUpdateSupport('update', true, '1.1.4', 'Principal')).toBe(''); - expect(checkWindowsArtifactUpdateSupport('install', true, '1.1.4', 'Principal')).toBe(''); + expect(checkWindowsArtifactUpdateSupport('update', SI, '1.1.4', 'Principal')).toBe(''); + expect(checkWindowsArtifactUpdateSupport('install', SI, '1.1.4', 'Principal')).toBe(''); + }); + + it('NO SABER no bloquea: es la prueba que faltaba y la que dejó 1.1.4 sin poder instalarse', () => { + // La sonda anterior colapsaba cualquier fallo en "no lo soporta", y sobre esa lectura se + // decidía un 409. Con un artefacto correcto, eso era un candado permanente. + const aviso = checkWindowsArtifactUpdateSupport('update', NOSE, '1.1.4', 'Principal'); + expect(aviso).toMatch(/No se pudo determinar/); + // Y dice POR QUÉ no se pudo, que es lo que evita adivinar la próxima vez. + expect(aviso).toContain('acceso denegado'); + }); + + it('tampoco bloquea al instalar cuando no se pudo determinar', () => { + expect(checkWindowsArtifactUpdateSupport('install', NOSE, '1.1.4', 'Principal')).toMatch( + /No se pudo determinar/ + ); + }); +}); + +/** + * La sonda que lee el instalador del artefacto. + * + * Lo que se protege aquí es la distinción entre "no lo declara" y "no pude leerlo". La versión + * anterior preguntaba por `(Get-Command …).Parameters`, funcionaba en una máquina de desarrollo y + * fallaba en el servidor real; como cualquier fallo se leía como "no", el panel acabó rechazando un + * artefacto correcto. + */ +describe('probeInstallerUpdateSupport', () => { + const RUTA = 'C:\\Temp\\cras\\CloudRestoreAS\\install.ps1'; + + it('lee el texto del script en vez de compilarlo', async () => { + const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: 'si' })); + const r = await probeInstallerUpdateSupport(sftp as never, RUTA); + expect(r.answer).toBe('si'); + + const script = Buffer.from( + commands[0].split('-EncodedCommand ')[1] ?? '', + 'base64' + ).toString('utf16le'); + // Nada de reflexión: en Constrained Language Mode el acceso a .Parameters puede fallar + // mientras `& install.ps1` sigue funcionando, que es lo que se observó en el servidor. + expect(script).not.toContain('Get-Command'); + expect(script).not.toContain('.Parameters'); + expect(script).toContain('Get-Content'); + // Anclado a la DECLARACIÓN, para que un comentario que nombre el parámetro no cuente. + expect(script).toContain('switch'); + }); + + it('un "no" limpio sigue siendo un no', async () => { + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'no' })); + expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('no'); + }); + + it('código de salida distinto de cero es DESCONOCIDO, nunca "no"', async () => { + const { sftp } = fakeSftp(() => ({ code: 1, stdout: '', stderr: 'AmsiScanBuffer bloqueó' })); + const r = await probeInstallerUpdateSupport(sftp as never, RUTA); + expect(r.answer).toBe('desconocido'); + expect(r.detail).toContain('AmsiScanBuffer'); + }); + + it('stdout vacío con código 0 también es DESCONOCIDO', async () => { + // Es la forma exacta en que fallaba la sonda vieja: sin salida, y leído como respuesta. + const { sftp } = fakeSftp(() => ({ code: 0, stdout: ' ' })); + expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido'); + }); + + it('el archivo que no existe se distingue de un instalador antiguo', async () => { + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'desconocido|no existe el archivo' })); + const r = await probeInstallerUpdateSupport(sftp as never, RUTA); + expect(r.answer).toBe('desconocido'); + expect(r.detail).toBe('no existe el archivo'); + }); + + it('una salida inesperada no se interpreta como respuesta', async () => { + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'WARNING: algo raro' })); + expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido'); }); }); @@ -539,12 +619,22 @@ describe('sondas de ruta en Windows', () => { it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => { const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM'); expect(await probeWindowsTask(sftp as never)).toEqual({ + answered: true, exists: true, execute: 'C:\\Otra\\CloudRestoreAS.exe', principal: 'SYSTEM' }); }); + it('una sonda que no responde no significa "no hay tarea"', async () => { + // Con `answered: false` el llamador sabe que no se sabe. Antes, un comando fallido se leía + // como "no hay tarea registrada" y de ahí salían decisiones sobre elevación y realineación. + const { sftp } = fakeSftp(() => ({ code: 1, stdout: '' })); + const r = await probeWindowsTask(sftp as never); + expect(r.answered).toBe(false); + expect(r.exists).toBe(false); + }); + it('probeWindowsTask sin tarea registrada', async () => { const { sftp } = psFake('no||'); const r = await probeWindowsTask(sftp as never); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 9d6d31d..5cfb342 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -1041,7 +1041,7 @@ async function verifyLinuxDeployment( // Windows // ============================================================================ -export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado'; +export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado' | 'desconocido'; export interface WindowsPrivilege { elevation: WindowsElevation; @@ -1075,7 +1075,22 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise p.trim()).filter(Boolean); const wanted = windowsAgentExe(prefix); return { + answered: true, running: true, paths, fromPrefix: paths.some((p) => sameWindowsPath(p, wanted)) @@ -1187,6 +1214,8 @@ export async function probeWindowsAgentProcess( } export interface WindowsTaskProbe { + /** La sonda respondió. En false, `exists: false` significa "no sé", no "no hay tarea". */ + answered: boolean; exists: boolean; /** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */ execute: string; @@ -1221,14 +1250,84 @@ export async function probeWindowsTask(sftp: SftpClient): Promise { + const probe = await execRemote( + sftp, + psEncoded( + `$p = '${installerPath}'; ` + + "if (-not (Test-Path -LiteralPath $p)) { Write-Output 'desconocido|no existe el archivo'; exit 0 }; " + + 'try { ' + + '$t = Get-Content -LiteralPath $p -Raw -ErrorAction Stop; ' + + // El [regex]::IsMatch evita depender de $matches y del operador -match, que en + // modo restringido puede comportarse distinto. + 'if ([regex]::IsMatch($t, \'\\[switch\\]\\s*\\$UpdateInPlace\')) ' + + "{ Write-Output 'si' } else { Write-Output 'no' } } " + + "catch { Write-Output ('desconocido|' + $_.Exception.Message) }" + ) + ); + + const salida = probe.stdout.trim(); + // El código de salida y stderr cuentan: un execRemote que falla deja stdout vacío, y leer eso + // como una respuesta es precisamente el error que se está corrigiendo. + if (probe.code !== 0 || !salida) { + return { + answer: 'desconocido', + detail: + `la sonda no respondió (código ${probe.code})` + + (probe.stderr ? `: ${truncate(probe.stderr)}` : '') + }; + } + if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' }; + return { + answer: 'desconocido', + detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida) + }; +} + /** * ¿Se puede desplegar este artefacto en Windows sin romper el servidor? * @@ -1250,11 +1349,22 @@ export async function probeWindowsTask(sftp: SftpClient): Promise