Skip to content
Open
55 changes: 55 additions & 0 deletions web/__test__/components/CallbackFeedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,61 @@ describe('CallbackFeedback.vue', () => {
expect(wrapper.find('.modal').attributes('data-success')).toBe('false');
});

describe('OS update confirmation', () => {
it('renders the update confirmation when a standalone update lands on a server with a state error', () => {
updateOsStatus.value = 'confirming';
callbackUpdateRelease.value = { name: 'Unraid 7.3.1' };
osVersion.value = '7.3.0';
// Server is in an error state (e.g. EGUID GUID mismatch) but no key was installed
stateDataError.value = true;
keyInstallStatus.value = 'ready';

const wrapper = mountComponent();

expect(wrapper.find('h1').text()).toBe('Update Unraid OS confirmation required');
expect(wrapper.text()).toContain('Current Version: Unraid 7.3.0');
expect(wrapper.text()).toContain('New Version: Unraid 7.3.1');
expect(wrapper.text()).toContain('Confirm and start update');
});

it('confirms the update when the confirm button is clicked', async () => {
updateOsStatus.value = 'confirming';
callbackUpdateRelease.value = { name: 'Unraid 7.3.1' };
stateDataError.value = true;
keyInstallStatus.value = 'ready';

const wrapper = mountComponent();

const confirmButton = wrapper
.findAll('button')
.find((button) => button.text() === 'Confirm and start update');
expect(confirmButton).toBeDefined();
await confirmButton!.trigger('click');

expect(mockInstallOsUpdate).toHaveBeenCalledTimes(1);
expect(mockSetCallbackStatus).toHaveBeenCalledWith('ready');
});

it('suppresses the update confirmation when a key install left the server in an error state', () => {
updateOsStatus.value = 'confirming';
callbackUpdateRelease.value = { name: 'Unraid 7.3.1' };
osVersion.value = '7.3.0';
// Combined key-install + update flow where the install errored
callbackStatus.value = 'success';
keyActionType.value = 'purchase';
keyInstallStatus.value = 'success';
keyType.value = 'Pro';
stateDataError.value = true;
callbackCallsCompleted.value = true;

const wrapper = mountComponent();

expect(wrapper.text()).not.toContain('New Version: Unraid 7.3.1');
expect(wrapper.text()).not.toContain('Confirm and start update');
expect(wrapper.text()).toContain('Post Install License Key Error');
});
});

it('reloads the page when the modal is dismissed after a callback action', async () => {
const mockReload = vi.fn();

Expand Down
52 changes: 52 additions & 0 deletions web/__test__/components/DropdownContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,58 @@ describe('DropdownContent', () => {
updateOsStoreRefs.availableWithRenewal!.value = null;
});

it('shows the OS update button even when the server has a state error', () => {
// e.g. EGUID key/GUID mismatch: updating is still allowed.
serverStoreRefs.stateDataError!.value = { message: 'Registration key mismatch' };

const wrapper = shallowMount(DropdownContent, {
global: {
plugins: [createTestI18n()],
},
});

const items = wrapper
.findAllComponents({ name: 'DropdownItem' })
.map((itemWrapper) => itemWrapper.props('item') as UserProfileLink);

expect(items.some((item) => item?.text === 'Check for Update')).toBe(true);
});

it('hides the OS update button when update entitlement has expired', () => {
serverStoreRefs.regUpdatesExpired!.value = true;

const wrapper = shallowMount(DropdownContent, {
global: {
plugins: [createTestI18n()],
},
});

const items = wrapper
.findAllComponents({ name: 'DropdownItem' })
.map((itemWrapper) => itemWrapper.props('item') as UserProfileLink);

expect(items.some((item) => item?.text === 'Check for Update')).toBe(false);
// the renewal/eligibility link stands in for it
expect(items.some((item) => item?.text === 'OS Update Eligibility Expired')).toBe(true);
});

it('still shows the reboot button when entitlement has expired but a reboot is pending', () => {
serverStoreRefs.regUpdatesExpired!.value = true;
serverStoreRefs.rebootType!.value = 'update';

const wrapper = shallowMount(DropdownContent, {
global: {
plugins: [createTestI18n()],
},
});

const items = wrapper
.findAllComponents({ name: 'DropdownItem' })
.map((itemWrapper) => itemWrapper.props('item') as UserProfileLink);

expect(items.some((item) => item?.text === 'Reboot Required for Update')).toBe(true);
});

it('does not show manage-license helper text when sign-in is the only action', () => {
const wrapper = shallowMount(DropdownContent, {
global: {
Expand Down
23 changes: 16 additions & 7 deletions web/__test__/components/HeaderOsVersion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,6 @@ describe('HeaderOsVersion', () => {
let serverStore: ReturnType<typeof useServerStore>;
let errorsStore: ReturnType<typeof useErrorsStore>;

const findUpdateStatusComponent = () => {
const statusElement = wrapper.find('a.group:not([title*="release notes"]), button.group');
return statusElement.exists() ? statusElement : null;
};

beforeEach(() => {
testingPinia = createTestingPinia({ createSpy: vi.fn });
setActivePinia(testingPinia);
Expand Down Expand Up @@ -124,7 +119,9 @@ describe('HeaderOsVersion', () => {
expect(hasUpdateButton).toBe(false);
});

it('does not render update status when stateDataError is present', async () => {
it('still renders the update-available badge when the server has a state error', async () => {
// A registration/key error (here surfaced as a serverState error) must not
// hide an available OS update — update eligibility is independent of it.
const mockError: CustomApiError = {
message: 'State data fetch failed',
heading: 'Fetch Error',
Expand All @@ -141,7 +138,19 @@ describe('HeaderOsVersion', () => {

await nextTick();

expect(findUpdateStatusComponent()).toBeNull();
expect(wrapper.find('[title="Unraid OS 6.13.0 Update Available"]').exists()).toBe(true);
});

it('renders the pending-reboot badge even when stateDataError is present', async () => {
// EGUID (registration/key mismatch) produces a state error, but a pending
// reboot applies an already-installed update and must still be surfaced.
serverStore.state = 'EGUID';
serverStore.rebootType = 'update';

await nextTick();

expect(wrapper.find('[title="Reboot Required for Update"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Reboot Required for Update');
});

it('removes logo class from logo wrapper on mount', async () => {
Expand Down
126 changes: 126 additions & 0 deletions web/__test__/composables/useOsUpdateStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { createPinia, setActivePinia } from 'pinia';

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

import type { Ref } from 'vue';

import { useOsUpdateStatus } from '~/composables/useOsUpdateStatus';

const { serverRefs, updateOsRefs, updateOsActionsRefs } = vi.hoisted(() => ({
serverRefs: {
regUpdatesExpired: null as Ref<boolean> | null,
stateDataError: null as Ref<{ message: string } | undefined> | null,
rebootType: null as Ref<string> | null,
rebootVersion: null as Ref<string | undefined> | null,
},
updateOsRefs: {
available: null as Ref<string | undefined> | null,
availableWithRenewal: null as Ref<string | undefined> | null,
availableRequiresAuth: null as Ref<boolean> | null,
},
updateOsActionsRefs: {
rebootTypeText: null as Ref<string> | null,
},
}));

vi.mock('~/store/server', async () => {
const { ref } = await import('vue');
const { defineStore } = await import('pinia');
serverRefs.regUpdatesExpired = ref(false);
serverRefs.stateDataError = ref(undefined);
serverRefs.rebootType = ref('');
serverRefs.rebootVersion = ref(undefined);
const useServerStore = defineStore('serverMockForOsUpdateStatus', () => ({
regUpdatesExpired: serverRefs.regUpdatesExpired!,
stateDataError: serverRefs.stateDataError!,
rebootType: serverRefs.rebootType!,
rebootVersion: serverRefs.rebootVersion!,
}));
return { useServerStore };
});

vi.mock('~/store/updateOs', async () => {
const { ref } = await import('vue');
const { defineStore } = await import('pinia');
updateOsRefs.available = ref(undefined);
updateOsRefs.availableWithRenewal = ref(undefined);
updateOsRefs.availableRequiresAuth = ref(false);
const useUpdateOsStore = defineStore('updateOsMockForOsUpdateStatus', () => ({
available: updateOsRefs.available!,
availableWithRenewal: updateOsRefs.availableWithRenewal!,
availableRequiresAuth: updateOsRefs.availableRequiresAuth!,
}));
return { useUpdateOsStore };
});

vi.mock('~/store/updateOsActions', async () => {
const { ref } = await import('vue');
const { defineStore } = await import('pinia');
updateOsActionsRefs.rebootTypeText = ref('');
const useUpdateOsActionsStore = defineStore('updateOsActionsMockForOsUpdateStatus', () => ({
rebootTypeText: updateOsActionsRefs.rebootTypeText!,
}));
return { useUpdateOsActionsStore };
});

describe('useOsUpdateStatus', () => {
beforeEach(() => {
setActivePinia(createPinia());
serverRefs.regUpdatesExpired!.value = false;
serverRefs.stateDataError!.value = undefined;
serverRefs.rebootType!.value = '';
serverRefs.rebootVersion!.value = undefined;
updateOsRefs.available!.value = undefined;
updateOsRefs.availableWithRenewal!.value = undefined;
updateOsRefs.availableRequiresAuth!.value = false;
updateOsActionsRefs.rebootTypeText!.value = '';
});

it('keeps key-state errors separate from update eligibility', () => {
serverRefs.stateDataError!.value = { message: 'Registration key mismatch' };

const status = useOsUpdateStatus();

// A key error is informational and must not imply the entitlement expired.
expect(status.blockedByKeyState.value).toBe(true);
expect(status.entitlementExpired.value).toBe(false);
});

it('flags entitlement expiration independently of key state', () => {
serverRefs.regUpdatesExpired!.value = true;

const status = useOsUpdateStatus();

expect(status.entitlementExpired.value).toBe(true);
expect(status.blockedByKeyState.value).toBe(false);
});

it('treats update and downgrade reboots as a required reboot', () => {
const status = useOsUpdateStatus();

serverRefs.rebootType!.value = 'update';
expect(status.rebootRequired.value).toBe(true);

serverRefs.rebootType!.value = 'downgrade';
expect(status.rebootRequired.value).toBe(true);

serverRefs.rebootType!.value = 'thirdPartyDriversDownloading';
expect(status.rebootRequired.value).toBe(false);

serverRefs.rebootType!.value = '';
expect(status.rebootRequired.value).toBe(false);
});

it('reports an available update from either a direct or renewal release', () => {
const status = useOsUpdateStatus();

expect(status.updateAvailable.value).toBe(false);

updateOsRefs.available!.value = '7.3.1';
expect(status.updateAvailable.value).toBe(true);

updateOsRefs.available!.value = undefined;
updateOsRefs.availableWithRenewal!.value = '7.3.1';
expect(status.updateAvailable.value).toBe(true);
});
});
18 changes: 7 additions & 11 deletions web/src/components/HeaderOsVersion.standalone.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ import { getReleaseNotesUrl, WEBGUI_TOOLS_DOWNGRADE, WEBGUI_TOOLS_UPDATE } from
import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
import { INFO_VERSIONS_QUERY } from '~/components/UserProfile/versions.query';
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
import { useOsUpdateStatus } from '~/composables/useOsUpdateStatus';
import { useServerStore } from '~/store/server';
import { useUpdateOsStore } from '~/store/updateOs';
import { useUpdateOsActionsStore } from '~/store/updateOsActions';

const { t } = useI18n();
const { copyWithNotification } = useClipboardWithToast();
Expand All @@ -44,11 +44,10 @@ onMounted(() => {
// Initialize all stores - they're needed for the UI
const serverStore = useServerStore();
const updateOsStore = useUpdateOsStore();
const updateOsActionsStore = useUpdateOsActionsStore();

const { osVersion, rebootType, stateDataError } = storeToRefs(serverStore);
const { available, availableWithRenewal } = storeToRefs(updateOsStore);
const { rebootTypeText } = storeToRefs(updateOsActionsStore);
const { osVersion } = storeToRefs(serverStore);
const { available, availableWithRenewal, updateAvailable, rebootType, rebootTypeText } =
useOsUpdateStatus();

// Use lazy query and only load when dropdown is opened
const { load: loadVersions, result: versionsResult } = useLazyQuery(INFO_VERSIONS_QUERY);
Expand Down Expand Up @@ -120,11 +119,8 @@ const handleUpdateStatusClick = () => {
};

const updateOsStatus = computed(() => {
if (stateDataError.value) {
// only allowed to update when server is does not have a state error
return null;
}

// A pending reboot applies an already-installed update/downgrade — surface it
// regardless of registration/key state, which does not gate applying it.
if (rebootTypeText.value) {
return {
badge: {
Expand All @@ -136,7 +132,7 @@ const updateOsStatus = computed(() => {
};
}

if (availableWithRenewal.value || available.value) {
if (updateAvailable.value) {
return {
badge: {
color: 'orange',
Expand Down
27 changes: 16 additions & 11 deletions web/src/components/UpdateOs/Status.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Badge, BrandLoading, Button } from '@unraid/ui';
import { WEBGUI_TOOLS_REGISTRATION } from '~/helpers/urls';

import useDateTimeHelper from '~/composables/dateTime';
import { useOsUpdateStatus } from '~/composables/useOsUpdateStatus';
import { useAccountStore } from '~/store/account';
import { useServerStore } from '~/store/server';
import { useUpdateOsStore } from '~/store/updateOs';
Expand All @@ -42,12 +43,18 @@ const updateOsActionsStore = useUpdateOsActionsStore();

const LoadingIcon = () => h(BrandLoading, { variant: 'white', style: 'width: 16px; height: 16px;' });

const { dateTimeFormat, osVersion, rebootType, rebootVersion, regExp, regUpdatesExpired } =
storeToRefs(serverStore);
const { available, availableWithRenewal } = storeToRefs(updateOsStore);
const { ineligibleText, rebootTypeText, status } = storeToRefs(updateOsActionsStore);

const updateAvailable = computed(() => available.value || availableWithRenewal.value);
const { dateTimeFormat, osVersion, regExp } = storeToRefs(serverStore);
const { ineligibleText, status } = storeToRefs(updateOsActionsStore);
const {
available,
availableWithRenewal,
updateAvailable,
entitlementExpired,
rebootType,
rebootTypeText,
rebootVersion,
rebootRequired,
} = useOsUpdateStatus();

const { outputDateTimeReadableDiff: readableDiffRegExp, outputDateTimeFormatted: formattedRegExp } =
useDateTimeHelper(dateTimeFormat.value, t, true, regExp.value);
Expand All @@ -57,22 +64,20 @@ const regExpOutput = computed(() => {
return undefined;
}
return {
text: regUpdatesExpired.value
text: entitlementExpired.value
? `${t('registration.updateExpirationAction.eligibleForUpdatesReleasedOnOr', [formattedRegExp.value])} ${t('registration.updateExpirationAction.extendYourLicenseToAccessThe')}`
: t('registration.updateExpirationAction.eligibleForFreeFeatureUpdatesUntil', [
formattedRegExp.value,
]),
title: regUpdatesExpired.value
title: entitlementExpired.value
? t('registration.updateExpirationAction.ineligibleAsOf', [readableDiffRegExp.value])
: t('registration.updateExpirationAction.eligibleForFreeFeatureUpdatesFor', [
readableDiffRegExp.value,
]),
};
});

const showRebootButton = computed(
() => rebootType.value === 'downgrade' || rebootType.value === 'update'
);
const showRebootButton = rebootRequired;

const checkButton = computed(() => {
if (showRebootButton.value || props.showExternalDowngrade) {
Expand Down
Loading
Loading