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
2 changes: 1 addition & 1 deletion 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 { bufferTime, debounceTime } from 'rxjs/operators';
import { bufferTime } from 'rxjs/operators';

import { API_VERSION } from '@app/environment.js';
import { ApiStateConfig } from '@app/unraid-api/config/factory/api-state.model.js';
Expand Down
4 changes: 4 additions & 0 deletions packages/unraid-api-plugin-connect/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
default:
@just --list

# Watch for changes in src files and run clean + build
watch:
watchexec -r -e ts,tsx -w src -- pnpm build

# Count TypeScript lines in src directory, excluding test and generated files
count-lines:
#!/usr/bin/env bash
Expand Down
2 changes: 2 additions & 0 deletions packages/unraid-api-plugin-connect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"description": "Unraid Connect plugin for Unraid API",
"devDependencies": {
"@apollo/client": "^3.11.8",
"@faker-js/faker": "^9.8.0",
"@graphql-codegen/cli": "^5.0.3",
"@graphql-typed-document-node/core": "^3.2.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
Expand All @@ -46,6 +47,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"execa": "^9.5.1",
"fast-check": "^4.1.1",
"got": "^14.4.6",
"graphql": "^16.9.0",
"graphql-scalars": "^1.23.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { UsePipes, ValidationPipe } from '@nestjs/common';
import { registerAs } from '@nestjs/config';
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import { ValidateIf } from 'class-validator';

import { URL_TYPE } from '@unraid/shared/network.model.js';
import { plainToInstance } from 'class-transformer';
Expand Down Expand Up @@ -58,9 +59,11 @@ export class MyServersConfig {
localApiKey!: string;

// User Information
@Field(() => String)
@Field(() => String, { nullable: true })
@IsOptional()
@ValidateIf((o) => o.email !== undefined && o.email !== null && o.email !== '')
@IsEmail()
email!: string;
email?: string | null;

@Field(() => String)
@IsString()
Expand Down Expand Up @@ -194,7 +197,6 @@ export const emptyMyServersConfig = (): MyServersConfig => ({
upnpEnabled: false,
apikey: '',
localApiKey: '',
email: '',
username: '',
avatar: '',
regWizTime: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,14 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
* @param config - The config object to validate.
* @returns The validated config instance.
*/
private async validate(config: object) {
public async validate(config: object) {
let instance: MyServersConfig;
if (config instanceof MyServersConfig) {
instance = config;
} else {
instance = plainToInstance(MyServersConfig, config, { enableImplicitConversion: true });
instance = plainToInstance(MyServersConfig, config, {
enableImplicitConversion: true,
});
}
await validateOrReject(instance);
return instance;
Expand All @@ -103,7 +105,7 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy {
this.logger.verbose(`Config loaded from ${this.configPath}`);
return true;
} catch (error) {
this.logger.warn('Error loading config:', error);
this.logger.warn(error, 'Error loading config');
}

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import type { OutgoingHttpHeaders } from 'node:http2';

import { Subscription } from 'rxjs';
import { debounceTime, filter } from 'rxjs/operators';
import { bufferTime, filter } from 'rxjs/operators';

import { EVENTS } from '../helper/nest-tokens.js';
import { ConnectionMetadata, MinigraphStatus, MyServersConfig } from '../model/connect-config.model.js';
Expand Down Expand Up @@ -83,7 +83,7 @@ export class MothershipConnectionService implements OnModuleInit, OnModuleDestro
this.identitySubscription = this.configService.changes$
.pipe(
filter((change) => Object.values(this.configKeys).includes(change.path)),
debounceTime(25)
bufferTime(25)
)
.subscribe({
next: () => {
Expand Down
244 changes: 216 additions & 28 deletions packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ConfigService } from '@nestjs/config';

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { faker } from '@faker-js/faker';
import * as fc from 'fast-check';

import { ConfigType } from '../model/connect-config.model.js';
import { ConfigType, DynamicRemoteAccessType } from '../model/connect-config.model.js';
import { ConnectConfigPersister } from '../service/config.persistence.js';

describe('ConnectConfigPersister', () => {
Expand All @@ -21,7 +23,7 @@ describe('ConnectConfigPersister', () => {
},
} as any;

service = new ConnectConfigPersister(configService);
service = new ConnectConfigPersister(configService as any);
});

describe('parseLegacyConfig', () => {
Expand Down Expand Up @@ -59,6 +61,80 @@ ssoSubIds="user1,user2"
expect(result.remote.upnpEnabled).toBe('no');
expect(result.remote.ssoSubIds).toBe('user1,user2');
});

it('should parse various INI configs with different boolean values using fast-check', () => {
fc.assert(
fc.property(
fc.boolean(),
fc.boolean(),
fc.constantFrom('yes', 'no'),
fc.integer({ min: 1000, max: 9999 }),
fc.constant(null).map(() => faker.internet.email()),
fc.constant(null).map(() => faker.internet.username()),
(wanaccess, upnpEnabled, sandbox, port, email, username) => {
const iniContent = `
[api]
version="6.12.0"
extraOrigins=""
[local]
sandbox="${sandbox}"
[remote]
wanaccess="${wanaccess ? 'yes' : 'no'}"
wanport="${port}"
upnpEnabled="${upnpEnabled ? 'yes' : 'no'}"
apikey="unraid_test_key"
localApiKey="test_local_key"
email="${email}"
username="${username}"
avatar=""
regWizTime=""
accesstoken=""
idtoken=""
refreshtoken=""
dynamicRemoteAccessType="DISABLED"
ssoSubIds=""
`.trim();

const result = service.parseLegacyConfig(iniContent);

expect(result.api.version).toBe('6.12.0');
expect(result.local.sandbox).toBe(sandbox);
expect(result.remote.wanaccess).toBe(wanaccess ? 'yes' : 'no');
expect(result.remote.wanport).toBe(port.toString());
expect(result.remote.upnpEnabled).toBe(upnpEnabled ? 'yes' : 'no');
expect(result.remote.email).toBe(email);
expect(result.remote.username).toBe(username);
}
),
{ numRuns: 25 }
);
});

it('should handle empty sections gracefully', () => {
const iniContent = `
[api]
version="6.12.0"
[local]
[remote]
wanaccess="no"
wanport="0"
upnpEnabled="no"
apikey="test"
localApiKey="test"
email="test@example.com"
username="test"
avatar=""
regWizTime=""
dynamicRemoteAccessType="DISABLED"
`.trim();

const result = service.parseLegacyConfig(iniContent);

expect(result.api.version).toBe('6.12.0');
expect(result.local).toBeDefined();
expect(result.remote).toBeDefined();
expect(result.remote.wanaccess).toBe('no');
});
});

describe('convertLegacyConfig', () => {
Expand Down Expand Up @@ -269,31 +345,6 @@ ssoSubIds="user1,user2"
expect(result.dynamicRemoteAccessType).toBe('UPNP');
});

it('should validate the migrated config and reject invalid email', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'yes',
wanport: '3333',
upnpEnabled: 'no',
apikey: 'unraid_test_key',
localApiKey: 'test_local_key',
email: 'invalid-email',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;

await expect(service.convertLegacyConfig(legacyConfig)).rejects.toThrow();
});

it('should handle integration of parsing and conversion together', async () => {
const iniContent = `
[api]
Expand Down Expand Up @@ -324,10 +375,147 @@ ssoSubIds="sub1,sub2"
// Convert to new format
const result = await service.convertLegacyConfig(legacyConfig);

// Verify the end-to-end conversion (extraOrigins and ssoSubIds are now handled by API config)
// Verify the end-to-end conversion
expect(result.wanaccess).toBe(true);
expect(result.wanport).toBe(8080);
expect(result.upnpEnabled).toBe(true);
});

it('should handle various boolean migrations consistently using property-based testing', () => {
fc.assert(
fc.asyncProperty(
fc.boolean(),
fc.boolean(),
fc.integer({ min: 1000, max: 65535 }),
fc.constant(null).map(() => faker.internet.email()),
fc.constant(null).map(() => faker.internet.username()),
fc.constant(null).map(() => faker.string.alphanumeric({ length: 32 })),
async (wanaccess, upnpEnabled, port, email, username, apikey) => {
const legacyConfig = {
api: { version: faker.system.semver(), extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: wanaccess ? 'yes' : 'no',
wanport: port.toString(),
upnpEnabled: upnpEnabled ? 'yes' : 'no',
apikey: `unraid_${apikey}`,
localApiKey: faker.string.alphanumeric({ length: 64 }),
email,
username,
avatar: faker.image.avatarGitHub(),
regWizTime: faker.date.past().toISOString(),
accesstoken: faker.string.alphanumeric({ length: 64 }),
idtoken: faker.string.alphanumeric({ length: 64 }),
refreshtoken: faker.string.alphanumeric({ length: 64 }),
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;

const result = await service.convertLegacyConfig(legacyConfig);

// Test migration logic, not validation
expect(result.wanaccess).toBe(wanaccess);
expect(result.upnpEnabled).toBe(upnpEnabled);
expect(result.wanport).toBe(port);
expect(typeof result.wanport).toBe('number');
expect(result.email).toBe(email);
expect(result.username).toBe(username);
expect(result.apikey).toBe(`unraid_${apikey}`);
}
),
{ numRuns: 20 }
);
});

it('should handle edge cases in port conversion', () => {
fc.assert(
fc.asyncProperty(
fc.integer({ min: 0, max: 65535 }),
async (port) => {
const legacyConfig = {
api: { version: '6.12.0', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'no',
wanport: port.toString(),
upnpEnabled: 'no',
apikey: 'unraid_test',
localApiKey: 'test_local',
email: 'test@example.com',
username: faker.internet.username(),
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;

const result = await service.convertLegacyConfig(legacyConfig);

// Test port conversion logic
expect(result.wanport).toBe(port);
expect(typeof result.wanport).toBe('number');
}
),
{ numRuns: 15 }
);
});

it('should handle empty port values', async () => {
const legacyConfig = {
api: { version: '6.12.0', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'no',
wanport: '',
upnpEnabled: 'no',
apikey: 'unraid_test',
localApiKey: 'test_local',
email: 'test@example.com',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;

const result = await service.convertLegacyConfig(legacyConfig);

expect(result.wanport).toBe(0);
expect(typeof result.wanport).toBe('number');
});

it('should reject invalid configurations during migration', async () => {
const legacyConfig = {
api: { version: '4.8.0+9485809', extraOrigins: '' },
local: { sandbox: 'no' },
remote: {
wanaccess: 'yes',
wanport: '3333',
upnpEnabled: 'no',
apikey: 'unraid_test_key',
localApiKey: 'test_local_key',
email: 'invalid-email',
username: 'testuser',
avatar: '',
regWizTime: '',
accesstoken: '',
idtoken: '',
refreshtoken: '',
dynamicRemoteAccessType: 'DISABLED',
ssoSubIds: '',
},
} as any;

await expect(service.convertLegacyConfig(legacyConfig)).rejects.toThrow();
});
});
});
Loading
Loading