diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 5566ce92ce..812a29f73f 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,7 +1,7 @@ { - "version": "4.12.0", - "extraOrigins": [], - "sandbox": true, - "ssoSubIds": [], - "plugins": ["unraid-api-plugin-connect"] -} \ No newline at end of file + "version": "4.12.0", + "extraOrigins": [], + "sandbox": true, + "ssoSubIds": [], + "plugins": ["unraid-api-plugin-connect"] +} diff --git a/api/package.json b/api/package.json index 22c19bff73..49110e6c9c 100644 --- a/api/package.json +++ b/api/package.json @@ -138,6 +138,7 @@ "semver": "7.7.2", "strftime": "0.10.3", "systeminformation": "5.27.7", + "undici": "^7.13.0", "uuid": "11.1.0", "ws": "8.18.3", "zen-observable-ts": "1.1.0", diff --git a/api/src/unraid-api/cli/cli.module.spec.ts b/api/src/unraid-api/cli/cli.module.spec.ts new file mode 100644 index 0000000000..97fd0ac8e1 --- /dev/null +++ b/api/src/unraid-api/cli/cli.module.spec.ts @@ -0,0 +1,74 @@ +import { ConfigModule } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js'; +import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js'; +import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js'; +import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js'; + +describe('CliServicesModule', () => { + let module: TestingModule; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [CliServicesModule], + }).compile(); + }); + + afterEach(async () => { + await module?.close(); + }); + + it('should compile the module', () => { + expect(module).toBeDefined(); + }); + + it('should provide CliInternalClientService', () => { + const service = module.get(CliInternalClientService); + expect(service).toBeDefined(); + expect(service).toBeInstanceOf(CliInternalClientService); + }); + + it('should provide AdminKeyService', () => { + const service = module.get(AdminKeyService); + expect(service).toBeDefined(); + expect(service).toBeInstanceOf(AdminKeyService); + }); + + it('should provide InternalGraphQLClientFactory via token', () => { + const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN); + expect(factory).toBeDefined(); + expect(factory).toBeInstanceOf(InternalGraphQLClientFactory); + }); + + describe('CliInternalClientService dependencies', () => { + it('should have all required dependencies available', () => { + // This test ensures that CliInternalClientService can be instantiated + // with all its dependencies properly resolved + const service = module.get(CliInternalClientService); + expect(service).toBeDefined(); + + // Verify the service has its dependencies injected + // The service should be able to create a client without errors + expect(service.getClient).toBeDefined(); + expect(service.clearClient).toBeDefined(); + }); + + it('should resolve InternalGraphQLClientFactory dependency via token', () => { + // Explicitly test that the factory is available in the module context via token + const factory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN); + expect(factory).toBeDefined(); + expect(factory.createClient).toBeDefined(); + }); + + it('should resolve AdminKeyService dependency', () => { + // Explicitly test that AdminKeyService is available in the module context + const adminKeyService = module.get(AdminKeyService); + expect(adminKeyService).toBeDefined(); + expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined(); + }); + }); +}); diff --git a/api/src/unraid-api/cli/internal-client.service.spec.ts b/api/src/unraid-api/cli/internal-client.service.spec.ts new file mode 100644 index 0000000000..c17468d23e --- /dev/null +++ b/api/src/unraid-api/cli/internal-client.service.spec.ts @@ -0,0 +1,203 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +import type { InternalGraphQLClientFactory } from '@unraid/shared'; +import { ApolloClient } from '@apollo/client/core/index.js'; +import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js'; +import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js'; + +describe('CliInternalClientService', () => { + let service: CliInternalClientService; + let clientFactory: InternalGraphQLClientFactory; + let adminKeyService: AdminKeyService; + let module: TestingModule; + + const mockApolloClient = { + query: vi.fn(), + mutate: vi.fn(), + stop: vi.fn(), + }; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot()], + providers: [ + CliInternalClientService, + { + provide: INTERNAL_CLIENT_SERVICE_TOKEN, + useValue: { + createClient: vi.fn().mockResolvedValue(mockApolloClient), + }, + }, + { + provide: AdminKeyService, + useValue: { + getOrCreateLocalAdminKey: vi.fn().mockResolvedValue('test-admin-key'), + }, + }, + ], + }).compile(); + + service = module.get(CliInternalClientService); + clientFactory = module.get(INTERNAL_CLIENT_SERVICE_TOKEN); + adminKeyService = module.get(AdminKeyService); + }); + + afterEach(async () => { + await module?.close(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('dependency injection', () => { + it('should have InternalGraphQLClientFactory injected', () => { + expect(clientFactory).toBeDefined(); + expect(clientFactory.createClient).toBeDefined(); + }); + + it('should have AdminKeyService injected', () => { + expect(adminKeyService).toBeDefined(); + expect(adminKeyService.getOrCreateLocalAdminKey).toBeDefined(); + }); + }); + + describe('getClient', () => { + it('should create a client with getApiKey function', async () => { + const client = await service.getClient(); + + // The API key is now fetched lazily, not immediately + expect(clientFactory.createClient).toHaveBeenCalledWith({ + getApiKey: expect.any(Function), + enableSubscriptions: false, + }); + + // Verify the getApiKey function works correctly when called + const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0]; + const apiKey = await callArgs.getApiKey(); + expect(apiKey).toBe('test-admin-key'); + expect(adminKeyService.getOrCreateLocalAdminKey).toHaveBeenCalled(); + + expect(client).toBe(mockApolloClient); + }); + + it('should return cached client on subsequent calls', async () => { + const client1 = await service.getClient(); + const client2 = await service.getClient(); + + expect(client1).toBe(client2); + expect(clientFactory.createClient).toHaveBeenCalledTimes(1); + }); + + it('should handle errors when getting admin key', async () => { + const error = new Error('Failed to get admin key'); + vi.mocked(adminKeyService.getOrCreateLocalAdminKey).mockRejectedValueOnce(error); + + // The client creation will succeed, but the API key error happens later + const client = await service.getClient(); + expect(client).toBe(mockApolloClient); + + // Now test that the getApiKey function throws the expected error + const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0]; + await expect(callArgs.getApiKey()).rejects.toThrow(); + }); + }); + + describe('clearClient', () => { + it('should stop and clear the client', async () => { + // First create a client + await service.getClient(); + + // Clear the client + service.clearClient(); + + expect(mockApolloClient.stop).toHaveBeenCalled(); + }); + + it('should handle clearing when no client exists', () => { + // Should not throw when clearing a non-existent client + expect(() => service.clearClient()).not.toThrow(); + }); + + it('should create a new client after clearing', async () => { + // Create initial client + await service.getClient(); + + // Clear it + service.clearClient(); + + // Create new client + await service.getClient(); + + // Should have created client twice + expect(clientFactory.createClient).toHaveBeenCalledTimes(2); + }); + }); + + describe('race condition protection', () => { + it('should prevent stale client resurrection when clearClient() is called during creation', async () => { + let resolveClientCreation!: (client: any) => void; + + // Mock createClient to return a controllable promise + const clientCreationPromise = new Promise((resolve) => { + resolveClientCreation = resolve; + }); + vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise); + + // Start client creation (but don't await yet) + const getClientPromise = service.getClient(); + + // Clear the client while creation is in progress + service.clearClient(); + + // Now complete the client creation + resolveClientCreation(mockApolloClient); + + // Wait for getClient to complete + const client = await getClientPromise; + + // The client should be returned from getClient + expect(client).toBe(mockApolloClient); + + // But subsequent getClient calls should create a new client + // because the race condition protection prevented assignment + await service.getClient(); + + // Should have created a second client, proving the first wasn't assigned + expect(clientFactory.createClient).toHaveBeenCalledTimes(2); + }); + + it('should handle concurrent getClient calls during race condition', async () => { + let resolveClientCreation!: (client: any) => void; + + // Mock createClient to return a controllable promise + const clientCreationPromise = new Promise((resolve) => { + resolveClientCreation = resolve; + }); + vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise); + + // Start multiple concurrent client creation calls + const getClientPromise1 = service.getClient(); + const getClientPromise2 = service.getClient(); // Should wait for first one + + // Clear the client while creation is in progress + service.clearClient(); + + // Complete the client creation + resolveClientCreation(mockApolloClient); + + // Both calls should resolve with the same client + const [client1, client2] = await Promise.all([getClientPromise1, getClientPromise2]); + expect(client1).toBe(mockApolloClient); + expect(client2).toBe(mockApolloClient); + + // But the client should not be cached due to race condition protection + await service.getClient(); + expect(clientFactory.createClient).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/api/src/unraid-api/cli/internal-client.service.ts b/api/src/unraid-api/cli/internal-client.service.ts index 67d3588582..b862d64e74 100644 --- a/api/src/unraid-api/cli/internal-client.service.ts +++ b/api/src/unraid-api/cli/internal-client.service.ts @@ -1,9 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Inject, Injectable, Logger } from '@nestjs/common'; -import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client/core/index.js'; -import { onError } from '@apollo/client/link/error/index.js'; -import { HttpLink } from '@apollo/client/link/http/index.js'; +import type { InternalGraphQLClientFactory } from '@unraid/shared'; +import { ApolloClient, NormalizedCacheObject } from '@apollo/client/core/index.js'; +import { INTERNAL_CLIENT_SERVICE_TOKEN } from '@unraid/shared'; import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js'; @@ -11,51 +10,20 @@ import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js'; * Internal GraphQL client for CLI commands. * * This service creates an Apollo client that queries the local API server - * through IPC, providing access to the same data that external clients would get - * but without needing to parse config files directly. + * with admin privileges for CLI operations. */ @Injectable() export class CliInternalClientService { private readonly logger = new Logger(CliInternalClientService.name); private client: ApolloClient | null = null; + private creatingClient: Promise> | null = null; constructor( - private readonly configService: ConfigService, + @Inject(INTERNAL_CLIENT_SERVICE_TOKEN) + private readonly clientFactory: InternalGraphQLClientFactory, private readonly adminKeyService: AdminKeyService ) {} - private PROD_NGINX_PORT = 80; - - private getNginxPort() { - return Number(this.configService.get('store.emhttp.nginx.httpPort', this.PROD_NGINX_PORT)); - } - - /** - * Get the port override from the environment variable PORT. e.g. during development. - * If the port is a socket port, return undefined. - */ - private getNonSocketPortOverride() { - const port = this.configService.get('PORT'); - if (!port || port.toString().includes('.sock')) { - return undefined; - } - return Number(port); - } - - /** - * Get the API address for HTTP requests. - */ - private getApiAddress(port = this.getNginxPort()) { - const portOverride = this.getNonSocketPortOverride(); - if (portOverride) { - return `http://127.0.0.1:${portOverride}/graphql`; - } - if (port !== this.PROD_NGINX_PORT) { - return `http://127.0.0.1:${port}/graphql`; - } - return `http://127.0.0.1/graphql`; - } - /** * Get the admin API key using the AdminKeyService. * This ensures the key exists and is available for CLI operations. @@ -71,49 +39,59 @@ export class CliInternalClientService { } } - private async createApiClient(): Promise> { - const httpUri = this.getApiAddress(); - const apiKey = await this.getLocalApiKey(); - - this.logger.debug('Internal GraphQL URL: %s', httpUri); - - const httpLink = new HttpLink({ - uri: httpUri, - fetch, - headers: { - Origin: '/var/run/unraid-cli.sock', - 'x-api-key': apiKey, - 'Content-Type': 'application/json', - }, - }); - - const errorLink = onError(({ networkError }) => { - if (networkError) { - this.logger.warn('[GRAPHQL-CLIENT] NETWORK ERROR ENCOUNTERED %o', networkError); - } - }); - - return new ApolloClient({ - defaultOptions: { - query: { - fetchPolicy: 'no-cache', - }, - }, - cache: new InMemoryCache(), - link: errorLink.concat(httpLink), - }); - } - + /** + * Get the default CLI client with admin API key. + * This is for CLI commands that need admin access. + */ public async getClient(): Promise> { + // If client already exists, return it if (this.client) { return this.client; } - this.client = await this.createApiClient(); - return this.client; + + // If another call is already creating the client, wait for it + if (this.creatingClient) { + return await this.creatingClient; + } + + // Start creating the client with race condition protection + let creationPromise!: Promise>; + // eslint-disable-next-line prefer-const + creationPromise = (async () => { + try { + const client = await this.clientFactory.createClient({ + getApiKey: () => this.getLocalApiKey(), + enableSubscriptions: false, // CLI doesn't need subscriptions + }); + + // awaiting *before* checking this.creatingClient is important! + // by yielding to the event loop, it ensures + // `this.creatingClient = creationPromise;` is executed before the next check. + + // This prevents race conditions where the client is assigned to the wrong instance. + // Only assign client if this creation is still current + if (this.creatingClient === creationPromise) { + this.client = client; + this.logger.debug('Created CLI internal GraphQL client with admin privileges'); + } + + return client; + } finally { + // Only clear if this creation is still current + if (this.creatingClient === creationPromise) { + this.creatingClient = null; + } + } + })(); + + this.creatingClient = creationPromise; + return await creationPromise; } public clearClient() { + // Stop the Apollo client to terminate any active processes this.client?.stop(); this.client = null; + this.creatingClient = null; } } diff --git a/api/src/unraid-api/plugin/global-deps.module.ts b/api/src/unraid-api/plugin/global-deps.module.ts index 31bf427277..851b31fdfc 100644 --- a/api/src/unraid-api/plugin/global-deps.module.ts +++ b/api/src/unraid-api/plugin/global-deps.module.ts @@ -1,9 +1,11 @@ import { Global, Module } from '@nestjs/common'; +import { SocketConfigService } from '@unraid/shared'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { GRAPHQL_PUBSUB_TOKEN } from '@unraid/shared/pubsub/graphql.pubsub.js'; import { API_KEY_SERVICE_TOKEN, + INTERNAL_CLIENT_SERVICE_TOKEN, LIFECYCLE_SERVICE_TOKEN, NGINX_SERVICE_TOKEN, UPNP_CLIENT_TOKEN, @@ -15,6 +17,7 @@ import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; import { ApiKeyModule } from '@app/unraid-api/graph/resolvers/api-key/api-key.module.js'; import { NginxModule } from '@app/unraid-api/nginx/nginx.module.js'; import { NginxService } from '@app/unraid-api/nginx/nginx.service.js'; +import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js'; import { upnpClient } from '@app/upnp/helpers.js'; // This is the actual module that provides the global dependencies @@ -22,6 +25,11 @@ import { upnpClient } from '@app/upnp/helpers.js'; @Module({ imports: [ApiKeyModule, NginxModule], providers: [ + SocketConfigService, + { + provide: INTERNAL_CLIENT_SERVICE_TOKEN, + useClass: InternalGraphQLClientFactory, + }, { provide: UPNP_CLIENT_TOKEN, useValue: upnpClient, @@ -46,10 +54,12 @@ import { upnpClient } from '@app/upnp/helpers.js'; }, ], exports: [ + SocketConfigService, UPNP_CLIENT_TOKEN, GRAPHQL_PUBSUB_TOKEN, API_KEY_SERVICE_TOKEN, NGINX_SERVICE_TOKEN, + INTERNAL_CLIENT_SERVICE_TOKEN, PrefixedID, LIFECYCLE_SERVICE_TOKEN, LifecycleService, diff --git a/api/src/unraid-api/shared/internal-graphql-client.factory.spec.ts b/api/src/unraid-api/shared/internal-graphql-client.factory.spec.ts new file mode 100644 index 0000000000..bec4e28f58 --- /dev/null +++ b/api/src/unraid-api/shared/internal-graphql-client.factory.spec.ts @@ -0,0 +1,228 @@ +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { ApolloClient } from '@apollo/client/core/index.js'; +import { SocketConfigService } from '@unraid/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InternalGraphQLClientFactory } from '@app/unraid-api/shared/internal-graphql-client.factory.js'; + +// Mock the graphql-ws module +vi.mock('graphql-ws', () => ({ + createClient: vi.fn(() => ({ + dispose: vi.fn(), + on: vi.fn(), + subscribe: vi.fn(), + })), +})); + +// Mock undici +vi.mock('undici', () => ({ + Agent: vi.fn(() => ({ + connect: { socketPath: '/test/socket.sock' }, + })), + fetch: vi.fn(() => Promise.resolve({ ok: true })), +})); + +describe('InternalGraphQLClientFactory', () => { + let factory: InternalGraphQLClientFactory; + let socketConfig: SocketConfigService; + let configService: ConfigService; + let module: TestingModule; + + beforeEach(async () => { + module = await Test.createTestingModule({ + providers: [ + InternalGraphQLClientFactory, + { + provide: ConfigService, + useValue: { + get: vi.fn(), + }, + }, + { + provide: SocketConfigService, + useValue: { + isRunningOnSocket: vi.fn(), + getSocketPath: vi.fn(), + getApiAddress: vi.fn(), + getWebSocketUri: vi.fn(), + }, + }, + ], + }).compile(); + + factory = module.get(InternalGraphQLClientFactory); + socketConfig = module.get(SocketConfigService); + configService = module.get(ConfigService); + }); + + afterEach(async () => { + await module?.close(); + vi.clearAllMocks(); + }); + + describe('createClient', () => { + it('should throw error when getApiKey is not provided', async () => { + await expect(factory.createClient({ getApiKey: null as any })).rejects.toThrow(); + }); + + it('should create client with Unix socket configuration', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(true); + vi.mocked(socketConfig.getSocketPath).mockReturnValue('/var/run/unraid-api.sock'); + vi.mocked(socketConfig.getWebSocketUri).mockReturnValue(undefined); + + const client = await factory.createClient({ + getApiKey: async () => 'test-api-key', + enableSubscriptions: false, + }); + + expect(client).toBeInstanceOf(ApolloClient); + expect(socketConfig.isRunningOnSocket).toHaveBeenCalled(); + expect(socketConfig.getSocketPath).toHaveBeenCalled(); + }); + + it('should create client with HTTP configuration', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false); + vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql'); + vi.mocked(socketConfig.getWebSocketUri).mockReturnValue(undefined); + + const client = await factory.createClient({ + getApiKey: async () => 'test-api-key', + enableSubscriptions: false, + }); + + expect(client).toBeInstanceOf(ApolloClient); + expect(socketConfig.getApiAddress).toHaveBeenCalledWith('http'); + }); + + it('should create client with WebSocket subscriptions enabled on Unix socket', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(true); + vi.mocked(socketConfig.getSocketPath).mockReturnValue('/var/run/unraid-api.sock'); + vi.mocked(socketConfig.getWebSocketUri).mockReturnValue( + 'ws+unix:///var/run/unraid-api.sock:/graphql' + ); + + const client = await factory.createClient({ + getApiKey: async () => 'test-api-key', + enableSubscriptions: true, + }); + + expect(client).toBeInstanceOf(ApolloClient); + expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(true); + }); + + it('should create client with WebSocket subscriptions enabled on TCP', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false); + vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql'); + vi.mocked(socketConfig.getWebSocketUri).mockReturnValue('ws://127.0.0.1:3001/graphql'); + + const client = await factory.createClient({ + getApiKey: async () => 'test-api-key', + enableSubscriptions: true, + }); + + expect(client).toBeInstanceOf(ApolloClient); + expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(true); + }); + + it('should use custom origin when provided', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false); + vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql'); + + const client = await factory.createClient({ + getApiKey: async () => 'test-api-key', + enableSubscriptions: false, + origin: 'custom-origin', + }); + + expect(client).toBeInstanceOf(ApolloClient); + // The origin would be set in the HTTP headers, but we can't easily verify that with the mocked setup + }); + + it('should use default origin when not provided', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false); + vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql'); + + const client = await factory.createClient({ + getApiKey: async () => 'test-api-key', + enableSubscriptions: false, + }); + + expect(client).toBeInstanceOf(ApolloClient); + // Default origin should be 'http://localhost' + }); + + it('should handle subscription disabled even when wsUri is provided', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false); + vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql'); + vi.mocked(socketConfig.getWebSocketUri).mockReturnValue(undefined); // Subscriptions disabled + + const client = await factory.createClient({ + getApiKey: async () => 'test-api-key', + enableSubscriptions: false, + }); + + expect(client).toBeInstanceOf(ApolloClient); + expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(false); + }); + }); + + describe('configuration scenarios', () => { + it('should handle production configuration with Unix socket', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(true); + vi.mocked(socketConfig.getSocketPath).mockReturnValue('/var/run/unraid-api.sock'); + vi.mocked(socketConfig.getWebSocketUri).mockReturnValue( + 'ws+unix:///var/run/unraid-api.sock:/graphql' + ); + + const client = await factory.createClient({ + getApiKey: async () => 'production-key', + enableSubscriptions: true, + }); + + expect(client).toBeInstanceOf(ApolloClient); + expect(socketConfig.isRunningOnSocket).toHaveBeenCalled(); + expect(socketConfig.getSocketPath).toHaveBeenCalled(); + expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(true); + }); + + it('should handle development configuration with TCP port', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false); + vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql'); + vi.mocked(socketConfig.getWebSocketUri).mockReturnValue('ws://127.0.0.1:3001/graphql'); + + const client = await factory.createClient({ + getApiKey: async () => 'dev-key', + enableSubscriptions: true, + }); + + expect(client).toBeInstanceOf(ApolloClient); + expect(socketConfig.isRunningOnSocket).toHaveBeenCalled(); + expect(socketConfig.getApiAddress).toHaveBeenCalledWith('http'); + expect(socketConfig.getWebSocketUri).toHaveBeenCalledWith(true); + }); + + it('should create multiple clients with different configurations', async () => { + vi.mocked(socketConfig.isRunningOnSocket).mockReturnValue(false); + vi.mocked(socketConfig.getApiAddress).mockReturnValue('http://127.0.0.1:3001/graphql'); + vi.mocked(socketConfig.getWebSocketUri) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce('ws://127.0.0.1:3001/graphql'); + + const client1 = await factory.createClient({ + getApiKey: async () => 'key1', + enableSubscriptions: false, + }); + + const client2 = await factory.createClient({ + getApiKey: async () => 'key2', + enableSubscriptions: true, + }); + + expect(client1).toBeInstanceOf(ApolloClient); + expect(client2).toBeInstanceOf(ApolloClient); + expect(client1).not.toBe(client2); + }); + }); +}); diff --git a/api/src/unraid-api/shared/internal-graphql-client.factory.ts b/api/src/unraid-api/shared/internal-graphql-client.factory.ts new file mode 100644 index 0000000000..4ee4523a74 --- /dev/null +++ b/api/src/unraid-api/shared/internal-graphql-client.factory.ts @@ -0,0 +1,168 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import type { InternalGraphQLClientFactory as IInternalGraphQLClientFactory } from '@unraid/shared'; +import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client/core/index.js'; +import { setContext } from '@apollo/client/link/context/index.js'; +import { split } from '@apollo/client/link/core/index.js'; +import { onError } from '@apollo/client/link/error/index.js'; +import { HttpLink } from '@apollo/client/link/http/index.js'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js'; +import { getMainDefinition } from '@apollo/client/utilities/index.js'; +import { SocketConfigService } from '@unraid/shared'; +import { createClient } from 'graphql-ws'; +import { Agent, fetch as undiciFetch } from 'undici'; +import WebSocket from 'ws'; + +/** + * Factory service for creating internal GraphQL clients. + * + * This service provides a way for any module to create its own GraphQL client + * with its own API key and configuration. It does NOT provide any default + * API key access - each consumer must provide their own. + * + * This ensures proper security isolation between different modules. + */ +@Injectable() +export class InternalGraphQLClientFactory implements IInternalGraphQLClientFactory { + private readonly logger = new Logger(InternalGraphQLClientFactory.name); + + constructor( + private readonly configService: ConfigService, + private readonly socketConfig: SocketConfigService + ) {} + + /** + * Create a GraphQL client with the provided configuration. + * + * @param options Configuration options + * @param options.getApiKey Function to get the current API key + * @param options.enableSubscriptions Optional flag to enable WebSocket subscriptions + * @param options.origin Optional origin header (defaults to 'http://localhost') + */ + public async createClient(options: { + getApiKey: () => Promise; + enableSubscriptions?: boolean; + origin?: string; + }): Promise> { + if (!options.getApiKey) { + throw new Error('getApiKey function is required for creating a GraphQL client'); + } + + const { getApiKey, enableSubscriptions = false, origin = 'http://localhost' } = options; + let httpLink: HttpLink; + + // Get WebSocket URI if subscriptions are enabled + const wsUri = this.socketConfig.getWebSocketUri(enableSubscriptions); + if (enableSubscriptions && wsUri) { + this.logger.debug('WebSocket subscriptions enabled: %s', wsUri); + } + + if (this.socketConfig.isRunningOnSocket()) { + const socketPath = this.socketConfig.getSocketPath(); + this.logger.debug('Creating GraphQL client using Unix socket: %s', socketPath); + + const agent = new Agent({ + connect: { + socketPath, + }, + }); + + httpLink = new HttpLink({ + uri: 'http://localhost/graphql', + fetch: ((uri: any, options: any) => { + return undiciFetch( + uri as string, + { + ...options, + dispatcher: agent, + } as any + ); + }) as unknown as typeof fetch, + headers: { + Origin: origin, + 'Content-Type': 'application/json', + }, + }); + } else { + const httpUri = this.socketConfig.getApiAddress('http'); + this.logger.debug('Creating GraphQL client using HTTP: %s', httpUri); + + httpLink = new HttpLink({ + uri: httpUri, + fetch, + headers: { + Origin: origin, + 'Content-Type': 'application/json', + }, + }); + } + + // Create auth link that dynamically fetches the API key for each request + const authLink = setContext(async (_, { headers }) => { + const apiKey = await getApiKey(); + return { + headers: { + ...headers, + 'x-api-key': apiKey, + }, + }; + }); + + const errorLink = onError(({ networkError }) => { + if (networkError) { + this.logger.warn('[GRAPHQL-CLIENT] NETWORK ERROR ENCOUNTERED %o', networkError); + } + }); + + // If subscriptions are enabled, set up WebSocket link + if (enableSubscriptions && wsUri) { + const wsLink = new GraphQLWsLink( + createClient({ + url: wsUri, + connectionParams: async () => { + const apiKey = await getApiKey(); + return { 'x-api-key': apiKey }; + }, + webSocketImpl: WebSocket, + }) + ); + + const splitLink = split( + ({ query }) => { + const definition = getMainDefinition(query); + return ( + definition.kind === 'OperationDefinition' && + definition.operation === 'subscription' + ); + }, + wsLink, + httpLink + ); + + return new ApolloClient({ + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + mutate: { + fetchPolicy: 'no-cache', + }, + }, + cache: new InMemoryCache(), + link: errorLink.concat(authLink).concat(splitLink), + }); + } + + // HTTP-only client + return new ApolloClient({ + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + }, + }, + cache: new InMemoryCache(), + link: errorLink.concat(authLink).concat(httpLink), + }); + } +} diff --git a/packages/unraid-api-plugin-connect/package.json b/packages/unraid-api-plugin-connect/package.json index 32c6fa9a67..cd93ba7b9c 100644 --- a/packages/unraid-api-plugin-connect/package.json +++ b/packages/unraid-api-plugin-connect/package.json @@ -62,6 +62,7 @@ "rxjs": "7.8.2", "type-fest": "4.41.0", "typescript": "5.9.2", + "undici": "^7.13.0", "vitest": "3.2.4", "ws": "8.18.3", "zen-observable-ts": "1.1.0" @@ -97,6 +98,7 @@ "lodash-es": "4.17.21", "nest-authz": "2.17.0", "rxjs": "7.8.2", + "undici": "^7.13.0", "ws": "8.18.3", "zen-observable-ts": "1.1.0" } diff --git a/packages/unraid-api-plugin-connect/src/internal-rpc/internal.client.spec.ts b/packages/unraid-api-plugin-connect/src/internal-rpc/internal.client.spec.ts new file mode 100644 index 0000000000..cdd7e06ac1 --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/internal-rpc/internal.client.spec.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +import { InternalClientService } from './internal.client.js'; + +describe('InternalClientService', () => { + let service: InternalClientService; + let clientFactory: any; + let apiKeyService: any; + + const mockApolloClient = { + query: vi.fn(), + mutate: vi.fn(), + stop: vi.fn(), + }; + + beforeEach(() => { + clientFactory = { + createClient: vi.fn().mockResolvedValue(mockApolloClient), + }; + + apiKeyService = { + getOrCreateLocalApiKey: vi.fn().mockResolvedValue('test-connect-key'), + }; + + service = new InternalClientService( + clientFactory as any, + apiKeyService as any + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getClient', () => { + it('should create a client with Connect API key and subscriptions', async () => { + const client = await service.getClient(); + + // The API key is now fetched lazily through getApiKey function + expect(clientFactory.createClient).toHaveBeenCalledWith({ + getApiKey: expect.any(Function), + enableSubscriptions: true, + }); + + // Verify the getApiKey function works correctly when called + const callArgs = vi.mocked(clientFactory.createClient).mock.calls[0][0]; + const apiKey = await callArgs.getApiKey(); + expect(apiKey).toBe('test-connect-key'); + expect(apiKeyService.getOrCreateLocalApiKey).toHaveBeenCalled(); + + expect(client).toBe(mockApolloClient); + }); + + it('should return cached client on subsequent calls', async () => { + const client1 = await service.getClient(); + const client2 = await service.getClient(); + + expect(client1).toBe(client2); + expect(clientFactory.createClient).toHaveBeenCalledTimes(1); + }); + + it('should handle concurrent calls correctly', async () => { + // Create a delayed mock to simulate async client creation + let resolveClientCreation: (value: any) => void; + const clientCreationPromise = new Promise((resolve) => { + resolveClientCreation = resolve; + }); + + vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise); + + // Start multiple concurrent calls + const promise1 = service.getClient(); + const promise2 = service.getClient(); + const promise3 = service.getClient(); + + // Resolve the client creation + resolveClientCreation!(mockApolloClient); + + // Wait for all promises to resolve + const [client1, client2, client3] = await Promise.all([promise1, promise2, promise3]); + + // All should return the same client + expect(client1).toBe(mockApolloClient); + expect(client2).toBe(mockApolloClient); + expect(client3).toBe(mockApolloClient); + + // createClient should only have been called once + expect(clientFactory.createClient).toHaveBeenCalledTimes(1); + }); + + it('should handle errors during client creation', async () => { + const error = new Error('Failed to create client'); + vi.mocked(clientFactory.createClient).mockRejectedValueOnce(error); + + await expect(service.getClient()).rejects.toThrow(); + + // The in-flight promise should be cleared after error + // A subsequent call should attempt creation again + vi.mocked(clientFactory.createClient).mockResolvedValueOnce(mockApolloClient); + const client = await service.getClient(); + expect(client).toBe(mockApolloClient); + expect(clientFactory.createClient).toHaveBeenCalledTimes(2); + }); + }); + + describe('clearClient', () => { + it('should stop and clear the client', async () => { + // First create a client + await service.getClient(); + + // Clear the client + service.clearClient(); + + expect(mockApolloClient.stop).toHaveBeenCalled(); + }); + + it('should handle clearing when no client exists', () => { + // Should not throw when clearing a non-existent client + expect(() => service.clearClient()).not.toThrow(); + }); + + it('should create a new client after clearing', async () => { + // Create initial client + await service.getClient(); + + // Clear it + service.clearClient(); + + // Reset mock to return a new client + const newMockClient = { + query: vi.fn(), + mutate: vi.fn(), + stop: vi.fn(), + }; + vi.mocked(clientFactory.createClient).mockResolvedValueOnce(newMockClient); + + // Create new client + const newClient = await service.getClient(); + + // Should have created client twice total + expect(clientFactory.createClient).toHaveBeenCalledTimes(2); + expect(newClient).toBe(newMockClient); + }); + + it('should clear in-flight promise when clearing client', async () => { + // Create a delayed mock to simulate async client creation + let resolveClientCreation: (value: any) => void; + const clientCreationPromise = new Promise((resolve) => { + resolveClientCreation = resolve; + }); + + vi.mocked(clientFactory.createClient).mockReturnValueOnce(clientCreationPromise); + + // Start client creation + const promise1 = service.getClient(); + + // Clear client while creation is in progress + service.clearClient(); + + // Resolve the original creation + resolveClientCreation!(mockApolloClient); + await promise1; + + // Reset mock for new client + const newMockClient = { + query: vi.fn(), + mutate: vi.fn(), + stop: vi.fn(), + }; + vi.mocked(clientFactory.createClient).mockResolvedValueOnce(newMockClient); + + // Try to get client again - should create a new one + const client = await service.getClient(); + expect(client).toBe(newMockClient); + expect(clientFactory.createClient).toHaveBeenCalledTimes(2); + }); + + it('should handle clearClient during creation followed by new getClient call', async () => { + // Create two delayed mocks to simulate async client creation + let resolveFirstCreation: (value: any) => void; + let resolveSecondCreation: (value: any) => void; + + const firstCreationPromise = new Promise((resolve) => { + resolveFirstCreation = resolve; + }); + const secondCreationPromise = new Promise((resolve) => { + resolveSecondCreation = resolve; + }); + + const firstMockClient = { + query: vi.fn(), + mutate: vi.fn(), + stop: vi.fn(), + }; + const secondMockClient = { + query: vi.fn(), + mutate: vi.fn(), + stop: vi.fn(), + }; + + vi.mocked(clientFactory.createClient) + .mockReturnValueOnce(firstCreationPromise) + .mockReturnValueOnce(secondCreationPromise); + + // Thread A: Start first client creation + const promiseA = service.getClient(); + + // Thread B: Clear client while first creation is in progress + service.clearClient(); + + // Thread C: Start second client creation + const promiseC = service.getClient(); + + // Resolve first creation (should not set client) + resolveFirstCreation!(firstMockClient); + const clientA = await promiseA; + + // Resolve second creation (should set client) + resolveSecondCreation!(secondMockClient); + const clientC = await promiseC; + + // Both should return their respective clients + expect(clientA).toBe(firstMockClient); + expect(clientC).toBe(secondMockClient); + + // But only the second client should be cached + const cachedClient = await service.getClient(); + expect(cachedClient).toBe(secondMockClient); + + // Should have created exactly 2 clients + expect(clientFactory.createClient).toHaveBeenCalledTimes(2); + }); + + it('should handle rapid clear and get cycles correctly', async () => { + // Test rapid clear/get cycles + const clients: any[] = []; + for (let i = 0; i < 3; i++) { + const mockClient = { + query: vi.fn(), + mutate: vi.fn(), + stop: vi.fn(), + }; + clients.push(mockClient); + vi.mocked(clientFactory.createClient).mockResolvedValueOnce(mockClient); + } + + // Cycle 1: Create and immediately clear + const promise1 = service.getClient(); + service.clearClient(); + const client1 = await promise1; + expect(client1).toBe(clients[0]); + + // Cycle 2: Create and immediately clear + const promise2 = service.getClient(); + service.clearClient(); + const client2 = await promise2; + expect(client2).toBe(clients[1]); + + // Cycle 3: Create and let it complete + const client3 = await service.getClient(); + expect(client3).toBe(clients[2]); + + // Verify the third client is cached + const cachedClient = await service.getClient(); + expect(cachedClient).toBe(clients[2]); + + // Should have created exactly 3 clients + expect(clientFactory.createClient).toHaveBeenCalledTimes(3); + }); + }); +}); \ No newline at end of file diff --git a/packages/unraid-api-plugin-connect/src/internal-rpc/internal.client.ts b/packages/unraid-api-plugin-connect/src/internal-rpc/internal.client.ts index f3826e21ba..87200b58b7 100644 --- a/packages/unraid-api-plugin-connect/src/internal-rpc/internal.client.ts +++ b/packages/unraid-api-plugin-connect/src/internal-rpc/internal.client.ts @@ -1,135 +1,74 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client/core/index.js'; -import { split } from '@apollo/client/link/core/index.js'; -import { onError } from '@apollo/client/link/error/index.js'; -import { HttpLink } from '@apollo/client/link/http/index.js'; -import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js'; -import { getMainDefinition } from '@apollo/client/utilities/index.js'; -import { createClient } from 'graphql-ws'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ApolloClient, NormalizedCacheObject } from '@apollo/client/core/index.js'; +import { INTERNAL_CLIENT_SERVICE_TOKEN, type InternalGraphQLClientFactory } from '@unraid/shared'; import { ConnectApiKeyService } from '../authn/connect-api-key.service.js'; /** - * Internal GraphQL "RPC" client. - * - * Unfortunately, there's no simple way to make perform internal gql operations that go through - * all of the validations, filters, authorization, etc. in our setup. - * - * The simplest and most maintainable solution, unfortunately, is to maintain an actual graphql client - * that queries our own graphql server. - * - * This service handles the lifecycle and construction of that client. + * Connect-specific internal GraphQL client. + * + * This uses the shared GraphQL client factory with Connect's API key + * and enables subscriptions for real-time updates. */ @Injectable() export class InternalClientService { + private readonly logger = new Logger(InternalClientService.name); + private client: ApolloClient | null = null; + private clientCreationPromise: Promise> | null = null; + constructor( - private readonly configService: ConfigService, + @Inject(INTERNAL_CLIENT_SERVICE_TOKEN) + private readonly clientFactory: InternalGraphQLClientFactory, private readonly apiKeyService: ConnectApiKeyService ) {} - private PROD_NGINX_PORT = 80; - private logger = new Logger(InternalClientService.name); - private client: ApolloClient | null = null; - - private getNginxPort() { - return Number(this.configService.get('store.emhttp.nginx.httpPort', this.PROD_NGINX_PORT)); - } - - /** - * Get the port override from the environment variable PORT. e.g. during development. - * If the port is a socket port, return undefined. - */ - private getNonSocketPortOverride() { - const port = this.configService.get('PORT'); - if (!port || port.toString().includes('.sock')) { - return undefined; + public async getClient(): Promise> { + // If client already exists, return it + if (this.client) { + return this.client; } - return Number(port); - } - - /** - * Get the API address for the given protocol. - * @param protocol - The protocol to use. - * @param port - The port to use. - * @returns The API address. - */ - private getApiAddress(protocol: 'http' | 'ws', port = this.getNginxPort()) { - const portOverride = this.getNonSocketPortOverride(); - if (portOverride) { - return `${protocol}://127.0.0.1:${portOverride}/graphql`; + + // If client creation is in progress, wait for it + if (this.clientCreationPromise) { + return this.clientCreationPromise; } - if (port !== this.PROD_NGINX_PORT) { - return `${protocol}://127.0.0.1:${port}/graphql`; + + // Start client creation and store the promise + const creationPromise = this.createClient(); + this.clientCreationPromise = creationPromise; + + try { + // Wait for client creation to complete + const client = await creationPromise; + // Only set the client if this is still the current creation promise + // (if clearClient was called, clientCreationPromise would be null) + if (this.clientCreationPromise === creationPromise) { + this.client = client; + } + return client; + } finally { + // Clear the in-flight promise only if it's still ours + if (this.clientCreationPromise === creationPromise) { + this.clientCreationPromise = null; + } } - return `${protocol}://127.0.0.1/graphql`; } - private createApiClient({ apiKey }: { apiKey: string }) { - const httpUri = this.getApiAddress('http'); - const wsUri = this.getApiAddress('ws'); - this.logger.debug('Internal GraphQL URL: %s', httpUri); - - const httpLink = new HttpLink({ - uri: httpUri, - fetch, - headers: { - Origin: '/var/run/unraid-cli.sock', - 'x-api-key': apiKey, - 'Content-Type': 'application/json', - }, + private async createClient(): Promise> { + // Create a client with a function to get Connect's API key dynamically + const client = await this.clientFactory.createClient({ + getApiKey: () => this.apiKeyService.getOrCreateLocalApiKey(), + enableSubscriptions: true }); - - const wsLink = new GraphQLWsLink( - createClient({ - url: wsUri, - connectionParams: () => ({ 'x-api-key': apiKey }), - }) - ); - - const splitLink = split( - ({ query }) => { - const definition = getMainDefinition(query); - return ( - definition.kind === 'OperationDefinition' && definition.operation === 'subscription' - ); - }, - wsLink, - httpLink - ); - - const errorLink = onError(({ networkError }) => { - if (networkError) { - this.logger.warn('[GRAPHQL-CLIENT] NETWORK ERROR ENCOUNTERED %o', networkError); - } - }); - - return new ApolloClient({ - defaultOptions: { - query: { - fetchPolicy: 'no-cache', - }, - mutate: { - fetchPolicy: 'no-cache', - }, - }, - cache: new InMemoryCache(), - link: errorLink.concat(splitLink), - }); - } - - public async getClient() { - if (this.client) { - return this.client; - } - const localApiKey = await this.apiKeyService.getOrCreateLocalApiKey(); - this.client = this.createApiClient({ apiKey: localApiKey }); - return this.client; + + this.logger.debug('Created Connect internal GraphQL client with subscriptions enabled'); + return client; } public clearClient() { + // Stop the Apollo client to terminate any active processes this.client?.stop(); this.client = null; + this.clientCreationPromise = null; } } diff --git a/packages/unraid-api-plugin-connect/tsconfig.json b/packages/unraid-api-plugin-connect/tsconfig.json index 1c9beed57f..c31b240515 100644 --- a/packages/unraid-api-plugin-connect/tsconfig.json +++ b/packages/unraid-api-plugin-connect/tsconfig.json @@ -11,8 +11,7 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, + "forceConsistentCasingInFileNames": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/unraid-api-plugin-generator/tsconfig.build.json b/packages/unraid-api-plugin-generator/tsconfig.build.json index 8f3bbf360d..9ab0926573 100644 --- a/packages/unraid-api-plugin-generator/tsconfig.build.json +++ b/packages/unraid-api-plugin-generator/tsconfig.build.json @@ -5,6 +5,7 @@ "moduleResolution": "nodenext", "esModuleInterop": true, "strict": true, + "skipLibCheck": true, "outDir": "dist", "rootDir": "src" }, diff --git a/packages/unraid-api-plugin-generator/tsconfig.json b/packages/unraid-api-plugin-generator/tsconfig.json index 96565250fd..caf9622fad 100644 --- a/packages/unraid-api-plugin-generator/tsconfig.json +++ b/packages/unraid-api-plugin-generator/tsconfig.json @@ -7,6 +7,7 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, "strict": true, + "skipLibCheck": true, "outDir": "dist", "rootDir": "src" }, diff --git a/packages/unraid-api-plugin-health/tsconfig.json b/packages/unraid-api-plugin-health/tsconfig.json index cbf07ab839..8b906eb78d 100644 --- a/packages/unraid-api-plugin-health/tsconfig.json +++ b/packages/unraid-api-plugin-health/tsconfig.json @@ -11,8 +11,7 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, + "forceConsistentCasingInFileNames": true }, "include": ["index.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/unraid-shared/package.json b/packages/unraid-shared/package.json index 47d9a5e69d..8d54206095 100644 --- a/packages/unraid-shared/package.json +++ b/packages/unraid-shared/package.json @@ -20,7 +20,8 @@ "scripts": { "build": "rimraf dist && tsc --project tsconfig.build.json", "prepare": "npm run build", - "test": "bun test" + "test": "vitest run", + "test:watch": "vitest" }, "keywords": [], "author": "Lime Technology, Inc. ", @@ -31,19 +32,25 @@ "@jsonforms/core": "3.6.0", "@nestjs/common": "11.1.6", "@nestjs/graphql": "13.1.0", + "@nestjs/testing": "11.1.5", "@types/bun": "1.2.20", "@types/lodash-es": "4.17.12", "@types/node": "22.17.1", + "@types/ws": "^8.5.13", "class-validator": "0.14.2", "graphql": "16.11.0", "graphql-scalars": "1.24.2", + "graphql-ws": "6.0.6", "lodash-es": "4.17.21", "nest-authz": "2.17.0", "rimraf": "6.0.1", "type-fest": "4.41.0", - "typescript": "5.9.2" + "typescript": "5.9.2", + "vitest": "3.2.4", + "ws": "^8.18.3" }, "peerDependencies": { + "@apollo/client": "3.13.9", "@graphql-tools/utils": "10.9.1", "@jsonforms/core": "3.6.0", "@nestjs/common": "11.1.6", @@ -53,8 +60,11 @@ "class-validator": "0.14.2", "graphql": "16.11.0", "graphql-scalars": "1.24.2", + "graphql-ws": "6.0.6", "lodash-es": "4.17.21", "nest-authz": "2.17.0", - "rxjs": "7.8.2" + "rxjs": "7.8.2", + "undici": "7.13.0", + "ws": "^8.18.0" } } \ No newline at end of file diff --git a/packages/unraid-shared/src/index.ts b/packages/unraid-shared/src/index.ts index b0230f3561..f4d2cb5c9f 100644 --- a/packages/unraid-shared/src/index.ts +++ b/packages/unraid-shared/src/index.ts @@ -1,4 +1,6 @@ export { ApiKeyService } from './services/api-key.js'; +export { SocketConfigService } from './services/socket-config.service.js'; export * from './graphql.model.js'; export * from './tokens.js'; export * from './use-permissions.directive.js'; +export type { InternalGraphQLClientFactory } from './types/internal-graphql-client.factory.js'; diff --git a/packages/unraid-shared/src/jsonforms/__tests__/settings.test.ts b/packages/unraid-shared/src/jsonforms/__tests__/settings.test.ts index b2ef5d795d..e5c4f90c1f 100644 --- a/packages/unraid-shared/src/jsonforms/__tests__/settings.test.ts +++ b/packages/unraid-shared/src/jsonforms/__tests__/settings.test.ts @@ -1,4 +1,4 @@ -import { expect, test, describe } from "bun:test"; +import { expect, test, describe } from "vitest"; import { mergeSettingSlices, type SettingSlice } from "../settings.js"; describe("mergeSettingSlices element ordering", () => { diff --git a/packages/unraid-shared/src/services/__tests__/config-file.test.ts b/packages/unraid-shared/src/services/__tests__/config-file.test.ts index b5821b0990..22f2e277cc 100644 --- a/packages/unraid-shared/src/services/__tests__/config-file.test.ts +++ b/packages/unraid-shared/src/services/__tests__/config-file.test.ts @@ -1,4 +1,4 @@ -import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import { expect, test, describe, beforeEach, afterEach } from "vitest"; import { Subject } from "rxjs"; import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; diff --git a/packages/unraid-shared/src/services/internal-graphql-client-usage.spec.ts b/packages/unraid-shared/src/services/internal-graphql-client-usage.spec.ts new file mode 100644 index 0000000000..57ebdfc348 --- /dev/null +++ b/packages/unraid-shared/src/services/internal-graphql-client-usage.spec.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { ConfigService } from '@nestjs/config'; +import { ApolloClient } from '@apollo/client/core/index.js'; + +import { SocketConfigService } from './socket-config.service.js'; + +// Mock graphql-ws +vi.mock('graphql-ws', () => ({ + createClient: vi.fn(() => ({ + dispose: vi.fn(), + on: vi.fn(), + subscribe: vi.fn(), + })), +})); + +// Mock undici +vi.mock('undici', () => ({ + Agent: vi.fn(() => ({ + connect: { socketPath: '/test/socket.sock' }, + })), + fetch: vi.fn(() => Promise.resolve({ ok: true })), +})); + +// Mock factory similar to InternalGraphQLClientFactory +class MockGraphQLClientFactory { + constructor( + private readonly configService: ConfigService, + private readonly socketConfig: SocketConfigService + ) {} + + async createClient(options: { + apiKey: string; + enableSubscriptions?: boolean; + origin?: string; + }): Promise> { + if (!options.apiKey) { + throw new Error('API key is required for creating a GraphQL client'); + } + + // Return a mock Apollo client + const mockClient = { + query: vi.fn(), + mutate: vi.fn(), + stop: vi.fn(), + subscribe: vi.fn(), + watchQuery: vi.fn(), + readQuery: vi.fn(), + writeQuery: vi.fn(), + cache: { + reset: vi.fn(), + }, + } as any; + + return mockClient; + } +} + +// Service that uses the factory pattern (like CliInternalClientService) +class ClientConsumerService { + private client: ApolloClient | null = null; + private wsClient: any = null; + + constructor( + private readonly factory: MockGraphQLClientFactory, + private readonly apiKeyProvider: () => Promise, + private readonly options: { enableSubscriptions?: boolean; origin?: string } = {} + ) { + // Use default origin if not provided + if (!this.options.origin) { + this.options.origin = 'http://localhost'; + } + } + + async getClient(): Promise> { + if (this.client) { + return this.client; + } + + const apiKey = await this.apiKeyProvider(); + this.client = await this.factory.createClient({ + apiKey, + ...this.options, + }); + + return this.client; + } + + clearClient() { + // Stop the Apollo client to terminate any active processes + this.client?.stop(); + // Clean up WebSocket client if it exists + if (this.wsClient) { + this.wsClient.dispose(); + this.wsClient = null; + } + this.client = null; + } +} + +describe('InternalGraphQLClient Usage Patterns', () => { + let factory: MockGraphQLClientFactory; + let configService: ConfigService; + let socketConfig: SocketConfigService; + let apiKeyProvider: () => Promise; + let service: ClientConsumerService; + + beforeEach(() => { + // Create mock ConfigService + configService = { + get: vi.fn((key, defaultValue) => { + if (key === 'PORT') return '3001'; + return defaultValue; + }), + } as any; + + // Create SocketConfigService instance + socketConfig = new SocketConfigService(configService); + + // Create factory + factory = new MockGraphQLClientFactory(configService, socketConfig); + + // Create mock API key provider + apiKeyProvider = vi.fn().mockResolvedValue('test-api-key'); + + // Create service + service = new ClientConsumerService(factory, apiKeyProvider); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor and initialization', () => { + it('should initialize with default options', () => { + const service = new ClientConsumerService(factory, apiKeyProvider); + expect(service).toBeDefined(); + // @ts-ignore - accessing private property for testing + expect(service.options.origin).toBe('http://localhost'); + }); + + it('should initialize with custom options', () => { + const options = { + enableSubscriptions: true, + origin: 'custom-origin', + }; + const service = new ClientConsumerService(factory, apiKeyProvider, options); + + // @ts-ignore - accessing private property for testing + expect(service.options.enableSubscriptions).toBe(true); + // @ts-ignore - accessing private property for testing + expect(service.options.origin).toBe('custom-origin'); + }); + }); + + describe('API key handling', () => { + it('should get API key from provider', async () => { + const client = await service.getClient(); + expect(apiKeyProvider).toHaveBeenCalled(); + expect(client).toBeDefined(); + }); + + it('should handle API key provider failures gracefully', async () => { + const failingProvider = vi.fn().mockRejectedValue(new Error('API key error')); + const service = new ClientConsumerService(factory, failingProvider); + + await expect(service.getClient()).rejects.toThrow('API key error'); + }); + }); + + describe('client lifecycle management', () => { + it('should create and cache client on first call', async () => { + const client = await service.getClient(); + expect(client).toBeDefined(); + expect(client.query).toBeDefined(); + expect(apiKeyProvider).toHaveBeenCalledOnce(); + + // Second call should return cached client + const client2 = await service.getClient(); + expect(client2).toBe(client); + expect(apiKeyProvider).toHaveBeenCalledOnce(); // Still only called once + }); + + it('should clear cached client and stop it', async () => { + const client = await service.getClient(); + const stopSpy = vi.spyOn(client, 'stop'); + + service.clearClient(); + + expect(stopSpy).toHaveBeenCalled(); + // @ts-ignore - accessing private property for testing + expect(service.client).toBeNull(); + }); + + it('should handle clearing when no client exists', () => { + expect(() => service.clearClient()).not.toThrow(); + }); + + it('should create new client after clearing', async () => { + const client1 = await service.getClient(); + service.clearClient(); + + const client2 = await service.getClient(); + expect(client2).not.toBe(client1); + expect(apiKeyProvider).toHaveBeenCalledTimes(2); + }); + }); + + describe('configuration scenarios', () => { + it('should handle Unix socket configuration', async () => { + vi.mocked(configService.get).mockImplementation((key, defaultValue) => { + if (key === 'PORT') return '/var/run/unraid-api.sock'; + return defaultValue; + }); + + const socketConfig = new SocketConfigService(configService); + const factory = new MockGraphQLClientFactory(configService, socketConfig); + const service = new ClientConsumerService(factory, apiKeyProvider); + + const client = await service.getClient(); + expect(client).toBeDefined(); + expect(apiKeyProvider).toHaveBeenCalled(); + }); + + it('should handle TCP port configuration', async () => { + vi.mocked(configService.get).mockImplementation((key, defaultValue) => { + if (key === 'PORT') return '3001'; + return defaultValue; + }); + + const client = await service.getClient(); + expect(client).toBeDefined(); + expect(apiKeyProvider).toHaveBeenCalled(); + }); + + it('should handle WebSocket subscriptions when enabled', async () => { + const options = { enableSubscriptions: true }; + const service = new ClientConsumerService(factory, apiKeyProvider, options); + + const client = await service.getClient(); + expect(client).toBeDefined(); + expect(client.subscribe).toBeDefined(); + }); + }); + + describe('factory pattern benefits', () => { + it('should allow multiple services to use the same factory', async () => { + const service1 = new ClientConsumerService(factory, apiKeyProvider, { + origin: 'service1', + }); + const service2 = new ClientConsumerService(factory, apiKeyProvider, { + origin: 'service2', + }); + + const client1 = await service1.getClient(); + const client2 = await service2.getClient(); + + expect(client1).toBeDefined(); + expect(client2).toBeDefined(); + // Each service gets its own client instance + expect(client1).not.toBe(client2); + }); + + it('should handle different API keys for different services', async () => { + const provider1 = vi.fn().mockResolvedValue('api-key-1'); + const provider2 = vi.fn().mockResolvedValue('api-key-2'); + + const service1 = new ClientConsumerService(factory, provider1); + const service2 = new ClientConsumerService(factory, provider2); + + await service1.getClient(); + await service2.getClient(); + + expect(provider1).toHaveBeenCalledOnce(); + expect(provider2).toHaveBeenCalledOnce(); + }); + }); + + describe('integration scenarios', () => { + it('should handle production scenario with Unix socket and subscriptions', async () => { + vi.mocked(configService.get).mockImplementation((key, defaultValue) => { + if (key === 'PORT') return '/var/run/unraid-api.sock'; + if (key === 'store.emhttp.nginx.httpPort') return '80'; + return defaultValue; + }); + + const socketConfig = new SocketConfigService(configService); + const factory = new MockGraphQLClientFactory(configService, socketConfig); + const service = new ClientConsumerService(factory, apiKeyProvider, { + enableSubscriptions: true, + }); + + const client = await service.getClient(); + expect(client).toBeDefined(); + }); + + it('should handle development scenario with TCP port and subscriptions', async () => { + vi.mocked(configService.get).mockImplementation((key, defaultValue) => { + if (key === 'PORT') return '3001'; + return defaultValue; + }); + + const socketConfig = new SocketConfigService(configService); + const factory = new MockGraphQLClientFactory(configService, socketConfig); + const service = new ClientConsumerService(factory, apiKeyProvider, { + enableSubscriptions: true, + }); + + const client = await service.getClient(); + expect(client).toBeDefined(); + }); + + it('should handle multiple client lifecycle operations', async () => { + const client1 = await service.getClient(); + expect(client1).toBeDefined(); + + service.clearClient(); + + const client2 = await service.getClient(); + expect(client2).toBeDefined(); + expect(client2).not.toBe(client1); + + service.clearClient(); + + const client3 = await service.getClient(); + expect(client3).toBeDefined(); + expect(client3).not.toBe(client2); + }); + + it('should handle WebSocket client cleanup when subscriptions are enabled', async () => { + const mockWsClient = { dispose: vi.fn() }; + const service = new ClientConsumerService(factory, apiKeyProvider, { + enableSubscriptions: true, + }); + + // First create a client + await service.getClient(); + + // Mock the WebSocket client after it's created + // @ts-ignore - accessing private property for testing + service.wsClient = mockWsClient; + + service.clearClient(); + + expect(mockWsClient.dispose).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/packages/unraid-shared/src/services/socket-config.service.spec.ts b/packages/unraid-shared/src/services/socket-config.service.spec.ts new file mode 100644 index 0000000000..bbf7cd8312 --- /dev/null +++ b/packages/unraid-shared/src/services/socket-config.service.spec.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ConfigService } from '@nestjs/config'; + +import { SocketConfigService } from './socket-config.service.js'; + +describe('SocketConfigService', () => { + let service: SocketConfigService; + let configService: ConfigService; + + beforeEach(() => { + configService = new ConfigService(); + service = new SocketConfigService(configService); + }); + + afterEach(() => { + // Clean up all spies and mocks after each test + vi.restoreAllMocks(); + }); + + describe('getNginxPort', () => { + it('should return configured nginx port', () => { + vi.spyOn(configService, 'get').mockReturnValue('8080'); + + const port = service.getNginxPort(); + + expect(port).toBe(8080); + expect(configService.get).toHaveBeenCalledWith('store.emhttp.nginx.httpPort', 80); + }); + + it('should return default port when not configured', () => { + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => defaultValue); + + const port = service.getNginxPort(); + + expect(port).toBe(80); + }); + }); + + describe('isRunningOnSocket', () => { + it('should return true when PORT contains .sock', () => { + vi.spyOn(configService, 'get').mockReturnValue('/var/run/unraid-api.sock'); + + expect(service.isRunningOnSocket()).toBe(true); + }); + + it('should return false when PORT is numeric', () => { + vi.spyOn(configService, 'get').mockReturnValue('3000'); + + expect(service.isRunningOnSocket()).toBe(false); + }); + + it('should use default socket path when PORT not set', () => { + // Mock to simulate PORT not being set in configuration + // When PORT returns undefined, ConfigService should use the default value + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => { + if (key === 'PORT') { + // Simulate ConfigService behavior: return default when config is undefined + return defaultValue; // This simulates PORT not being in config + } + return defaultValue; + }); + + expect(service.isRunningOnSocket()).toBe(true); + expect(configService.get).toHaveBeenCalledWith('PORT', '/var/run/unraid-api.sock'); + }); + }); + + describe('getSocketPath', () => { + it('should return configured socket path', () => { + const socketPath = '/custom/socket.sock'; + vi.spyOn(configService, 'get').mockReturnValue(socketPath); + + expect(service.getSocketPath()).toBe(socketPath); + }); + + it('should return default socket path when not configured', () => { + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => defaultValue); + + expect(service.getSocketPath()).toBe('/var/run/unraid-api.sock'); + }); + }); + + describe('getNumericPort', () => { + it('should return numeric port when configured', () => { + vi.spyOn(configService, 'get').mockReturnValue('3000'); + + expect(service.getNumericPort()).toBe(3000); + }); + + it('should return undefined when running on socket', () => { + vi.spyOn(configService, 'get').mockReturnValue('/var/run/unraid-api.sock'); + + expect(service.getNumericPort()).toBeUndefined(); + }); + + it('should handle string ports correctly', () => { + vi.spyOn(configService, 'get').mockReturnValue('8080'); + + expect(service.getNumericPort()).toBe(8080); + }); + + it('should return undefined for non-numeric port values', () => { + vi.spyOn(configService, 'get').mockReturnValue('invalid-port'); + + expect(service.getNumericPort()).toBeUndefined(); + }); + + it('should return undefined for empty string port', () => { + vi.spyOn(configService, 'get').mockReturnValue(''); + + expect(service.getNumericPort()).toBeUndefined(); + }); + + it('should return undefined for port with mixed characters', () => { + vi.spyOn(configService, 'get').mockReturnValue('3000abc'); + + expect(service.getNumericPort()).toBeUndefined(); + }); + + it('should return undefined for port 0', () => { + vi.spyOn(configService, 'get').mockReturnValue('0'); + + expect(service.getNumericPort()).toBeUndefined(); + }); + + it('should return undefined for negative port', () => { + vi.spyOn(configService, 'get').mockReturnValue('-1'); + + expect(service.getNumericPort()).toBeUndefined(); + }); + + it('should return undefined for port above 65535', () => { + vi.spyOn(configService, 'get').mockReturnValue('70000'); + + expect(service.getNumericPort()).toBeUndefined(); + }); + + it('should return valid port 65535', () => { + vi.spyOn(configService, 'get').mockReturnValue('65535'); + + expect(service.getNumericPort()).toBe(65535); + }); + }); + + describe('getApiAddress', () => { + it('should return HTTP address with numeric port', () => { + vi.spyOn(configService, 'get').mockImplementation((key) => { + if (key === 'PORT') return '3000'; + return undefined; + }); + + expect(service.getApiAddress('http')).toBe('http://127.0.0.1:3000/graphql'); + }); + + it('should return WS address with numeric port', () => { + vi.spyOn(configService, 'get').mockImplementation((key) => { + if (key === 'PORT') return '3000'; + return undefined; + }); + + expect(service.getApiAddress('ws')).toBe('ws://127.0.0.1:3000/graphql'); + }); + + it('should use nginx port when no numeric port configured', () => { + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => { + if (key === 'PORT') return '/var/run/unraid-api.sock'; + if (key === 'store.emhttp.nginx.httpPort') return '8080'; + return defaultValue; + }); + + expect(service.getApiAddress('http')).toBe('http://127.0.0.1:8080/graphql'); + }); + + it('should omit port when nginx port is default (80)', () => { + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => { + if (key === 'PORT') return '/var/run/unraid-api.sock'; + if (key === 'store.emhttp.nginx.httpPort') return '80'; + return defaultValue; + }); + + expect(service.getApiAddress('http')).toBe('http://127.0.0.1/graphql'); + }); + + it('should default to http protocol', () => { + vi.spyOn(configService, 'get').mockImplementation((key) => { + if (key === 'PORT') return '3000'; + return undefined; + }); + + expect(service.getApiAddress()).toBe('http://127.0.0.1:3000/graphql'); + }); + + it('should fallback to nginx port when PORT is invalid', () => { + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => { + if (key === 'PORT') return 'invalid-port'; + if (key === 'store.emhttp.nginx.httpPort') return '8080'; + return defaultValue; + }); + + expect(service.getApiAddress('http')).toBe('http://127.0.0.1:8080/graphql'); + }); + + it('should use default port when PORT is invalid and nginx port is default', () => { + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => { + if (key === 'PORT') return 'not-a-number'; + if (key === 'store.emhttp.nginx.httpPort') return '80'; + return defaultValue; + }); + + expect(service.getApiAddress('http')).toBe('http://127.0.0.1/graphql'); + }); + }); + + describe('getWebSocketUri', () => { + it('should return undefined when subscriptions disabled', () => { + expect(service.getWebSocketUri(false)).toBeUndefined(); + }); + + it('should return ws+unix:// URI when running on socket', () => { + const socketPath = '/var/run/unraid-api.sock'; + vi.spyOn(configService, 'get').mockReturnValue(socketPath); + + const uri = service.getWebSocketUri(true); + + expect(uri).toBe(`ws+unix://${socketPath}:/graphql`); + }); + + it('should return ws:// URI when running on TCP port', () => { + vi.spyOn(configService, 'get').mockImplementation((key) => { + if (key === 'PORT') return '3000'; + return undefined; + }); + + const uri = service.getWebSocketUri(true); + + expect(uri).toBe('ws://127.0.0.1:3000/graphql'); + }); + + it('should handle custom socket paths', () => { + const customSocket = '/custom/path/api.sock'; + vi.spyOn(configService, 'get').mockReturnValue(customSocket); + + const uri = service.getWebSocketUri(true); + + expect(uri).toBe(`ws+unix://${customSocket}:/graphql`); + }); + + it('should use TCP port for WebSocket when running on TCP port', () => { + // Configure to use TCP port instead of Unix socket + // This naturally causes isRunningOnSocket() to return false + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => { + if (key === 'PORT') return '3001'; // TCP port, not a socket + if (key === 'store.emhttp.nginx.httpPort') return '8080'; + return defaultValue; + }); + + const uri = service.getWebSocketUri(true); + + // When PORT is numeric, it uses that port directly for WebSocket + expect(uri).toBe('ws://127.0.0.1:3001/graphql'); + }); + }); + + describe('integration scenarios', () => { + it('should handle production configuration correctly', () => { + // Production typically runs on Unix socket + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => { + if (key === 'PORT') return '/var/run/unraid-api.sock'; + if (key === 'store.emhttp.nginx.httpPort') return '80'; + return defaultValue; + }); + + expect(service.isRunningOnSocket()).toBe(true); + expect(service.getSocketPath()).toBe('/var/run/unraid-api.sock'); + expect(service.getNumericPort()).toBeUndefined(); + expect(service.getApiAddress('http')).toBe('http://127.0.0.1/graphql'); + expect(service.getWebSocketUri(true)).toBe('ws+unix:///var/run/unraid-api.sock:/graphql'); + }); + + it('should handle development configuration correctly', () => { + // Development typically runs on TCP port + vi.spyOn(configService, 'get').mockImplementation((key, defaultValue) => { + if (key === 'PORT') return '3001'; + return defaultValue; + }); + + expect(service.isRunningOnSocket()).toBe(false); + expect(service.getNumericPort()).toBe(3001); + expect(service.getApiAddress('http')).toBe('http://127.0.0.1:3001/graphql'); + expect(service.getWebSocketUri(true)).toBe('ws://127.0.0.1:3001/graphql'); + }); + }); +}); \ No newline at end of file diff --git a/packages/unraid-shared/src/services/socket-config.service.ts b/packages/unraid-shared/src/services/socket-config.service.ts new file mode 100644 index 0000000000..7d7e71bb42 --- /dev/null +++ b/packages/unraid-shared/src/services/socket-config.service.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** + * Shared service for socket detection and address resolution. + * Used by InternalGraphQLClientFactory and other services that need socket configuration. + */ +@Injectable() +export class SocketConfigService { + private readonly PROD_NGINX_PORT = 80; + + constructor(private readonly configService: ConfigService) {} + + /** + * Get the nginx port from configuration + */ + getNginxPort(): number { + const port = Number(this.configService.get('store.emhttp.nginx.httpPort', this.PROD_NGINX_PORT)); + // Validate the numeric result and fall back to PROD_NGINX_PORT if invalid + if (!Number.isFinite(port) || port <= 0 || port > 65535) { + return this.PROD_NGINX_PORT; + } + return port; + } + + /** + * Check if the API is running on a Unix socket + */ + isRunningOnSocket(): boolean { + const port = this.configService.get('PORT', '/var/run/unraid-api.sock'); + return port.includes('.sock'); + } + + /** + * Get the socket path from config + */ + getSocketPath(): string { + return this.configService.get('PORT', '/var/run/unraid-api.sock'); + } + + /** + * Get the numeric port if not running on socket + */ + getNumericPort(): number | undefined { + const port = this.configService.get('PORT', '/var/run/unraid-api.sock'); + if (port.includes('.sock')) { + return undefined; + } + const numericPort = Number(port); + // Check if the conversion resulted in a valid finite number + // Also check for reasonable port range (0 is not a valid port) + if (!Number.isFinite(numericPort) || numericPort <= 0 || numericPort > 65535) { + return undefined; + } + return numericPort; + } + + /** + * Get the API address for HTTP or WebSocket requests. + * @param protocol - The protocol to use ('http' or 'ws') + * @returns The full API endpoint URL + */ + getApiAddress(protocol: 'http' | 'ws' = 'http'): string { + const numericPort = this.getNumericPort(); + if (numericPort) { + return `${protocol}://127.0.0.1:${numericPort}/graphql`; + } + const nginxPort = this.getNginxPort(); + if (nginxPort !== this.PROD_NGINX_PORT) { + return `${protocol}://127.0.0.1:${nginxPort}/graphql`; + } + return `${protocol}://127.0.0.1/graphql`; + } + + /** + * Get the WebSocket URI for subscriptions. + * Handles both Unix socket and TCP connections. + * @param enableSubscriptions - Whether subscriptions are enabled + * @returns The WebSocket URI or undefined if subscriptions are disabled + */ + getWebSocketUri(enableSubscriptions: boolean = false): string | undefined { + if (!enableSubscriptions) { + return undefined; + } + + if (this.isRunningOnSocket()) { + // For Unix sockets, use the ws+unix:// protocol + // Format: ws+unix://socket/path:/url/path + const socketPath = this.getSocketPath(); + return `ws+unix://${socketPath}:/graphql`; + } + + return this.getApiAddress('ws'); + } +} \ No newline at end of file diff --git a/packages/unraid-shared/src/services/ws-unix-socket-test.spec.ts b/packages/unraid-shared/src/services/ws-unix-socket-test.spec.ts new file mode 100644 index 0000000000..f1942e2633 --- /dev/null +++ b/packages/unraid-shared/src/services/ws-unix-socket-test.spec.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import WebSocket, { WebSocketServer } from 'ws'; +import { createServer } from 'http'; +import { unlinkSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +describe('WebSocket Unix Socket - Actual Connection Test', () => { + const socketPath = join(tmpdir(), 'test-ws-unix-' + Date.now() + '.sock'); + let server: ReturnType; + let wss: WebSocketServer; + + beforeAll(async () => { + // Clean up any existing socket file + if (existsSync(socketPath)) { + unlinkSync(socketPath); + } + + // Create an HTTP server + server = createServer((req, res) => { + res.writeHead(200); + res.end('HTTP server on Unix socket'); + }); + + // Create WebSocket server attached to the HTTP server + wss = new WebSocketServer({ server }); + + // Handle WebSocket connections + wss.on('connection', (ws, request) => { + console.log('Server: New WebSocket connection on path:', request.url); + + // Send welcome message + ws.send(JSON.stringify({ type: 'welcome', message: 'Connected to Unix socket' })); + + // Echo messages back + ws.on('message', (data) => { + const message = data.toString(); + console.log('Server received:', message); + ws.send(JSON.stringify({ type: 'echo', message })); + }); + + ws.on('close', () => { + console.log('Server: Client disconnected'); + }); + }); + + // Start listening on Unix socket + await new Promise((resolve, reject) => { + server.listen(socketPath, () => { + console.log(`Server listening on Unix socket: ${socketPath}`); + resolve(); + }); + + server.on('error', (err) => { + console.error('Server error:', err); + reject(err); + }); + }); + }); + + afterAll(async () => { + // First, close all WebSocket clients gracefully + if (wss && wss.clients) { + const closePromises: Promise[] = []; + for (const client of wss.clients) { + if (client.readyState === WebSocket.OPEN) { + closePromises.push( + new Promise((resolve) => { + client.once('close', () => resolve()); + client.close(1000, 'Test ending'); + }) + ); + } else { + client.terminate(); + } + } + // Wait for all clients to close + await Promise.all(closePromises); + } + + // Close WebSocket server + if (wss) { + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + } + + // Close HTTP server + if (server && server.listening) { + await new Promise((resolve) => { + server.close((err) => { + if (err) console.error('Server close error:', err); + resolve(); + }); + }); + } + + // Clean up socket file + try { + if (existsSync(socketPath)) { + unlinkSync(socketPath); + } + } catch (err) { + console.error('Error cleaning up socket file:', err); + } + }); + + it('should connect to Unix socket using ws+unix:// protocol', async () => { + // This is the exact format the ws library expects for Unix sockets + const wsUrl = `ws+unix://${socketPath}:/`; + console.log('Connecting to:', wsUrl); + + const client = new WebSocket(wsUrl); + + // Wait for connection and first message + const connected = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Connection timeout')), 10000); + + client.once('open', () => { + console.log('Client: Connected successfully!'); + clearTimeout(timeout); + // Connection established - that's what we want to test + resolve(true); + }); + + client.once('error', (err) => { + console.error('Client error:', err); + clearTimeout(timeout); + reject(err); + }); + }); + + expect(connected).toBe(true); + + // Test message exchange + client.send('Test message'); + + const received = await new Promise((resolve) => { + client.on('message', (data) => { + resolve(JSON.parse(data.toString())); + }); + + setTimeout(() => resolve(null), 1000); + }); + + // We should have received something (welcome or echo) + expect(received).toBeTruthy(); + + // Clean up gracefully + if (client.readyState === WebSocket.OPEN) { + client.close(1000, 'Test complete'); + } else { + client.terminate(); + } + }); + + it('should connect with /graphql path like SocketConfigService', async () => { + // Test the exact format that SocketConfigService.getWebSocketUri() returns + const wsUrl = `ws+unix://${socketPath}:/graphql`; + console.log('Testing SocketConfigService format:', wsUrl); + + const client = new WebSocket(wsUrl); + + await new Promise((resolve, reject) => { + client.on('open', () => { + console.log('Client: Connected to /graphql path'); + resolve(); + }); + + client.on('error', reject); + setTimeout(() => reject(new Error('Connection timeout')), 2000); + }); + + // Connection successful! + expect(client.readyState).toBe(WebSocket.OPEN); + + client.close(); + }); + + it('should fail when using regular ws:// to connect to Unix socket', async () => { + // This should fail - attempting to connect to non-existent Unix socket + const nonExistentSocket = `${socketPath}.nonexistent`; + const wsUrl = `ws+unix://${nonExistentSocket}:/test`; + + await expect( + new Promise((_, reject) => { + const client = new WebSocket(wsUrl); + client.on('error', reject); + client.on('open', () => reject(new Error('Should not connect'))); + }) + ).rejects.toThrow(); + }); + + it('should work with multiple concurrent connections', async () => { + const clients: WebSocket[] = []; + const numClients = 3; + + // Create multiple connections + for (let i = 0; i < numClients; i++) { + const wsUrl = `ws+unix://${socketPath}:/client-${i}`; + const client = new WebSocket(wsUrl); + + await new Promise((resolve, reject) => { + client.on('open', () => { + console.log(`Client ${i} connected`); + resolve(); + }); + + client.on('error', reject); + setTimeout(() => reject(new Error('Connection timeout')), 2000); + }); + + clients.push(client); + } + + expect(clients).toHaveLength(numClients); + // The server might have leftover connections from previous tests + expect(wss.clients.size).toBeGreaterThanOrEqual(numClients); + + // Clean up all clients gracefully + clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.close(1000, 'Test complete'); + } else { + client.terminate(); + } + }); + }); + + it('should verify the exact implementation used in BaseInternalClientService', async () => { + // This tests the exact code path in base-internal-client.service.ts + const { createClient } = await import('graphql-ws'); + + const wsUrl = `ws+unix://${socketPath}:/graphql`; + + // This is exactly what BaseInternalClientService does + const wsClient = createClient({ + url: wsUrl, + connectionParams: () => ({ 'x-api-key': 'test-key' }), + webSocketImpl: WebSocket, + retryAttempts: 0, + lazy: true, // Use lazy mode to prevent immediate connection + on: { + error: (err) => { + // Suppress connection errors in test + const message = err instanceof Error ? err.message : String(err); + console.log('GraphQL client error (expected in test):', message); + }, + }, + }); + + // The client should be created without errors + expect(wsClient).toBeDefined(); + expect(wsClient.dispose).toBeDefined(); + + // Clean up immediately - don't wait for connection + await wsClient.dispose(); + }); +}); \ No newline at end of file diff --git a/packages/unraid-shared/src/tokens.ts b/packages/unraid-shared/src/tokens.ts index d1998fd186..97b79c1698 100644 --- a/packages/unraid-shared/src/tokens.ts +++ b/packages/unraid-shared/src/tokens.ts @@ -2,3 +2,4 @@ export const UPNP_CLIENT_TOKEN = 'UPNP_CLIENT'; export const API_KEY_SERVICE_TOKEN = 'ApiKeyService'; export const LIFECYCLE_SERVICE_TOKEN = 'LifecycleService'; export const NGINX_SERVICE_TOKEN = 'NginxService'; +export const INTERNAL_CLIENT_SERVICE_TOKEN = 'InternalClientService'; diff --git a/packages/unraid-shared/src/types/internal-graphql-client.factory.ts b/packages/unraid-shared/src/types/internal-graphql-client.factory.ts new file mode 100644 index 0000000000..c557cee1ad --- /dev/null +++ b/packages/unraid-shared/src/types/internal-graphql-client.factory.ts @@ -0,0 +1,13 @@ +import type { ApolloClient, NormalizedCacheObject } from '@apollo/client/core/index.js'; + +/** + * Interface for the internal GraphQL client factory. + * The actual implementation is provided by the API package through dependency injection. + */ +export interface InternalGraphQLClientFactory { + createClient(options: { + getApiKey: () => Promise; + enableSubscriptions?: boolean; + origin?: string; + }): Promise>; +} \ No newline at end of file diff --git a/packages/unraid-shared/src/util/__tests__/config-definition.test.ts b/packages/unraid-shared/src/util/__tests__/config-definition.test.ts index cdea6f4a02..8bdc0c24b4 100644 --- a/packages/unraid-shared/src/util/__tests__/config-definition.test.ts +++ b/packages/unraid-shared/src/util/__tests__/config-definition.test.ts @@ -1,4 +1,4 @@ -import { expect, test, describe, beforeEach } from "bun:test"; +import { expect, test, describe, beforeEach } from "vitest"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ConfigDefinition } from "../config-definition.js"; diff --git a/packages/unraid-shared/src/util/__tests__/config-file-handler.test.ts b/packages/unraid-shared/src/util/__tests__/config-file-handler.test.ts index 38a56f989e..17e19d5eb1 100644 --- a/packages/unraid-shared/src/util/__tests__/config-file-handler.test.ts +++ b/packages/unraid-shared/src/util/__tests__/config-file-handler.test.ts @@ -1,4 +1,4 @@ -import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import { expect, test, describe, beforeEach, afterEach } from "vitest"; import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; diff --git a/packages/unraid-shared/src/util/__tests__/key-order.test.ts b/packages/unraid-shared/src/util/__tests__/key-order.test.ts index 7c826b8d36..7ac59a577c 100644 --- a/packages/unraid-shared/src/util/__tests__/key-order.test.ts +++ b/packages/unraid-shared/src/util/__tests__/key-order.test.ts @@ -1,4 +1,4 @@ -import { expect, test, describe } from "bun:test"; +import { expect, test, describe } from "vitest"; import { getPrefixedSortedKeys } from "../key-order.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cda7b1e39f..c7bcc487a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,9 @@ importers: systeminformation: specifier: 5.27.7 version: 5.27.7 + undici: + specifier: ^7.13.0 + version: 7.13.0 unraid-api-plugin-connect: specifier: workspace:* version: link:../packages/unraid-api-plugin-connect @@ -615,6 +618,9 @@ importers: typescript: specifier: 5.9.2 version: 5.9.2 + undici: + specifier: ^7.13.0 + version: 7.13.0 vitest: specifier: 3.2.4 version: 3.2.4(@types/node@22.17.1)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1) @@ -706,6 +712,9 @@ importers: packages/unraid-shared: dependencies: + '@apollo/client': + specifier: 3.13.9 + version: 3.13.9(@types/react@19.0.8)(graphql-ws@6.0.6(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.3))(graphql@16.11.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(subscriptions-transport-ws@0.11.0(graphql@16.11.0)) '@nestjs/config': specifier: 4.0.2 version: 4.0.2(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2) @@ -715,6 +724,9 @@ importers: rxjs: specifier: 7.8.2 version: 7.8.2 + undici: + specifier: 7.13.0 + version: 7.13.0 devDependencies: '@graphql-tools/utils': specifier: 10.9.1 @@ -728,6 +740,9 @@ importers: '@nestjs/graphql': specifier: 13.1.0 version: 13.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.11.0)(reflect-metadata@0.1.14)(ts-morph@24.0.0) + '@nestjs/testing': + specifier: 11.1.5 + version: 11.1.5(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2)) '@types/bun': specifier: 1.2.20 version: 1.2.20(@types/react@19.0.8) @@ -737,6 +752,9 @@ importers: '@types/node': specifier: 22.17.1 version: 22.17.1 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 class-validator: specifier: 0.14.2 version: 0.14.2 @@ -746,6 +764,9 @@ importers: graphql-scalars: specifier: 1.24.2 version: 1.24.2(graphql@16.11.0) + graphql-ws: + specifier: 6.0.6 + version: 6.0.6(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.3) lodash-es: specifier: 4.17.21 version: 4.17.21 @@ -761,6 +782,12 @@ importers: typescript: specifier: 5.9.2 version: 5.9.2 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/node@22.17.1)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1) + ws: + specifier: ^8.18.3 + version: 8.18.3 plugin: dependencies: @@ -3704,6 +3731,19 @@ packages: '@nestjs/common': ^10.0.0 || ^11.0.0 '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/testing@11.1.5': + resolution: {integrity: sha512-ZYRYF750SefmuIo7ZqPlHDcin1OHh6My0OkOfGEFjrD9mJ0vMVIpwMTOOkpzCfCcpqUuxeHBuecpiIn+NLrQbQ==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/testing@11.1.6': resolution: {integrity: sha512-srYzzDNxGvVCe1j0SpTS9/ix75PKt6Sn6iMaH1rpJ6nj2g8vwNrhK0CoJJXvpCYgrnI+2WES2pprYnq8rAMYHA==} peerDependencies: @@ -12774,8 +12814,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@7.10.0: - resolution: {integrity: sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==} + undici@7.13.0: + resolution: {integrity: sha512-l+zSMssRqrzDcb3fjMkjjLGmuiiK2pMIcV++mJaAc9vhjSGpvM7h43QgP+OAMb1GImHmbPyG2tBXeuyG5iY4gA==} engines: {node: '>=20.18.1'} unenv@2.0.0-rc.18: @@ -16375,6 +16415,11 @@ snapshots: '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2) cron: 4.3.0 + '@nestjs/testing@11.1.5(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2) + tslib: 2.8.1 + '@nestjs/testing@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -23951,7 +23996,7 @@ snapshots: glob-to-regexp: 0.4.1 sharp: 0.33.5 stoppable: 1.1.0 - undici: 7.10.0 + undici: 7.13.0 workerd: 1.20250803.0 ws: 8.18.0 youch: 4.1.0-beta.10 @@ -26165,7 +26210,7 @@ snapshots: tinyexec: 1.0.1 tinyglobby: 0.2.14 ts-morph: 26.0.0 - undici: 7.10.0 + undici: 7.13.0 vue-metamorph: 3.3.3(eslint@9.33.0(jiti@2.5.1)) zod: 3.25.76 transitivePeerDependencies: @@ -27026,7 +27071,7 @@ snapshots: undici-types@6.21.0: {} - undici@7.10.0: {} + undici@7.13.0: {} unenv@2.0.0-rc.18: dependencies: @@ -27597,7 +27642,7 @@ snapshots: expect-type: 1.2.1 magic-string: 0.30.17 pathe: 2.0.3 - picomatch: 4.0.2 + picomatch: 4.0.3 std-env: 3.9.0 tinybench: 2.9.0 tinyexec: 0.3.2 diff --git a/unraid-ui/src/components/common/index.ts b/unraid-ui/src/components/common/index.ts new file mode 100644 index 0000000000..30e96a2a87 --- /dev/null +++ b/unraid-ui/src/components/common/index.ts @@ -0,0 +1,14 @@ +// Common component exports +export * from './accordion/index.js'; +export * from './badge/index.js'; +export * from './button/index.js'; +export * from './dialog/index.js'; +export * from './dropdown-menu/index.js'; +export * from './loading/index.js'; +export * from './popover/index.js'; +export * from './scroll-area/index.js'; +export * from './sheet/index.js'; +export * from './stepper/index.js'; +export * from './tabs/index.js'; +export * from './toast/index.js'; +export * from './tooltip/index.js'; diff --git a/unraid-ui/src/components/form/index.ts b/unraid-ui/src/components/form/index.ts new file mode 100644 index 0000000000..2a7102f856 --- /dev/null +++ b/unraid-ui/src/components/form/index.ts @@ -0,0 +1,8 @@ +// Form component exports +export * from './combobox/index.js'; +export * from './input/index.js'; +export * from './label/index.js'; +export * from './lightswitch/index.js'; +export * from './number/index.js'; +export * from './select/index.js'; +export * from './switch/index.js';