|
| 1 | +/********************************************************************** |
| 2 | + * Copyright (C) 2026 Red Hat, Inc. |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + * |
| 16 | + * SPDX-License-Identifier: Apache-2.0 |
| 17 | + ***********************************************************************/ |
| 18 | + |
| 19 | +import type * as extensionApi from '@podman-desktop/api'; |
| 20 | +import { beforeEach, describe, expect, test, vi } from 'vitest'; |
| 21 | + |
| 22 | +import * as crcCli from './crc-cli.js'; |
| 23 | +import * as daemon from './daemon-commander.js'; |
| 24 | +import type { Status } from './types.js'; |
| 25 | + |
| 26 | +// Mock node:child_process — keychainStore / keychainLoad use execFile |
| 27 | +const mockExecFile = vi.fn(); |
| 28 | +vi.mock('node:child_process', () => ({ |
| 29 | + execFile: (...args: unknown[]) => mockExecFile(...args), |
| 30 | +})); |
| 31 | + |
| 32 | +vi.mock('node:util', () => ({ |
| 33 | + promisify: (fn: unknown) => |
| 34 | + vi.fn().mockImplementation((...args: unknown[]) => { |
| 35 | + return new Promise((resolve, reject) => { |
| 36 | + // eslint-disable-next-line @typescript-eslint/no-unsafe-call |
| 37 | + (fn as (...a: unknown[]) => void)(...args, (error: Error | null, stdout: string, stderr: string) => { |
| 38 | + if (error) reject(error); |
| 39 | + else resolve({ stdout: stdout ?? '', stderr: stderr ?? '' }); |
| 40 | + }); |
| 41 | + }); |
| 42 | + }), |
| 43 | +})); |
| 44 | + |
| 45 | +vi.mock('node:fs/promises', () => ({ |
| 46 | + mkdtemp: vi.fn().mockResolvedValue('/tmp/crc-ps-test'), |
| 47 | + writeFile: vi.fn().mockResolvedValue(undefined), |
| 48 | + unlink: vi.fn().mockResolvedValue(undefined), |
| 49 | + rmdir: vi.fn().mockResolvedValue(undefined), |
| 50 | +})); |
| 51 | + |
| 52 | +vi.mock('node:os', () => ({ |
| 53 | + tmpdir: () => '/tmp', |
| 54 | +})); |
| 55 | + |
| 56 | +vi.mock('node:path', async importOriginal => { |
| 57 | + return importOriginal(); |
| 58 | +}); |
| 59 | + |
| 60 | +vi.mock('./crc-cli.js', () => ({ |
| 61 | + execPromise: vi.fn(), |
| 62 | + getCrcCli: vi.fn().mockReturnValue('/usr/local/bin/crc'), |
| 63 | +})); |
| 64 | + |
| 65 | +vi.mock('./daemon-commander.js', () => ({ |
| 66 | + commander: { |
| 67 | + status: vi.fn(), |
| 68 | + }, |
| 69 | +})); |
| 70 | + |
| 71 | +vi.mock('./util.js', () => ({ |
| 72 | + productName: 'OpenShift Local', |
| 73 | +})); |
| 74 | + |
| 75 | +beforeEach(() => { |
| 76 | + vi.restoreAllMocks(); |
| 77 | +}); |
| 78 | + |
| 79 | +function mockKeychainLoadSuccess(pullSecret: string): void { |
| 80 | + // execFileAsync calls execFile(path, args, callback) |
| 81 | + mockExecFile.mockImplementation( |
| 82 | + (cmd: string, args: string[], callback?: (err: Error | null, stdout: string, stderr: string) => void) => { |
| 83 | + if (typeof callback === 'function') { |
| 84 | + // This is the promisified execFile path (keychainLoad) |
| 85 | + callback(null, pullSecret + '\n', ''); |
| 86 | + } |
| 87 | + // Return a mock child process for the non-promisified path (keychainStore) |
| 88 | + return { |
| 89 | + stdin: { write: vi.fn(), end: vi.fn() }, |
| 90 | + stdout: null, |
| 91 | + stderr: null, |
| 92 | + }; |
| 93 | + }, |
| 94 | + ); |
| 95 | +} |
| 96 | + |
| 97 | +function mockKeychainLoadFailure(exitCode = 44): void { |
| 98 | + mockExecFile.mockImplementation( |
| 99 | + (cmd: string, args: string[], callback?: (err: Error | null, stdout: string, stderr: string) => void) => { |
| 100 | + if (typeof callback === 'function') { |
| 101 | + const err: Error & { code?: number } = new Error('The specified item could not be found in the keychain.'); |
| 102 | + err.code = exitCode; |
| 103 | + callback(err, '', ''); |
| 104 | + return; |
| 105 | + } |
| 106 | + return { |
| 107 | + stdin: { write: vi.fn(), end: vi.fn() }, |
| 108 | + stdout: null, |
| 109 | + stderr: null, |
| 110 | + }; |
| 111 | + }, |
| 112 | + ); |
| 113 | +} |
| 114 | + |
| 115 | +const mockLogger: extensionApi.Logger = { |
| 116 | + log: vi.fn(), |
| 117 | + warn: vi.fn(), |
| 118 | + error: vi.fn(), |
| 119 | +} as unknown as extensionApi.Logger; |
| 120 | + |
| 121 | +describe('hasPullSecret', () => { |
| 122 | + test('returns true when keychain has the pull secret', async () => { |
| 123 | + mockKeychainLoadSuccess('{"auths":{}}'); |
| 124 | + const { hasPullSecret } = await import('./crc-start-macos.js'); |
| 125 | + expect(await hasPullSecret()).toBe(true); |
| 126 | + }); |
| 127 | + |
| 128 | + test('returns false when keychain item does not exist (exit 44)', async () => { |
| 129 | + mockKeychainLoadFailure(44); |
| 130 | + const { hasPullSecret } = await import('./crc-start-macos.js'); |
| 131 | + expect(await hasPullSecret()).toBe(false); |
| 132 | + }); |
| 133 | + |
| 134 | + test('re-throws when keychain fails for a reason other than missing item', async () => { |
| 135 | + mockKeychainLoadFailure(1); |
| 136 | + const { hasPullSecret } = await import('./crc-start-macos.js'); |
| 137 | + await expect(hasPullSecret()).rejects.toThrow(); |
| 138 | + }); |
| 139 | +}); |
| 140 | + |
| 141 | +describe('storePullSecret', () => { |
| 142 | + test('calls security -i with the pull secret via stdin', async () => { |
| 143 | + const stdinWrite = vi.fn(); |
| 144 | + const stdinEnd = vi.fn(); |
| 145 | + |
| 146 | + mockExecFile.mockImplementation((cmd: string, args: string[], callback?: (err: Error | null) => void) => { |
| 147 | + if (Array.isArray(args) && args[0] === '-i') { |
| 148 | + // security -i for keychainStore |
| 149 | + if (typeof callback === 'function') { |
| 150 | + // simulate success after stdin is piped |
| 151 | + setTimeout(() => callback(null), 0); |
| 152 | + } |
| 153 | + return { |
| 154 | + stdin: { write: stdinWrite, end: stdinEnd }, |
| 155 | + }; |
| 156 | + } |
| 157 | + return { stdin: { write: vi.fn(), end: vi.fn() } }; |
| 158 | + }); |
| 159 | + |
| 160 | + const { storePullSecret } = await import('./crc-start-macos.js'); |
| 161 | + await storePullSecret('{"auths":{"reg.io":{"auth":"abc"}}}'); |
| 162 | + |
| 163 | + expect(stdinWrite).toHaveBeenCalledOnce(); |
| 164 | + const writtenCommand = stdinWrite.mock.calls[0][0] as string; |
| 165 | + expect(writtenCommand).toContain('add-generic-password'); |
| 166 | + expect(writtenCommand).toContain('com.redhat.crc-extension.pull-secret'); |
| 167 | + expect(writtenCommand).toContain('-U'); |
| 168 | + expect(stdinEnd).toHaveBeenCalledOnce(); |
| 169 | + }); |
| 170 | + |
| 171 | + test('escapes backslashes and quotes in pull secret', async () => { |
| 172 | + const stdinWrite = vi.fn(); |
| 173 | + |
| 174 | + mockExecFile.mockImplementation((cmd: string, args: string[], callback?: (err: Error | null) => void) => { |
| 175 | + if (typeof callback === 'function') { |
| 176 | + setTimeout(() => callback(null), 0); |
| 177 | + } |
| 178 | + return { stdin: { write: stdinWrite, end: vi.fn() } }; |
| 179 | + }); |
| 180 | + |
| 181 | + const { storePullSecret } = await import('./crc-start-macos.js'); |
| 182 | + await storePullSecret('val\\"ue'); |
| 183 | + |
| 184 | + const writtenCommand = stdinWrite.mock.calls[0][0] as string; |
| 185 | + expect(writtenCommand).toContain('val\\\\\\"ue'); |
| 186 | + }); |
| 187 | +}); |
| 188 | + |
| 189 | +describe('start', () => { |
| 190 | + test('throws missing pull secret error when keychain is empty', async () => { |
| 191 | + mockKeychainLoadFailure(); |
| 192 | + const { start } = await import('./crc-start-macos.js'); |
| 193 | + |
| 194 | + await expect(start(mockLogger)).rejects.toThrow('Failed to ask for pull secret'); |
| 195 | + }); |
| 196 | + |
| 197 | + test('reads from keychain, writes temp file, calls crc start, and returns Running', async () => { |
| 198 | + const pullSecret = '{"auths":{"registry.redhat.io":{"auth":"dXNlcjpwYXNz"}}}'; |
| 199 | + mockKeychainLoadSuccess(pullSecret); |
| 200 | + |
| 201 | + const fs = await import('node:fs/promises'); |
| 202 | + vi.spyOn(crcCli, 'execPromise').mockResolvedValue(''); |
| 203 | + vi.spyOn(daemon.commander, 'status').mockResolvedValue({ |
| 204 | + CrcStatus: 'Running', |
| 205 | + } as Status); |
| 206 | + |
| 207 | + const { start } = await import('./crc-start-macos.js'); |
| 208 | + const result = await start(mockLogger); |
| 209 | + |
| 210 | + expect(result.Status).toBe('Running'); |
| 211 | + expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining('pull-secret.json'), pullSecret, { mode: 0o600 }); |
| 212 | + expect(crcCli.execPromise).toHaveBeenCalledWith( |
| 213 | + '/usr/local/bin/crc', |
| 214 | + ['start', '--pull-secret-file', expect.stringContaining('pull-secret.json')], |
| 215 | + expect.objectContaining({ logger: mockLogger }), |
| 216 | + ); |
| 217 | + }); |
| 218 | + |
| 219 | + test('cleans up temp file even when crc start fails', async () => { |
| 220 | + mockKeychainLoadSuccess('{"auths":{}}'); |
| 221 | + |
| 222 | + const fs = await import('node:fs/promises'); |
| 223 | + vi.spyOn(crcCli, 'execPromise').mockRejectedValue(new Error('start failed')); |
| 224 | + vi.spyOn(daemon.commander, 'status').mockRejectedValue(new Error('daemon unreachable')); |
| 225 | + |
| 226 | + const { start } = await import('./crc-start-macos.js'); |
| 227 | + |
| 228 | + await expect(start(mockLogger)).rejects.toThrow('start failed'); |
| 229 | + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('pull-secret.json')); |
| 230 | + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining('crc-ps-')); |
| 231 | + }); |
| 232 | + |
| 233 | + test('returns Running if daemon reports Running despite CLI error', async () => { |
| 234 | + mockKeychainLoadSuccess('{"auths":{}}'); |
| 235 | + |
| 236 | + vi.spyOn(crcCli, 'execPromise').mockRejectedValue(new Error('exit code 1')); |
| 237 | + vi.spyOn(daemon.commander, 'status').mockResolvedValue({ |
| 238 | + CrcStatus: 'Running', |
| 239 | + } as Status); |
| 240 | + |
| 241 | + const { start } = await import('./crc-start-macos.js'); |
| 242 | + const result = await start(mockLogger); |
| 243 | + |
| 244 | + expect(result.Status).toBe('Running'); |
| 245 | + }); |
| 246 | + |
| 247 | + test('returns non-Running status from daemon on success', async () => { |
| 248 | + mockKeychainLoadSuccess('{"auths":{}}'); |
| 249 | + |
| 250 | + vi.spyOn(crcCli, 'execPromise').mockResolvedValue(''); |
| 251 | + vi.spyOn(daemon.commander, 'status').mockResolvedValue({ |
| 252 | + CrcStatus: 'Starting', |
| 253 | + } as Status); |
| 254 | + |
| 255 | + const { start } = await import('./crc-start-macos.js'); |
| 256 | + const result = await start(mockLogger); |
| 257 | + |
| 258 | + expect(result.Status).toBe('Starting'); |
| 259 | + }); |
| 260 | +}); |
0 commit comments