mirror of
https://github.com/unraid/api.git
synced 2026-05-02 05:01:34 -05:00
9323b14879
## 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 -->
400 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|