Files
api/web/__test__/components/Onboarding/internalBoot.test.ts
T
Ajit Mehrotra 9323b14879 feat(onboarding): force reboot after internal boot setup and lock down wizard (#1966)
## Summary

When internal boot configuration is applied during onboarding, the
system must reboot (or shut down) to finalize setup — regardless of
whether the apply succeeded or failed. Previously, users could escape
the reboot path via the X button, back navigation, browser back, a
keyboard shortcut (`Ctrl+Alt+Shift+O`), or a URL bypass
(`?onboarding=bypass`). This left the system in a potentially broken
state if internal boot was partially configured but the user never
rebooted.

## Changes

### Lockdown mechanism
- Added `internalBootApplyAttempted` flag to the onboarding draft store
(persisted to localStorage)
- Flag is set `true` when the Summary step begins applying internal
boot, **before** the API call — this engages the lockdown immediately
- All escape hatches are gated on this flag via a single
`isInternalBootLocked` computed

### Wizard lockdown (OnboardingModal.vue)
- X close button hidden when locked
- Back button hidden on all steps when locked
- `handleExitIntent`, `goToPreviousStep` — early return when locked
- `goToStep` — blocks backward stepper clicks when locked
- `handlePopstate` — calls `window.history.forward()` to neutralize
browser back, with an `isProgrammaticHistoryExit` guard so the modal's
own `closeModal()` history navigation isn't blocked
- Removed keyboard shortcut `Ctrl+Alt+Shift+O` and URL parameter
`?onboarding=bypass` entirely (feature hasn't shipped)

### Forced reboot/shutdown on success or failure
(OnboardingNextStepsStep.vue)
- `showRebootButton` now checks `internalBootSelection !== null` instead
of `internalBootApplySucceeded` — reboot shows regardless of outcome
- Added **shutdown button** alongside reboot as a secondary option
(smaller, text-style) — mirrors the "Skip Setup" / "Get Started" CTA
pattern
- Shutdown calls the same server shutdown mutation and shows the same
confirmation dialog pattern as reboot
- Added failure alert: *"Internal boot timed out but is likely setup on
your server. Please reboot your system to finalize setup."*
- Added BIOS warning when `updateBios` was selected but apply failed —
instructs user to manually update BIOS boot order
- Made `completeOnboarding()` failure non-blocking for the
reboot/shutdown path — wraps in try/catch so users are never stuck

### Standalone lockdown (OnboardingInternalBoot.standalone.vue)
- Same lockdown: X hidden, popstate blocked, dialog dismiss blocked
- "Edit Again" disabled when locked
- "Close" button becomes **two buttons**: "Shutdown" (secondary) and
"Reboot" (primary) when locked
- BIOS warning shown on failure with `updateBios` selected
- Failure message uses same wording as wizard flow

### Log message preservation (SummaryStep + composable)
- `returnedError` and `failed` callbacks in `applyInternalBootSelection`
preserve the original error output in the user-visible log stream
- The generic "timed out / likely setup" message only appears on the
Next Steps popup — the actual error details remain in the technical logs

### i18n
- 7 new keys in `en.json` for failure messaging, BIOS warnings, shutdown
button, and dialog descriptions

## Test plan

- [x] All tests pass (64 test files, 628+ tests)
- [x] Lint passes (ESLint + Prettier)
- [x] Type-check passes (vue-tsc)
- [ ] Manual: start onboarding with internal boot eligible → select
storage boot → apply → verify lockdown engages
- [ ] Manual: simulate API failure → verify reboot AND shutdown buttons
still show + failure messaging
- [ ] Manual: verify browser back, keyboard shortcut, URL bypass all do
nothing during lockdown
- [ ] Manual: click Shutdown → confirm it calls shutdown mutation, not
reboot
- [ ] Manual: test standalone internal boot wizard with same scenarios
- [ ] Manual: verify log console still shows specific error details on
failure

## Files changed (13)

| File | Change |
|------|--------|
| `store/onboardingDraft.ts` | Added `internalBootApplyAttempted` flag +
setter + persistence |
| `store/onboardingModalVisibility.ts` | Removed bypass shortcut, URL
action, and `bypassOnboarding()` |
| `OnboardingModal.vue` | Added lockdown gates on X, back, popstate
(with programmatic exit guard), stepper, exit intent |
| `composables/internalBoot.ts` | Restored original error output in
`returnedError`/`failed` callbacks |
| `steps/OnboardingSummaryStep.vue` | Set `internalBootApplyAttempted`
before apply |
| `steps/OnboardingNextStepsStep.vue` | Forced reboot on failure,
shutdown button, failure alerts, BIOS warning, resilient
`finishOnboarding` |
| `standalone/OnboardingInternalBoot.standalone.vue` | Same lockdown +
shutdown/reboot buttons + failure messaging |
| `locales/en.json` | 7 new i18n keys |
| 5 test files | Updated/added tests for lockdown, shutdown, and failure
behavior |

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added Shutdown alongside Reboot for internal-boot recovery and a
confirmation dialog for power actions.
* Modal/result UI now shows Reboot/Shutdown actions when internal-boot
is locked.

* **Improvements**
* Locked failure state removes “Edit Again” and blocks
close/back/browser-back while internal boot is in progress.
* Internal-boot timeout and BIOS-missed messaging updated with new
localization keys.
  * Onboarding flow tracks an “apply attempted” flag to drive UI state.

* **Removed**
  * Keyboard shortcut bypass for onboarding.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-03-26 20:47:58 -04:00

400 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
applyInternalBootSelection,
submitInternalBootCreation,
submitInternalBootReboot,
summarizeInternalBootBiosLogs,
} from '~/components/Onboarding/composables/internalBoot';
const mutateMock = vi.fn();
vi.mock('@vue/apollo-composable', () => ({
useApolloClient: () => ({
client: {
mutate: mutateMock,
},
}),
}));
describe('internalBoot composable', () => {
beforeEach(() => {
vi.restoreAllMocks();
mutateMock.mockReset();
document.body.innerHTML = '';
globalThis.csrf_token = 'csrf-token-value';
});
it('submits create internal boot pool via onboarding mutation', async () => {
mutateMock.mockResolvedValue({
data: {
onboarding: {
createInternalBootPool: {
ok: true,
code: 0,
output: 'done',
},
},
},
});
const result = await submitInternalBootCreation({
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
});
expect(result).toEqual({
ok: true,
code: 0,
output: 'done',
});
expect(mutateMock).toHaveBeenCalledTimes(1);
const call = mutateMock.mock.calls[0]?.[0];
expect(call).toBeDefined();
if (!call || typeof call !== 'object') {
return;
}
const payload = call as {
variables?: {
input?: {
poolName?: string;
devices?: string[];
bootSizeMiB?: number;
updateBios?: boolean;
reboot?: boolean;
};
};
};
expect(payload.variables?.input).toEqual({
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
reboot: false,
});
});
it('returns fallback error when mutation response is empty', async () => {
mutateMock.mockResolvedValue({
data: {
onboarding: {
createInternalBootPool: null,
},
},
});
const result = await submitInternalBootCreation({
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
});
expect(result.ok).toBe(false);
expect(result.output).toContain('Internal boot setup request failed');
});
it('returns structured failure when mutation throws', async () => {
mutateMock.mockRejectedValue(new Error('network down'));
const result = await submitInternalBootCreation({
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
});
expect(result.ok).toBe(false);
expect(result.output).toContain('Internal boot setup request failed');
expect(result.output).toContain('network down');
});
it('passes reboot flag when requested', async () => {
mutateMock.mockResolvedValue({
data: {
onboarding: {
createInternalBootPool: {
ok: true,
code: 0,
output: 'done',
},
},
},
});
await submitInternalBootCreation(
{
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
},
{ reboot: true }
);
const call = mutateMock.mock.calls[0]?.[0];
expect(call).toBeDefined();
if (!call || typeof call !== 'object') {
return;
}
const payload = call as {
variables?: {
input?: {
reboot?: boolean;
};
};
};
expect(payload.variables?.input?.reboot).toBe(true);
});
it('summarizes BIOS log output into a summary line and deduplicated failures', () => {
const summary = summarizeInternalBootBiosLogs(
[
'Applying BIOS boot entry updates...',
'efibootmgr failed: first',
'BIOS boot entry updates completed with warnings.',
'efibootmgr failed: first',
'efibootmgr failed: second',
].join('\n')
);
expect(summary).toEqual({
summaryLine: 'BIOS boot entry updates completed with warnings.',
failureLines: ['efibootmgr failed: first', 'efibootmgr failed: second'],
});
});
it('builds success and BIOS warning logs for shared internal boot apply', async () => {
mutateMock.mockResolvedValue({
data: {
onboarding: {
createInternalBootPool: {
ok: true,
code: 0,
output: [
'Applying BIOS boot entry updates...',
'efibootmgr failed: no boot entry updated',
'BIOS boot entry updates completed with warnings.',
].join('\n'),
},
},
},
});
const result = await applyInternalBootSelection(
{
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
returnedError: (output) => `Internal boot setup returned an error: ${output}`,
failed: 'Internal boot setup failed',
biosUnverified: 'BIOS boot order update could not be verified.',
}
);
expect(result.applySucceeded).toBe(true);
expect(result.hadWarnings).toBe(true);
expect(result.hadNonOptimisticFailures).toBe(true);
expect(result.logs).toEqual([
{
message: 'Internal boot pool configured.',
type: 'success',
},
{
message: 'BIOS boot entry updates completed with warnings.',
type: 'error',
},
{
message: 'efibootmgr failed: no boot entry updated',
type: 'error',
},
]);
});
it('warns when BIOS update was requested but output contains no recognizable completion', async () => {
mutateMock.mockResolvedValue({
data: {
onboarding: {
createInternalBootPool: {
ok: true,
code: 0,
output: 'Pool created successfully.',
},
},
},
});
const result = await applyInternalBootSelection(
{
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
returnedError: (output) => `Internal boot setup returned an error: ${output}`,
failed: 'Internal boot setup failed',
biosUnverified: 'BIOS boot order update could not be verified.',
}
);
expect(result.applySucceeded).toBe(true);
expect(result.hadWarnings).toBe(true);
expect(result.hadNonOptimisticFailures).toBe(true);
expect(result.logs).toEqual([
{
message: 'Internal boot pool configured.',
type: 'success',
},
{
message: 'BIOS boot order update could not be verified.',
type: 'error',
},
]);
});
it('returns an error log with diagnostics when shared internal boot apply gets ok=false', async () => {
mutateMock.mockResolvedValue({
data: {
onboarding: {
createInternalBootPool: {
ok: false,
code: 500,
output: 'mkbootpool failed',
},
},
},
});
const result = await applyInternalBootSelection(
{
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
returnedError: (output) => `Internal boot setup returned an error: ${output}`,
failed: 'Internal boot setup failed',
biosUnverified: 'BIOS boot order update could not be verified.',
}
);
expect(result.applySucceeded).toBe(false);
expect(result.hadWarnings).toBe(true);
expect(result.hadNonOptimisticFailures).toBe(true);
expect(result.logs).toHaveLength(1);
expect(result.logs[0]).toMatchObject({
message: 'Internal boot setup returned an error: mkbootpool failed',
type: 'error',
});
expect(result.logs[0]?.details).toBeDefined();
});
it('returns error log when underlying mutation rejects with a network error', async () => {
mutateMock.mockRejectedValue(new Error('Network failure'));
const result = await applyInternalBootSelection(
{
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
returnedError: (output) => `Internal boot setup returned an error: ${output}`,
failed: 'Internal boot setup failed',
biosUnverified: 'BIOS boot order update could not be verified.',
}
);
expect(result.applySucceeded).toBe(false);
expect(result.hadWarnings).toBe(true);
expect(result.hadNonOptimisticFailures).toBe(true);
expect(result.logs).toHaveLength(1);
expect(result.logs[0]?.type).toBe('error');
expect(result.logs[0]?.details).toBeDefined();
});
it('returns success without BIOS logs when updateBios is false', async () => {
mutateMock.mockResolvedValue({
data: {
onboarding: {
createInternalBootPool: {
ok: true,
code: 200,
output: 'Pool created successfully',
},
},
},
});
const result = await applyInternalBootSelection(
{
poolName: 'cache',
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
returnedError: (output) => `Internal boot setup returned an error: ${output}`,
failed: 'Internal boot setup failed',
biosUnverified: 'BIOS boot order update could not be verified.',
}
);
expect(result.applySucceeded).toBe(true);
expect(result.hadWarnings).toBe(false);
expect(result.hadNonOptimisticFailures).toBe(false);
expect(result.logs).toHaveLength(1);
expect(result.logs[0]).toMatchObject({
message: 'Internal boot pool configured.',
type: 'success',
});
});
it('submits reboot form with cmd and csrf token', () => {
const submitSpy = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => undefined);
submitInternalBootReboot();
expect(submitSpy).toHaveBeenCalledTimes(1);
const form = document.querySelector('form');
expect(form).toBeTruthy();
if (!form) {
return;
}
expect(form.method.toLowerCase()).toBe('post');
expect(form.target).toBe('_top');
expect(form.getAttribute('action')).toBe('/plugins/dynamix/include/Boot.php');
const cmd = form.querySelector('input[name="cmd"]') as HTMLInputElement | null;
expect(cmd?.value).toBe('reboot');
const csrf = form.querySelector('input[name="csrf_token"]') as HTMLInputElement | null;
expect(csrf?.value).toBe('csrf-token-value');
});
});