Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions web/__test__/components/UpdateOsDowngrade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { mount } from '@vue/test-utils';

import { beforeEach, describe, expect, it, vi } from 'vitest';

import UpdateOsDowngrade from '~/components/UpdateOs/Downgrade.vue';
import { createTestI18n } from '../utils/i18n';

const mockViewReleaseNotes = vi.fn();
const mockServerStore = {
bootedFromFlashWithInternalBootSetup: false,
dateTimeFormat: { date: '%A, %m/%d/%Y', time: '%R' },
osVersion: '7.3.0',
};

vi.mock('pinia', async (importOriginal) => {
const actual = (await importOriginal()) as typeof import('pinia');
return {
...actual,
storeToRefs: (store: Record<string, unknown>) =>
Object.fromEntries(
Object.keys(store).map((key) => [
key,
{
get value() {
return store[key];
},
set value(value: unknown) {
store[key] = value;
},
},
])
),
};
});

vi.mock('@unraid/ui', () => ({
BrandButton: {
template:
'<button :data-button-name="name" :data-button-text="text" @click="$emit(\'click\')">{{ text }}</button>',
emits: ['click'],
props: ['name', 'text'],
},
CardWrapper: {
template: '<div><slot /></div>',
},
}));

vi.mock('~/helpers/urls', async (importOriginal) => {
const actual = await importOriginal<typeof import('~/helpers/urls')>();
return {
...actual,
FORUMS_BUG_REPORT: new URL('https://example.com/bug-report'),
};
});

vi.mock('~/store/server', () => ({
useServerStore: () => mockServerStore,
}));

vi.mock('~/store/updateOsActions', () => ({
useUpdateOsActionsStore: () => ({
viewReleaseNotes: mockViewReleaseNotes,
}),
}));

describe('UpdateOs/Downgrade', () => {
beforeEach(() => {
vi.clearAllMocks();
mockServerStore.bootedFromFlashWithInternalBootSetup = false;
mockServerStore.dateTimeFormat = { date: '%A, %m/%d/%Y', time: '%R' };
mockServerStore.osVersion = '7.3.0';
(window as Window & { confirmDowngrade?: () => void }).confirmDowngrade = vi.fn();
});

const mountComponent = (version = '7.2.5') =>
mount(UpdateOsDowngrade, {
props: {
releaseDate: '2024-01-01',
version,
},
global: {
plugins: [createTestI18n()],
},
});

it('starts downgrade immediately when warning conditions are not met', async () => {
mockServerStore.osVersion = '7.2.9';
mockServerStore.bootedFromFlashWithInternalBootSetup = true;

const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
const wrapper = mountComponent('7.2.8');

await wrapper.find('[data-button-name="downgrade"]').trigger('click');

expect(confirmSpy).not.toHaveBeenCalled();
expect(window.confirmDowngrade).toHaveBeenCalledTimes(1);
expect(wrapper.text()).not.toContain('Internal boot downgrade risk');
});

it('shows warning and blocks downgrade when user cancels confirmation', async () => {
mockServerStore.osVersion = '7.3.0-beta.2';
mockServerStore.bootedFromFlashWithInternalBootSetup = true;

const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
const wrapper = mountComponent('7.2.3');

await wrapper.find('[data-button-name="downgrade"]').trigger('click');

expect(confirmSpy).toHaveBeenCalledTimes(1);
expect(window.confirmDowngrade).not.toHaveBeenCalled();
expect(wrapper.text()).toContain('Internal boot downgrade risk');
});

it('shows warning and allows downgrade after explicit confirmation', async () => {
mockServerStore.osVersion = '7.3.1';
mockServerStore.bootedFromFlashWithInternalBootSetup = true;

const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
const wrapper = mountComponent('7.1.0');

await wrapper.find('[data-button-name="downgrade"]').trigger('click');

expect(confirmSpy).toHaveBeenCalledTimes(1);
expect(window.confirmDowngrade).toHaveBeenCalledTimes(1);
expect(wrapper.text()).toContain('Internal boot downgrade risk');
});
});
58 changes: 53 additions & 5 deletions web/src/components/UpdateOs/Downgrade.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';

Expand All @@ -13,6 +13,8 @@ import {
import { BrandButton, CardWrapper } from '@unraid/ui';
import { FORUMS_BUG_REPORT } from '~/helpers/urls';
import dayjs from 'dayjs';
import coerce from 'semver/functions/coerce';
import lt from 'semver/functions/lt';

import type { UserProfileLink } from '~/types/userProfile';

Expand All @@ -29,14 +31,51 @@ const { t } = useI18n();
const serverStore = useServerStore();
const updateOsActionsStore = useUpdateOsActionsStore();

const { dateTimeFormat } = storeToRefs(serverStore);
const { bootedFromFlashWithInternalBootSetup, dateTimeFormat, osVersion } = storeToRefs(serverStore);
const { outputDateTimeFormatted: formattedReleaseDate } = useDateTimeHelper(
dateTimeFormat.value,
t,
true,
dayjs(props.releaseDate, 'YYYY-MM-DD').valueOf()
);

const INTERNAL_BOOT_SUPPORT_VERSION = '7.3.0';

const isVersionIn73Series = (version: string | null | undefined) => {
const normalizedVersion = coerce(version);
if (!normalizedVersion) {
return false;
}
return normalizedVersion.major === 7 && normalizedVersion.minor === 3;
};

const isBeforeInternalBootSupportVersion = (version: string | null | undefined) => {
const normalizedVersion = coerce(version);
if (!normalizedVersion) {
return false;
}
return lt(normalizedVersion, INTERNAL_BOOT_SUPPORT_VERSION);
};

const shouldWarnAboutInternalBootDowngrade = computed(
() =>
bootedFromFlashWithInternalBootSetup.value &&
isVersionIn73Series(osVersion.value) &&
isBeforeInternalBootSupportVersion(props.version)
Comment on lines +62 to +64

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Warn for already-internal-booted servers too

This condition only fires when bootedFromFlashWithInternalBootSetup is true, but the onboarding flow treats bootedFromFlashWithInternalBootSetup === false as the state where the machine is already booting internally (web/src/components/Onboarding/OnboardingModal.vue:84-92, plus the “already booting internally” test in web/__test__/components/Onboarding/OnboardingModal.test.ts:369-380). In that common post-migration state, downgrading from 7.3.x to a pre-7.3 release is still the same boot-breaking case, yet this PR suppresses both the red warning and the extra confirmation.

Useful? React with 👍 / 👎.

Comment on lines +62 to +64

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid using the TPM fallback as proof of internal boot

bootedFromFlashWithInternalBootSetup is not always a reported server flag: when the backend value is absent, the store falls back to hasDistinctTpmGuid (web/src/store/server.ts:139-140). The store tests explicitly cover the case where a server has a distinct TPM GUID even though internal boot is not configured (web/__test__/store/server.test.ts:412-423). Because this new check treats that derived value as authoritative, those downgrade pages will show a bogus “server may become unbootable” warning and require an unnecessary confirmation.

Useful? React with 👍 / 👎.

);

const startDowngrade = () => {
if (shouldWarnAboutInternalBootDowngrade.value) {
const confirmed = window.confirm(
t('updateOs.downgrade.internalBootConfirmFrom73xToOlder', [osVersion.value, props.version])
);
if (!confirmed) {
return;
}
}
window.confirmDowngrade?.();
};

const diagnosticsButton = ref<UserProfileLink | undefined>({
click: () => {
window.downloadDiagnostics?.();
Expand All @@ -47,9 +86,7 @@ const diagnosticsButton = ref<UserProfileLink | undefined>({
});

const downgradeButton = ref<UserProfileLink>({
click: () => {
window.confirmDowngrade?.();
},
click: startDowngrade,
name: 'downgrade',
text: t('updateOs.downgrade.beginDowngradeTo', [props.version]),
});
Expand Down Expand Up @@ -82,6 +119,17 @@ const downgradeButton = ref<UserProfileLink>({
{{ t('updateOs.downgrade.downloadTheDiagnosticsZipThenPlease') }}
</p>
</div>
<div
v-if="shouldWarnAboutInternalBootDowngrade"
class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm leading-relaxed text-red-900"
>
<p class="font-semibold">
{{ t('updateOs.downgrade.internalBootWarningTitle') }}
</p>
<p>
{{ t('updateOs.downgrade.internalBootWarningFrom73xToOlder', [osVersion, version]) }}
</p>
</div>
</div>

<div v-if="downgradeButton" class="flex shrink-0 grow flex-col items-stretch gap-4">
Expand Down
3 changes: 3 additions & 0 deletions web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,9 @@
"updateOs.downgrade.downloadDiagnostics": "Download Diagnostics",
"updateOs.downgrade.downloadTheDiagnosticsZipThenPlease": "Download the Diagnostics zip then please open a bug report on our forums with a description of the issue along with your diagnostics.",
"updateOs.downgrade.inTheRareEventYouNeed": "In the rare event you need to downgrade we ask that you please provide us with Diagnostics so we can investigate your issue.",
"updateOs.downgrade.internalBootConfirmFrom73xToOlder": "WARNING: This server is currently on {0} with internal boot configured. Downgrading to {1} may not support internal boot and could leave the server unbootable until boot media is restored. Do you want to continue anyway?",
"updateOs.downgrade.internalBootWarningFrom73xToOlder": "This server is on {0} with internal boot configured. Downgrading to {1} targets a release before internal boot support.",
"updateOs.downgrade.internalBootWarningTitle": "Internal boot downgrade risk",
"updateOs.downgrade.openABugReport": "Open a bug report",
"updateOs.downgrade.originalReleaseDate": "Original release date {0}",
"updateOs.downgrade.releaseNotes": "{0} Release Notes",
Expand Down
Loading