mirror of
https://github.com/unraid/api.git
synced 2026-02-04 23:19:04 -06:00
fix: refactor API client to support Unix socket connections (#1575)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "4.12.0",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
"plugins": ["unraid-api-plugin-connect"]
|
||||
}
|
||||
"version": "4.12.0",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
"plugins": ["unraid-api-plugin-connect"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
74
api/src/unraid-api/cli/cli.module.spec.ts
Normal file
74
api/src/unraid-api/cli/cli.module.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
203
api/src/unraid-api/cli/internal-client.service.spec.ts
Normal file
203
api/src/unraid-api/cli/internal-client.service.spec.ts
Normal file
@@ -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>(CliInternalClientService);
|
||||
clientFactory = module.get<InternalGraphQLClientFactory>(INTERNAL_CLIENT_SERVICE_TOKEN);
|
||||
adminKeyService = module.get<AdminKeyService>(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<any>((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<any>((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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<NormalizedCacheObject> | null = null;
|
||||
private creatingClient: Promise<ApolloClient<NormalizedCacheObject>> | 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<string | number | undefined>('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<ApolloClient<NormalizedCacheObject>> {
|
||||
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<ApolloClient<NormalizedCacheObject>> {
|
||||
// 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<ApolloClient<NormalizedCacheObject>>;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(InternalGraphQLClientFactory);
|
||||
socketConfig = module.get<SocketConfigService>(SocketConfigService);
|
||||
configService = module.get<ConfigService>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
api/src/unraid-api/shared/internal-graphql-client.factory.ts
Normal file
168
api/src/unraid-api/shared/internal-graphql-client.factory.ts
Normal file
@@ -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<string>;
|
||||
enableSubscriptions?: boolean;
|
||||
origin?: string;
|
||||
}): Promise<ApolloClient<NormalizedCacheObject>> {
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<NormalizedCacheObject> | null = null;
|
||||
private clientCreationPromise: Promise<ApolloClient<NormalizedCacheObject>> | 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<NormalizedCacheObject> | 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<string | number | undefined>('PORT');
|
||||
if (!port || port.toString().includes('.sock')) {
|
||||
return undefined;
|
||||
}
|
||||
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 (port !== this.PROD_NGINX_PORT) {
|
||||
return `${protocol}://127.0.0.1:${port}/graphql`;
|
||||
}
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
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() {
|
||||
public async getClient(): Promise<ApolloClient<NormalizedCacheObject>> {
|
||||
// If client already exists, return it
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
const localApiKey = await this.apiKeyService.getOrCreateLocalApiKey();
|
||||
this.client = this.createApiClient({ apiKey: localApiKey });
|
||||
return this.client;
|
||||
|
||||
// If client creation is in progress, wait for it
|
||||
if (this.clientCreationPromise) {
|
||||
return this.clientCreationPromise;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createClient(): Promise<ApolloClient<NormalizedCacheObject>> {
|
||||
// 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
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"forceConsistentCasingInFileNames": true },
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"moduleResolution": "nodenext",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"forceConsistentCasingInFileNames": true },
|
||||
"include": ["index.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
@@ -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. <unraid.net>",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ApolloClient<any>> {
|
||||
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<any> | null = null;
|
||||
private wsClient: any = null;
|
||||
|
||||
constructor(
|
||||
private readonly factory: MockGraphQLClientFactory,
|
||||
private readonly apiKeyProvider: () => Promise<string>,
|
||||
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<ApolloClient<any>> {
|
||||
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<string>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
95
packages/unraid-shared/src/services/socket-config.service.ts
Normal file
95
packages/unraid-shared/src/services/socket-config.service.ts
Normal file
@@ -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<string>('PORT', '/var/run/unraid-api.sock');
|
||||
return port.includes('.sock');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the socket path from config
|
||||
*/
|
||||
getSocketPath(): string {
|
||||
return this.configService.get<string>('PORT', '/var/run/unraid-api.sock');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the numeric port if not running on socket
|
||||
*/
|
||||
getNumericPort(): number | undefined {
|
||||
const port = this.configService.get<string>('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');
|
||||
}
|
||||
}
|
||||
259
packages/unraid-shared/src/services/ws-unix-socket-test.spec.ts
Normal file
259
packages/unraid-shared/src/services/ws-unix-socket-test.spec.ts
Normal file
@@ -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<typeof createServer>;
|
||||
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<void>((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<void>[] = [];
|
||||
for (const client of wss.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
closePromises.push(
|
||||
new Promise<void>((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<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// Close HTTP server
|
||||
if (server && server.listening) {
|
||||
await new Promise<void>((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<boolean>((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<any>((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<void>((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<void>((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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string>;
|
||||
enableSubscriptions?: boolean;
|
||||
origin?: string;
|
||||
}): Promise<ApolloClient<NormalizedCacheObject>>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, describe } from "bun:test";
|
||||
import { expect, test, describe } from "vitest";
|
||||
|
||||
import { getPrefixedSortedKeys } from "../key-order.js";
|
||||
|
||||
|
||||
57
pnpm-lock.yaml
generated
57
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
14
unraid-ui/src/components/common/index.ts
Normal file
14
unraid-ui/src/components/common/index.ts
Normal file
@@ -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';
|
||||
8
unraid-ui/src/components/form/index.ts
Normal file
8
unraid-ui/src/components/form/index.ts
Normal file
@@ -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';
|
||||
Reference in New Issue
Block a user