Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import { OidcProvider } from '@app/unraid-api/graph/resolvers/sso/models/oidc-pr
import { OidcSessionService } from '@app/unraid-api/graph/resolvers/sso/session/oidc-session.service.js';
import { OidcStateService } from '@app/unraid-api/graph/resolvers/sso/session/oidc-state.service.js';

describe('OidcService Integration Tests - Enhanced Logging', () => {
// These integration tests make real outbound network calls (discovery, SSL/DNS
// probes), which can exceed the 5s default under slow CI networking. Give the
// whole suite generous headroom.
describe('OidcService Integration Tests - Enhanced Logging', { timeout: 20000 }, () => {
let service: OidcService;
let configPersistence: OidcConfigPersistence;
let loggerSpy: any;
Expand Down
75 changes: 75 additions & 0 deletions web/__test__/components/CallbackFeedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,81 @@ 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('suppresses the update confirmation while key-install reconciliation is still pending', () => {
updateOsStatus.value = 'confirming';
callbackUpdateRelease.value = { name: 'Unraid 7.3.1' };
osVersion.value = '7.3.0';
// Combined key-install + update flow, but the delayed refreshServerState
// reconciliation has not finished yet, so the pre-refresh error persists.
callbackStatus.value = 'success';
keyActionType.value = 'purchase';
keyInstallStatus.value = 'success';
keyType.value = 'Pro';
stateDataError.value = true;
refreshServerStateStatus.value = 'refreshing';
callbackCallsCompleted.value = false;

const wrapper = mountComponent();

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

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

Expand Down
53 changes: 53 additions & 0 deletions web/__test__/components/DropdownContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ describe('DropdownContent', () => {
};

beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createPinia());

serverStoreRefs.keyActions!.value = [];
Expand All @@ -129,6 +130,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
33 changes: 18 additions & 15 deletions web/__test__/components/HeaderOsVersion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { TestingPinia } from '@pinia/testing';
import type { VueWrapper } from '@vue/test-utils';
import type { Error as CustomApiError } from '~/store/errors';
import type { ServerUpdateOsResponse } from '~/types/server';

import HeaderOsVersion from '~/components/HeaderOsVersion.standalone.vue';
Expand Down Expand Up @@ -77,11 +76,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,14 +118,10 @@ describe('HeaderOsVersion', () => {
expect(hasUpdateButton).toBe(false);
});

it('does not render update status when stateDataError is present', async () => {
const mockError: CustomApiError = {
message: 'State data fetch failed',
heading: 'Fetch Error',
level: 'error',
type: 'serverState',
};
errorsStore.errors = [mockError];
it('still renders the update-available badge when the server has a state error', async () => {
// A registration/key error (EGUID GUID mismatch → stateDataError) must not
// hide an available OS update — update eligibility is independent of it.
serverStore.state = 'EGUID';
serverStore.updateOsResponse = {
version: '6.13.0',
isNewer: true,
Expand All @@ -141,7 +131,20 @@ describe('HeaderOsVersion', () => {

await nextTick();

expect(findUpdateStatusComponent()).toBeNull();
expect(serverStore.stateDataError).toBeDefined();
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);
});
});
Loading
Loading