Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ jobs:
- name: PNPM Install
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm run lint

- name: Setup libvirt
run: |
# Create required groups (if they don't already exist)
Expand Down Expand Up @@ -111,11 +114,41 @@ jobs:
# Verify libvirt is running using sudo to bypass group membership delays
sudo virsh list --all || true

- name: Lint
run: pnpm run lint
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Test
run: pnpm run coverage
- name: Run Tests Concurrently
run: |
set -e

# Run all tests in parallel with labeled output
echo "🚀 Starting API coverage tests..."
pnpm run coverage > api-test.log 2>&1 &
API_PID=$!

echo "🚀 Starting Connect plugin tests..."
(cd ../packages/unraid-api-plugin-connect && pnpm test) > connect-test.log 2>&1 &
CONNECT_PID=$!

echo "🚀 Starting Shared package tests..."
(cd ../packages/unraid-shared && pnpm test) > shared-test.log 2>&1 &
SHARED_PID=$!

# Wait for all processes and capture exit codes
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }

# Display all outputs
echo "📋 API Test Results:" && cat api-test.log
echo "📋 Connect Plugin Test Results:" && cat connect-test.log
echo "📋 Shared Package Test Results:" && cat shared-test.log

# Exit with error if any test failed
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 ]]; then
exit 1
fi
Comment on lines +121 to +151
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Hard-fail safety & trailing-space lint

  1. Robust shell flags
    Use set -euo pipefail instead of plain set -e to catch:
    • unset variables ($API_EXIT et al. before defaulting)
    • errors in pipelines.

  2. Quoting & paths
    The cd ../packages/... hops rely on api being the CWD. For clarity & resilience, prefer ${{ github.workspace }}/packages/....

  3. YAML-lint errors
    Static analysis flags trailing spaces on lines 124, 129, 133, 137, 142, 145, 147. Remove them to keep the workflow YAML-lint-clean.

Example patch (trimmed for brevity):

-          set -e
+          set -euo pipefail
...
-          (cd ../packages/unraid-api-plugin-connect && pnpm test) > connect-test.log 2>&1 &
+          (cd "${{ github.workspace }}/packages/unraid-api-plugin-connect" && pnpm test) > connect-test.log 2>&1 &

…and delete the offending trailing spaces.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Run Tests Concurrently
run: |
set -e
# Run all tests in parallel with labeled output
echo "🚀 Starting API coverage tests..."
pnpm run coverage > api-test.log 2>&1 &
API_PID=$!
echo "🚀 Starting Connect plugin tests..."
(cd ../packages/unraid-api-plugin-connect && pnpm test) > connect-test.log 2>&1 &
CONNECT_PID=$!
echo "🚀 Starting Shared package tests..."
(cd ../packages/unraid-shared && pnpm test) > shared-test.log 2>&1 &
SHARED_PID=$!
# Wait for all processes and capture exit codes
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }
# Display all outputs
echo "📋 API Test Results:" && cat api-test.log
echo "📋 Connect Plugin Test Results:" && cat connect-test.log
echo "📋 Shared Package Test Results:" && cat shared-test.log
# Exit with error if any test failed
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 ]]; then
exit 1
fi
- name: Run Tests Concurrently
run: |
- set -e
+ set -euo pipefail
# Run all tests in parallel with labeled output
echo "🚀 Starting API coverage tests..."
pnpm run coverage > api-test.log 2>&1 &
API_PID=$!
echo "🚀 Starting Connect plugin tests..."
- (cd ../packages/unraid-api-plugin-connect && pnpm test) > connect-test.log 2>&1 &
+ (cd "${{ github.workspace }}/packages/unraid-api-plugin-connect" && pnpm test) > connect-test.log 2>&1 &
CONNECT_PID=$!
echo "🚀 Starting Shared package tests..."
(cd ../packages/unraid-shared && pnpm test) > shared-test.log 2>&1 &
SHARED_PID=$!
# Wait for all processes and capture exit codes
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }
# Display all outputs
echo "📋 API Test Results:" && cat api-test.log
echo "📋 Connect Plugin Test Results:" && cat connect-test.log
echo "📋 Shared Package Test Results:" && cat shared-test.log
# Exit with error if any test failed
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 ]]; then
exit 1
fi
🧰 Tools
🪛 YAMLlint (1.37.1)

[error] 124-124: trailing spaces

(trailing-spaces)


[error] 129-129: trailing spaces

(trailing-spaces)


[error] 133-133: trailing spaces

(trailing-spaces)


[error] 137-137: trailing spaces

(trailing-spaces)


[error] 142-142: trailing spaces

(trailing-spaces)


[error] 145-145: trailing spaces

(trailing-spaces)


[error] 147-147: trailing spaces

(trailing-spaces)

🤖 Prompt for AI Agents
In .github/workflows/main.yml lines 121 to 151, improve shell safety by
replacing 'set -e' with 'set -euo pipefail' to catch unset variables and
pipeline errors. Update all relative 'cd ../packages/...' commands to use the
absolute path '${{ github.workspace }}/packages/...' for clarity and robustness.
Remove all trailing spaces on lines 124, 129, 133, 137, 142, 145, and 147 to fix
YAML lint errors and ensure clean formatting.


build-api:
name: Build API
Expand Down
137 changes: 137 additions & 0 deletions api/src/__test__/config/api-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { ConfigService } from '@nestjs/config';

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

import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';

describe('ApiConfigPersistence', () => {
let service: ApiConfigPersistence;
let configService: ConfigService;
let persistenceHelper: ConfigPersistenceHelper;

beforeEach(() => {
configService = {
get: vi.fn(),
set: vi.fn(),
} as any;

persistenceHelper = {} as ConfigPersistenceHelper;
service = new ApiConfigPersistence(configService, persistenceHelper);
});

describe('convertLegacyConfig', () => {
it('should migrate sandbox from string "yes" to boolean true', () => {
const legacyConfig = {
local: { sandbox: 'yes' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.sandbox).toBe(true);
});

it('should migrate sandbox from string "no" to boolean false', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.sandbox).toBe(false);
});

it('should migrate extraOrigins from comma-separated string to array', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: 'https://example.com,https://test.com' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.extraOrigins).toEqual(['https://example.com', 'https://test.com']);
});

it('should filter out non-HTTP origins from extraOrigins', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: {
extraOrigins: 'https://example.com,invalid-origin,http://test.com,ftp://bad.com',
},
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.extraOrigins).toEqual(['https://example.com', 'http://test.com']);
});

it('should handle empty extraOrigins string', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.extraOrigins).toEqual([]);
});

it('should migrate ssoSubIds from comma-separated string to array', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: 'user1,user2,user3' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.ssoSubIds).toEqual(['user1', 'user2', 'user3']);
});

it('should handle empty ssoSubIds string', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.ssoSubIds).toEqual([]);
});

it('should handle undefined config sections', () => {
const legacyConfig = {};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.sandbox).toBe(false);
expect(result.extraOrigins).toEqual([]);
expect(result.ssoSubIds).toEqual([]);
});

it('should handle complete migration with all fields', () => {
const legacyConfig = {
local: { sandbox: 'yes' },
api: { extraOrigins: 'https://app1.example.com,https://app2.example.com' },
remote: { ssoSubIds: 'sub1,sub2,sub3' },
};

const result = service.convertLegacyConfig(legacyConfig);

expect(result.sandbox).toBe(true);
expect(result.extraOrigins).toEqual([
'https://app1.example.com',
'https://app2.example.com',
]);
expect(result.ssoSubIds).toEqual(['sub1', 'sub2', 'sub3']);
});
});
});
39 changes: 25 additions & 14 deletions api/src/unraid-api/config/api-config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ConfigService, registerAs } from '@nestjs/config';
import type { ApiConfig } from '@unraid/shared/services/api-config.js';
import { csvStringToArray } from '@unraid/shared/util/data.js';
import { fileExists } from '@unraid/shared/util/file.js';
import { debounceTime } from 'rxjs/operators';
import { bufferTime, debounceTime } from 'rxjs/operators';

import { API_VERSION } from '@app/environment.js';
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
Expand Down Expand Up @@ -56,7 +56,7 @@ export const loadApiConfig = async () => {
export const apiConfig = registerAs<ApiConfig>('api', loadApiConfig);

@Injectable()
class ApiConfigPersistence {
export class ApiConfigPersistence {
private configModel: ApiStateConfig<ApiConfig>;
private logger = new Logger(ApiConfigPersistence.name);
get filePath() {
Expand Down Expand Up @@ -85,10 +85,10 @@ class ApiConfigPersistence {
this.migrateFromMyServersConfig();
}
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
this.configService.changes$.pipe(debounceTime(25)).subscribe({
next: async ({ newValue, oldValue, path }) => {
if (path.startsWith('api')) {
this.logger.verbose(`Config changed: ${path} from ${oldValue} to ${newValue}`);
this.configService.changes$.pipe(bufferTime(25)).subscribe({
next: async (changes) => {
if (changes.some((change) => change.path.startsWith('api'))) {
this.logger.verbose(`API Config changed ${JSON.stringify(changes)}`);
await this.persistenceHelper.persistIfChanged(this.filePath, this.config);
}
},
Expand All @@ -98,15 +98,26 @@ class ApiConfigPersistence {
});
}

private migrateFromMyServersConfig() {
const { local, api, remote } = this.configService.get('store.config', {});
const sandbox = local?.sandbox;
const extraOrigins = csvStringToArray(api?.extraOrigins ?? '').filter(
(origin) => origin.startsWith('http://') || origin.startsWith('https://')
);
const ssoSubIds = csvStringToArray(remote?.ssoSubIds ?? '');
convertLegacyConfig(
config?: Partial<{
local: { sandbox?: string };
api: { extraOrigins?: string };
remote: { ssoSubIds?: string };
}>
) {
return {
sandbox: config?.local?.sandbox === 'yes',
extraOrigins: csvStringToArray(config?.api?.extraOrigins ?? '').filter(
(origin) => origin.startsWith('http://') || origin.startsWith('https://')
),
ssoSubIds: csvStringToArray(config?.remote?.ssoSubIds ?? ''),
};
}

this.configService.set('api.sandbox', sandbox === 'yes');
migrateFromMyServersConfig() {
const legacyConfig = this.configService.get('store.config', {});
const { sandbox, extraOrigins, ssoSubIds } = this.convertLegacyConfig(legacyConfig);
this.configService.set('api.sandbox', sandbox);
this.configService.set('api.extraOrigins', extraOrigins);
this.configService.set('api.ssoSubIds', ssoSubIds);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/unraid-api-plugin-connect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"readme.md"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "vitest",
"clean": "rimraf dist",
"build": "tsc",
"prepare": "npm run build",
Expand Down Expand Up @@ -60,7 +60,7 @@
"rxjs": "^7.8.2",
"type-fest": "^4.37.0",
"typescript": "^5.8.2",
"vitest": "^3.1.4",
"vitest": "^3.2.4",
"ws": "^8.18.0",
"zen-observable-ts": "^1.1.0"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,6 @@ export class MyServersConfig {
@IsEnum(DynamicRemoteAccessType)
dynamicRemoteAccessType!: DynamicRemoteAccessType;

@Field(() => [String])
@IsArray()
@Matches(/^[a-zA-Z0-9-]+$/, {
each: true,
message: 'Each SSO ID must be alphanumeric with dashes',
})
ssoSubIds!: string[];

// Connection Status
// @Field(() => MinigraphStatus)
// @IsEnum(MinigraphStatus)
Expand Down Expand Up @@ -223,7 +215,6 @@ export const emptyMyServersConfig = (): MyServersConfig => ({
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
ssoSubIds: [],
});

export const configFeature = registerAs<ConnectConfig>('connect', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { existsSync, readFileSync } from 'fs';
import { writeFile } from 'fs/promises';
import path from 'path';

import { csvStringToArray } from '@unraid/shared/util/data.js';
import { plainToInstance } from 'class-transformer';
import { validateOrReject } from 'class-validator';
import { parse as parseIni } from 'ini';
Expand Down Expand Up @@ -137,25 +136,30 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async migrateLegacyConfig() {
const legacyConfig = await this.parseLegacyConfig();
this.configService.set('connect.config', legacyConfig);
private async migrateLegacyConfig(filePath?: string) {
const myServersCfgFile = await this.readLegacyConfig(filePath);
const legacyConfig = this.parseLegacyConfig(myServersCfgFile);
const newConfig = await this.convertLegacyConfig(legacyConfig);
this.configService.set('connect.config', newConfig);
}

/**
* Parse the legacy config file and return a new config object.
* Transform the legacy config object to the new config format.
* @param filePath - The path to the legacy config file.
* @returns A new config object.
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async parseLegacyConfig(filePath?: string): Promise<MyServersConfig> {
const config = await this.getLegacyConfig(filePath);
public async convertLegacyConfig(config:LegacyConfig): Promise<MyServersConfig> {
return this.validate({
...config.api,
...config.local,
...config.remote,
extraOrigins: csvStringToArray(config.api.extraOrigins),
// Convert string yes/no to boolean
wanaccess: config.remote.wanaccess === 'yes',
upnpEnabled: config.remote.upnpEnabled === 'yes',
// Convert string port to number
wanport: config.remote.wanport ? parseInt(config.remote.wanport, 10) : 0,
});
}

Expand All @@ -166,7 +170,7 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
* @throws {Error} - If the legacy config file does not exist.
* @throws {Error} - If the legacy config file is not parse-able.
*/
private async getLegacyConfig(filePath?: string) {
private async readLegacyConfig(filePath?: string) {
filePath ??= this.configService.get(
'PATHS_MY_SERVERS_CONFIG',
'/boot/config/plugins/dynamix.my.servers/myservers.cfg'
Expand All @@ -177,6 +181,10 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
if (!existsSync(filePath)) {
throw new Error(`Legacy config file does not exist: ${filePath}`);
}
return parseIni(readFileSync(filePath, 'utf8')) as LegacyConfig;
return readFileSync(filePath, 'utf8');
}

public parseLegacyConfig(iniFileContent: string): LegacyConfig {
return parseIni(iniFileContent) as LegacyConfig;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class ConnectConfigService {
*/
resetUser() {
// overwrite identity fields, but retain destructured fields
const { wanaccess, wanport, upnpEnabled, ssoSubIds, ...identity } = emptyMyServersConfig();
const { wanaccess, wanport, upnpEnabled, ...identity } = emptyMyServersConfig();
this.configService.set(this.configKey, {
...this.getConfig(),
...identity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ export class UrlResolverService {
});

// Now Process the FQDN Urls
nginx.fqdnUrls.forEach((fqdnUrl: FqdnEntry) => {
nginx.fqdnUrls?.forEach((fqdnUrl: FqdnEntry) => {
doSafely(() => {
const urlType = this.getUrlTypeFromFqdn(fqdnUrl.interface);
const fqdnUrlToUse = this.getUrlForField({
Expand Down
Loading
Loading