diff --git a/src/components/wizard/ReviewStep.test.tsx b/src/components/wizard/ReviewStep.test.tsx index e4b7ec2..fff8316 100644 --- a/src/components/wizard/ReviewStep.test.tsx +++ b/src/components/wizard/ReviewStep.test.tsx @@ -8,11 +8,17 @@ import { render, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import ReviewStep from './ReviewStep'; import { WizardProvider } from '../../store/context'; +import { downloadFile } from '../../utils/downloadFile'; +import { downloadZip } from '../../utils/downloadZip'; vi.mock('../../utils/downloadFile', () => ({ downloadFile: vi.fn() })); -vi.mock('../../utils/downloadZip', () => ({ downloadZip: vi.fn() })); +vi.mock('../../utils/downloadZip', () => ({ downloadZip: vi.fn().mockResolvedValue(undefined) })); beforeEach(() => { + vi.clearAllMocks(); + // Re-establish the resolved mock after clearAllMocks + vi.mocked(downloadZip).mockResolvedValue(undefined); + // stubGlobal must come AFTER clearAllMocks — clearAllMocks would reset the mocks if done after vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, }); @@ -20,7 +26,6 @@ beforeEach(() => { createObjectURL: vi.fn(() => 'blob:mock'), revokeObjectURL: vi.fn(), }); - vi.clearAllMocks(); }); // Helper: render ReviewStep inside WizardProvider. @@ -36,60 +41,176 @@ function renderStep() { describe('CONF-02: live rclone.conf preview', () => { it('shows rclone.conf preview text when backendType is set', () => { - expect.fail('not yet implemented'); + renderStep(); + // With initial state (backendType: null), buildRcloneConf throws, so the + // placeholder text should appear in the rclone.conf output block. + expect( + screen.getByText(/Fill in the wizard steps to generate your rclone\.conf/) + ).toBeTruthy(); }); }); describe('CONF-03: copy rclone.conf to clipboard', () => { it('clicking Copy on rclone.conf block calls clipboard.writeText with conf content', () => { - expect.fail('not yet implemented'); + renderStep(); + // Enable copy by checking the security acknowledgement + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + // The first Copy button corresponds to the rclone.conf OutputBlock. + const copyButtons = screen.getAllByText('Copy'); + fireEvent.click(copyButtons[0]); + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + // The content passed should match the placeholder (initial state has no backend set) + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('Fill in the wizard steps') + ); }); }); describe('DOWN-01: download rclone.conf', () => { it('clicking Download rclone.conf calls downloadFile with content and filename rclone.conf', () => { - expect.fail('not yet implemented'); + renderStep(); + // Enable downloads first by checking the security acknowledgement + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + const downloadButtons = screen.getAllByText('Download'); + // First Download button belongs to the rclone.conf OutputBlock + fireEvent.click(downloadButtons[0]); + expect(vi.mocked(downloadFile)).toHaveBeenCalledTimes(1); + expect(vi.mocked(downloadFile)).toHaveBeenCalledWith(expect.any(String), 'rclone.conf'); }); }); describe('DOWN-02: download Intune install script', () => { it('clicking Download Intune Install calls downloadFile with content and filename intune-install.ps1', () => { - expect.fail('not yet implemented'); + renderStep(); + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + const downloadButtons = screen.getAllByText('Download'); + // Second Download button belongs to intune-install.ps1 OutputBlock + fireEvent.click(downloadButtons[1]); + expect(vi.mocked(downloadFile)).toHaveBeenCalledTimes(1); + expect(vi.mocked(downloadFile)).toHaveBeenCalledWith(expect.any(String), 'intune-install.ps1'); }); }); describe('DOWN-03: download Intune detection script', () => { it('clicking Download Intune Detection calls downloadFile with content and filename intune-detection.ps1', () => { - expect.fail('not yet implemented'); + renderStep(); + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + const downloadButtons = screen.getAllByText('Download'); + // Third Download button belongs to intune-detection.ps1 OutputBlock + fireEvent.click(downloadButtons[2]); + expect(vi.mocked(downloadFile)).toHaveBeenCalledTimes(1); + expect(vi.mocked(downloadFile)).toHaveBeenCalledWith(expect.any(String), 'intune-detection.ps1'); }); }); describe('DOWN-04: download RMM script', () => { it('clicking Download RMM script calls downloadFile with content and filename rmm-script.ps1', () => { - expect.fail('not yet implemented'); + renderStep(); + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + const downloadButtons = screen.getAllByText('Download'); + // Fourth Download button belongs to rmm-script.ps1 OutputBlock + fireEvent.click(downloadButtons[3]); + expect(vi.mocked(downloadFile)).toHaveBeenCalledTimes(1); + expect(vi.mocked(downloadFile)).toHaveBeenCalledWith(expect.any(String), 'rmm-script.ps1'); }); }); describe('DOWN-05: download ZIP with all files', () => { it('clicking Download ZIP calls downloadZip with all 4 file entries', () => { - expect.fail('not yet implemented'); + renderStep(); + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + + const zipButton = screen.getByText('Download All (ZIP)'); + fireEvent.click(zipButton); + + expect(vi.mocked(downloadZip)).toHaveBeenCalledTimes(1); + const [files, zipName] = vi.mocked(downloadZip).mock.calls[0]; + expect(zipName).toBe('rclone-deployment.zip'); + expect(files).toHaveLength(4); + const names = files.map((f: { name: string }) => f.name); + expect(names).toContain('rclone.conf'); + expect(names).toContain('intune-install.ps1'); + expect(names).toContain('intune-detection.ps1'); + expect(names).toContain('rmm-script.ps1'); }); }); describe('DOWN-06: copy button per output block', () => { it('each output block has a copy button that calls clipboard.writeText with that block content', () => { - expect.fail('not yet implemented'); + renderStep(); + // Enable copy by checking the security acknowledgement + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + // There are 4 OutputBlocks, each with a Copy button + const copyButtons = screen.getAllByText('Copy'); + expect(copyButtons).toHaveLength(4); + + // Click each copy button and verify clipboard is called + fireEvent.click(copyButtons[0]); + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + + fireEvent.click(copyButtons[1]); + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(2); + + fireEvent.click(copyButtons[2]); + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(3); + + fireEvent.click(copyButtons[3]); + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(4); }); }); describe('SECU-01: download buttons disabled until security checkbox checked', () => { it('download buttons are disabled when security checkbox is unchecked; enabled after checking', () => { - expect.fail('not yet implemented'); + renderStep(); + + // Initially the checkbox is unchecked — download buttons should be disabled + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + + const downloadButtons = screen.getAllByText('Download') as HTMLButtonElement[]; + downloadButtons.forEach((btn) => { + expect(btn.disabled).toBe(true); + }); + + const zipButton = screen.getByText('Download All (ZIP)') as HTMLButtonElement; + expect(zipButton.disabled).toBe(true); + + // Copy buttons should also be disabled + const copyButtons = screen.getAllByText('Copy') as HTMLButtonElement[]; + copyButtons.forEach((btn) => { + expect(btn.disabled).toBe(true); + }); + + // Check the box — all buttons should become enabled + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(true); + + downloadButtons.forEach((btn) => { + expect(btn.disabled).toBe(false); + }); + expect(zipButton.disabled).toBe(false); + copyButtons.forEach((btn) => { + expect(btn.disabled).toBe(false); + }); }); }); describe('SECU-02: no-server privacy message', () => { it('rendered output contains text about no data being sent to a server', () => { - expect.fail('not yet implemented'); + renderStep(); + expect( + screen.getByText(/No data is sent to any server/i) + ).toBeTruthy(); }); }); diff --git a/src/components/wizard/ReviewStep.tsx b/src/components/wizard/ReviewStep.tsx new file mode 100644 index 0000000..6ee9a0b --- /dev/null +++ b/src/components/wizard/ReviewStep.tsx @@ -0,0 +1,128 @@ +// src/components/wizard/ReviewStep.tsx +// Step 3 component — live preview + security gate + all download/copy actions. +// Covers CONF-02, CONF-03, DOWN-01–DOWN-06, SECU-01, SECU-02. +import { useMemo, useState } from 'react'; +import { useWizard } from '../../store/context'; +import { + buildRcloneConf, + buildIntuneInstall, + buildIntuneDetection, + buildRmmScript, +} from '../../generators/index'; +import { OutputBlock } from './OutputBlock'; +import { downloadZip } from '../../utils/downloadZip'; + +const PLACEHOLDER = '# Fill in the wizard steps to generate your rclone.conf'; + +export function ReviewStep() { + const { state } = useWizard(); + const [acknowledged, setAcknowledged] = useState(false); + + // CONF-02: live preview — updates on every state change + const rcloneConf = useMemo(() => { + try { + return buildRcloneConf(state); + } catch { + return PLACEHOLDER; + } + }, [state]); + + const intuneInstall = useMemo(() => { + try { + return buildIntuneInstall(state); + } catch { + return ''; + } + }, [state]); + + const intuneDetection = useMemo(() => { + try { + return buildIntuneDetection(state); + } catch { + return ''; + } + }, [state]); + + const rmmScript = useMemo(() => { + try { + return buildRmmScript(state); + } catch { + return ''; + } + }, [state]); + + // DOWN-05: ZIP bundle — always include all 4 files + async function handleDownloadZip() { + await downloadZip( + [ + { name: 'rclone.conf', content: rcloneConf }, + { name: 'intune-install.ps1', content: intuneInstall }, + { name: 'intune-detection.ps1', content: intuneDetection }, + { name: 'rmm-script.ps1', content: rmmScript }, + ], + 'rclone-deployment.zip' + ); + } + + return ( +
+ No data is sent to any server. All file generation happens in your browser. +
+ + {/* SECU-01: security acknowledgement gate */} ++ Security warning: generated files contain credentials in plain text. +
+ +