From f542c8e0bd9596d9d3abf75b58b97d95fb033215 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Wed, 2 Jul 2025 10:24:38 -0400 Subject: [PATCH] fix: parsing of `ssoEnabled` in state.php (#1455) read `ssoSubIds` in state.php from `api.json` ## Summary by CodeRabbit * **New Features** * Added a new query to check if Single Sign-On (SSO) is enabled. * Updated UI components to dynamically reflect SSO availability via live data. * **Refactor** * Streamlined internal handling of SSO status detection for improved reliability and maintainability. * **Tests** * Enhanced tests for SSO button behavior with mocked live data and added edge case coverage for SSO callback handling. --- api/generated-schema.graphql | 1 + .../resolvers/settings/settings.resolver.ts | 13 +- .../dynamix.my.servers/include/api-config.php | 42 ++++-- .../dynamix.my.servers/include/state.php | 2 +- web/__test__/components/SsoButton.test.ts | 122 +++++++++++++----- web/components/SsoButton.ce.vue | 12 +- web/composables/gql/gql.ts | 6 + web/composables/gql/graphql.ts | 7 + web/pages/index.vue | 2 +- web/pages/webComponents.vue | 2 +- web/store/account.fragment.ts | 6 + 11 files changed, 162 insertions(+), 53 deletions(-) diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index ff22008e0..2b58d548e 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1666,6 +1666,7 @@ type Query { disk(id: PrefixedID!): Disk! rclone: RCloneBackupSettings! settings: Settings! + isSSOEnabled: Boolean! """List all installed plugins with their metadata""" plugins: [Plugin!]! diff --git a/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts b/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts index 251bd2da6..1ab1f5201 100644 --- a/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/settings/settings.resolver.ts @@ -13,6 +13,8 @@ import { GraphQLJSON } from 'graphql-scalars'; import { ENVIRONMENT } from '@app/environment.js'; import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js'; +import { Public } from '@app/unraid-api/auth/public.decorator.js'; +import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js'; import { Settings, UnifiedSettings, @@ -22,7 +24,10 @@ import { ApiSettings } from '@app/unraid-api/graph/resolvers/settings/settings.s @Resolver(() => Settings) export class SettingsResolver { - constructor(private readonly apiSettings: ApiSettings) {} + constructor( + private readonly apiSettings: ApiSettings, + private readonly ssoUserService: SsoUserService + ) {} @Query(() => Settings) async settings() { @@ -45,6 +50,12 @@ export class SettingsResolver { id: 'unified-settings', }; } + + @Query(() => Boolean) + @Public() + public async isSSOEnabled(): Promise { + return this.ssoUserService.getSsoUsers().then((users) => users.length > 0); + } } @Resolver(() => UnifiedSettings) diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/api-config.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/api-config.php index b090d41aa..a436d8cb6 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/api-config.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/api-config.php @@ -6,6 +6,9 @@ */ class ApiConfig { + /** Home of API-specific configuration files */ + public const CONFIG_DIR = '/boot/config/plugins/dynamix.my.servers/configs'; + private static $scriptsDir = "/usr/local/share/dynamix.unraid.net/scripts"; /** @@ -27,9 +30,9 @@ class ApiConfig { $output = []; $exitCode = 0; - + exec($command, $output, $exitCode); - + return implode("\n", $output); } @@ -45,7 +48,7 @@ class ApiConfig } $apiUtilsScript = self::getApiUtilsScript(); - + if (!is_executable($apiUtilsScript)) { return false; } @@ -53,10 +56,10 @@ class ApiConfig $escapedScript = escapeshellarg($apiUtilsScript); $escapedPlugin = escapeshellarg($pluginName); $command = "$escapedScript is_api_plugin_enabled $escapedPlugin 2>/dev/null"; - + $exitCode = 0; self::executeCommand($command, $exitCode); - + return $exitCode === 0; } @@ -76,22 +79,43 @@ class ApiConfig public static function getApiVersion() { $apiUtilsScript = self::getApiUtilsScript(); - + if (!is_executable($apiUtilsScript)) { return 'unknown'; } $escapedScript = escapeshellarg($apiUtilsScript); $command = "$escapedScript get_api_version 2>/dev/null"; - + $exitCode = 0; $output = self::executeCommand($command, $exitCode); - + if ($exitCode !== 0) { return 'unknown'; } - + $version = trim($output); return !empty($version) ? $version : 'unknown'; } } + + +class ApiUserConfig +{ + public const CONFIG_PATH = ApiConfig::CONFIG_DIR . '/api.json'; + + public static function getConfig() + { + try { + return json_decode(file_get_contents(self::CONFIG_PATH), true) ?? []; + } catch (Throwable $e) { + return []; + } + } + + public static function isSSOEnabled() + { + $config = self::getConfig(); + return !empty($config['ssoSubIds'] ?? ''); + } +} diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php index 530240328..65abcc3c4 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php @@ -202,7 +202,7 @@ class ServerState $this->registered = !empty($connectConfig['apikey']) && $this->connectPluginInstalled; $this->registeredTime = $connectConfig['regWizTime'] ?? ''; $this->username = $connectConfig['username'] ?? ''; - $this->ssoEnabled = !empty($connectConfig['ssoSubIds'] ?? ''); + $this->ssoEnabled = ApiUserConfig::isSSOEnabled(); } private function getConnectKnownOrigins() diff --git a/web/__test__/components/SsoButton.test.ts b/web/__test__/components/SsoButton.test.ts index e71208ce9..1f138815e 100644 --- a/web/__test__/components/SsoButton.test.ts +++ b/web/__test__/components/SsoButton.test.ts @@ -2,6 +2,7 @@ * SsoButton Component Test Coverage */ +import { useQuery } from '@vue/apollo-composable'; import { flushPromises, mount } from '@vue/test-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -15,6 +16,11 @@ const BrandButtonStub = { props: ['disabled', 'variant', 'class'], }; +// Mock the GraphQL composable +vi.mock('@vue/apollo-composable', () => ({ + useQuery: vi.fn(), +})); + vi.mock('~/helpers/urls', () => ({ ACCOUNT: 'http://mock-account-url.net', })); @@ -60,10 +66,13 @@ const mockUsernameField = { value: '' }; describe('SsoButton.ce.vue', () => { let querySelectorSpy: MockInstance; + let mockUseQuery: Mock; - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); + mockUseQuery = useQuery as Mock; + (sessionStorage.getItem as Mock).mockReturnValue(null); (sessionStorage.setItem as Mock).mockClear(); mockForm.requestSubmit.mockClear(); @@ -73,6 +82,7 @@ describe('SsoButton.ce.vue', () => { mockLocation.search = ''; mockLocation.href = ''; (fetch as Mock).mockClear(); + mockUseQuery.mockClear(); // Spy on document.querySelector and provide mock implementation querySelectorSpy = vi.spyOn(document, 'querySelector'); @@ -93,9 +103,12 @@ describe('SsoButton.ce.vue', () => { vi.restoreAllMocks(); }); - it('renders the button when ssoenabled prop is true (boolean)', () => { + it('renders the button when SSO is enabled via GraphQL', () => { + mockUseQuery.mockReturnValue({ + result: { value: { isSSOEnabled: true } }, + }); + const wrapper = mount(SsoButton, { - props: { ssoenabled: true }, global: { stubs: { BrandButton: BrandButtonStub }, }, @@ -105,33 +118,12 @@ describe('SsoButton.ce.vue', () => { expect(wrapper.text()).toContain('Log In With Unraid.net'); }); - it('renders the button when ssoenabled prop is true (string)', () => { - const wrapper = mount(SsoButton, { - props: { ssoenabled: 'true' }, - global: { - stubs: { BrandButton: BrandButtonStub }, - }, + it('does not render the button when SSO is disabled via GraphQL', () => { + mockUseQuery.mockReturnValue({ + result: { value: { isSSOEnabled: false } }, }); - expect(wrapper.findComponent(BrandButtonStub).exists()).toBe(true); - expect(wrapper.text()).toContain('or'); - expect(wrapper.text()).toContain('Log In With Unraid.net'); - }); - it('renders the button when ssoEnabled prop is true', () => { const wrapper = mount(SsoButton, { - props: { ssoEnabled: true }, - global: { - stubs: { BrandButton: BrandButtonStub }, - }, - }); - expect(wrapper.findComponent(BrandButtonStub).exists()).toBe(true); - expect(wrapper.text()).toContain('or'); - expect(wrapper.text()).toContain('Log In With Unraid.net'); - }); - - it('does not render the button when ssoenabled prop is false', () => { - const wrapper = mount(SsoButton, { - props: { ssoenabled: false }, global: { stubs: { BrandButton: BrandButtonStub }, }, @@ -140,9 +132,12 @@ describe('SsoButton.ce.vue', () => { expect(wrapper.text()).not.toContain('or'); }); - it('does not render the button when ssoEnabled prop is false', () => { + it('does not render the button when GraphQL result is null/undefined', () => { + mockUseQuery.mockReturnValue({ + result: { value: null }, + }); + const wrapper = mount(SsoButton, { - props: { ssoEnabled: false }, global: { stubs: { BrandButton: BrandButtonStub }, }, @@ -151,7 +146,11 @@ describe('SsoButton.ce.vue', () => { expect(wrapper.text()).not.toContain('or'); }); - it('does not render the button when props are not provided', () => { + it('does not render the button when GraphQL result is undefined', () => { + mockUseQuery.mockReturnValue({ + result: { value: undefined }, + }); + const wrapper = mount(SsoButton, { global: { stubs: { BrandButton: BrandButtonStub }, @@ -162,8 +161,11 @@ describe('SsoButton.ce.vue', () => { }); it('navigates to the external SSO URL on button click', async () => { + mockUseQuery.mockReturnValue({ + result: { value: { isSSOEnabled: true } }, + }); + const wrapper = mount(SsoButton, { - props: { ssoenabled: true }, global: { stubs: { BrandButton: BrandButtonStub }, }, @@ -186,6 +188,10 @@ describe('SsoButton.ce.vue', () => { }); it('handles SSO callback in onMounted hook successfully', async () => { + mockUseQuery.mockReturnValue({ + result: { value: { isSSOEnabled: true } }, + }); + const mockCode = 'mock_auth_code'; const mockState = 'mock_session_state_value'; const mockAccessToken = 'mock_access_token_123'; @@ -199,7 +205,6 @@ describe('SsoButton.ce.vue', () => { // Mount the component so that onMounted hook is called mount(SsoButton, { - props: { ssoenabled: true }, global: { stubs: { BrandButton: BrandButtonStub }, }, @@ -225,6 +230,10 @@ describe('SsoButton.ce.vue', () => { }); it('handles SSO callback error in onMounted hook', async () => { + mockUseQuery.mockReturnValue({ + result: { value: { isSSOEnabled: true } }, + }); + const mockCode = 'mock_auth_code_error'; const mockState = 'mock_session_state_error'; @@ -235,7 +244,6 @@ describe('SsoButton.ce.vue', () => { (fetch as Mock).mockRejectedValueOnce(fetchError); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const wrapper = mount(SsoButton, { - props: { ssoenabled: true }, global: { stubs: { BrandButton: BrandButtonStub }, }, @@ -261,4 +269,52 @@ describe('SsoButton.ce.vue', () => { consoleErrorSpy.mockRestore(); }); + + it('handles SSO callback when state does not match', async () => { + mockUseQuery.mockReturnValue({ + result: { value: { isSSOEnabled: true } }, + }); + + const mockCode = 'mock_auth_code'; + const mockState = 'mock_session_state_value'; + const differentState = 'different_state_value'; + + mockLocation.search = `?code=${mockCode}&state=${mockState}`; + (sessionStorage.getItem as Mock).mockReturnValue(differentState); + + const wrapper = mount(SsoButton, { + global: { + stubs: { BrandButton: BrandButtonStub }, + }, + }); + + await flushPromises(); + + // Should not make any fetch calls when state doesn't match + expect(fetch).not.toHaveBeenCalled(); + expect(mockForm.requestSubmit).not.toHaveBeenCalled(); + expect(wrapper.findComponent(BrandButtonStub).text()).toBe('Log In With Unraid.net'); + }); + + it('handles SSO callback when no code is present', async () => { + mockUseQuery.mockReturnValue({ + result: { value: { isSSOEnabled: true } }, + }); + + mockLocation.search = '?state=some_state'; + (sessionStorage.getItem as Mock).mockReturnValue('some_state'); + + const wrapper = mount(SsoButton, { + global: { + stubs: { BrandButton: BrandButtonStub }, + }, + }); + + await flushPromises(); + + // Should not make any fetch calls when no code is present + expect(fetch).not.toHaveBeenCalled(); + expect(mockForm.requestSubmit).not.toHaveBeenCalled(); + expect(wrapper.findComponent(BrandButtonStub).text()).toBe('Log In With Unraid.net'); + }); }); diff --git a/web/components/SsoButton.ce.vue b/web/components/SsoButton.ce.vue index 6dcf428be..96aa13150 100644 --- a/web/components/SsoButton.ce.vue +++ b/web/components/SsoButton.ce.vue @@ -1,22 +1,20 @@