From b2d4c7d2762d04d61b5a0c23df5adfa5906e9626 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 1 Jul 2025 10:42:23 -0400 Subject: [PATCH 1/7] refactor: rm unused oauth token fiels in connect model --- .../src/model/connect-config.model.ts | 16 ---------------- .../src/test/config.persistence.test.ts | 3 --- 2 files changed, 19 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/model/connect-config.model.ts b/packages/unraid-api-plugin-connect/src/model/connect-config.model.ts index fdbb33bf92..32f2d80eda 100644 --- a/packages/unraid-api-plugin-connect/src/model/connect-config.model.ts +++ b/packages/unraid-api-plugin-connect/src/model/connect-config.model.ts @@ -74,19 +74,6 @@ export class MyServersConfig { @IsString() regWizTime!: string; - // Authentication Tokens - @Field(() => String) - @IsString() - accesstoken!: string; - - @Field(() => String) - @IsString() - idtoken!: string; - - @Field(() => String) - @IsString() - refreshtoken!: string; - // Remote Access Settings @Field(() => DynamicRemoteAccessType) @IsEnum(DynamicRemoteAccessType) @@ -211,9 +198,6 @@ export const emptyMyServersConfig = (): MyServersConfig => ({ username: '', avatar: '', regWizTime: '', - accesstoken: '', - idtoken: '', - refreshtoken: '', dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED, }); diff --git a/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts b/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts index 3fcba266c9..bbaec1004d 100644 --- a/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts +++ b/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts @@ -263,9 +263,6 @@ ssoSubIds="user1,user2" expect(result.username).toBe('testuser'); expect(result.avatar).toBe('https://avatar.url'); expect(result.regWizTime).toBe('2023-01-01T00:00:00Z'); - expect(result.accesstoken).toBe('access_token_value'); - expect(result.idtoken).toBe('id_token_value'); - expect(result.refreshtoken).toBe('refresh_token_value'); expect(result.dynamicRemoteAccessType).toBe('UPNP'); }); From 68c6fbefb25acd708623568762d0eb8d4d54c3c3 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 1 Jul 2025 10:53:37 -0400 Subject: [PATCH 2/7] rm oauth fields from connect sign in input --- api/generated-schema.graphql | 9 ---- .../src/model/connect.model.ts | 15 ------ .../src/resolver/connect.resolver.ts | 1 - .../src/service/config.persistence.ts | 6 ++- .../src/service/connect-api-key.service.ts | 2 +- .../src/service/connect-settings.service.ts | 2 +- .../src/test/config.persistence.test.ts | 49 ++++++++++--------- .../src/test/url-resolver.service.test.ts | 8 ++- web/composables/gql/graphql.ts | 12 ++--- 9 files changed, 41 insertions(+), 63 deletions(-) diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index c50e5705b9..ff22008e0d 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1778,17 +1778,8 @@ input ConnectSignInInput { """The API key for authentication""" apiKey: String! - """The ID token for authentication""" - idToken: String - """User information for the sign-in""" userInfo: ConnectUserInfoInput - - """The access token for authentication""" - accessToken: String - - """The refresh token for authentication""" - refreshToken: String } input ConnectUserInfoInput { diff --git a/packages/unraid-api-plugin-connect/src/model/connect.model.ts b/packages/unraid-api-plugin-connect/src/model/connect.model.ts index 72ebf177a2..964b9b59ab 100644 --- a/packages/unraid-api-plugin-connect/src/model/connect.model.ts +++ b/packages/unraid-api-plugin-connect/src/model/connect.model.ts @@ -93,11 +93,6 @@ export class ConnectSignInInput { @MinLength(5) apiKey!: string; - @Field(() => String, { nullable: true, description: 'The ID token for authentication' }) - @IsString() - @IsOptional() - idToken?: string; - @Field(() => ConnectUserInfoInput, { nullable: true, description: 'User information for the sign-in', @@ -105,16 +100,6 @@ export class ConnectSignInInput { @ValidateNested() @IsOptional() userInfo?: ConnectUserInfoInput; - - @Field(() => String, { nullable: true, description: 'The access token for authentication' }) - @IsString() - @IsOptional() - accessToken?: string; - - @Field(() => String, { nullable: true, description: 'The refresh token for authentication' }) - @IsString() - @IsOptional() - refreshToken?: string; } @InputType() diff --git a/packages/unraid-api-plugin-connect/src/resolver/connect.resolver.ts b/packages/unraid-api-plugin-connect/src/resolver/connect.resolver.ts index 1fad61082c..b632ecc93d 100644 --- a/packages/unraid-api-plugin-connect/src/resolver/connect.resolver.ts +++ b/packages/unraid-api-plugin-connect/src/resolver/connect.resolver.ts @@ -43,5 +43,4 @@ export class ConnectResolver { public async settings(): Promise { return {} as ConnectSettings; } - } diff --git a/packages/unraid-api-plugin-connect/src/service/config.persistence.ts b/packages/unraid-api-plugin-connect/src/service/config.persistence.ts index 51a4dcd26e..0daa752391 100644 --- a/packages/unraid-api-plugin-connect/src/service/config.persistence.ts +++ b/packages/unraid-api-plugin-connect/src/service/config.persistence.ts @@ -34,7 +34,9 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy { // Persist changes to the config. this.configService.changes$.pipe(bufferTime(25)).subscribe({ next: async (changes) => { - const connectConfigChanged = changes.some(({ path }) => path.startsWith('connect.config')); + const connectConfigChanged = changes.some(({ path }) => + path.startsWith('connect.config') + ); if (connectConfigChanged) { await this.persist(); } @@ -150,7 +152,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. */ - public async convertLegacyConfig(config:LegacyConfig): Promise { + public async convertLegacyConfig(config: LegacyConfig): Promise { return this.validate({ ...config.api, ...config.local, diff --git a/packages/unraid-api-plugin-connect/src/service/connect-api-key.service.ts b/packages/unraid-api-plugin-connect/src/service/connect-api-key.service.ts index cb3859ce7d..1f96e3ebcd 100644 --- a/packages/unraid-api-plugin-connect/src/service/connect-api-key.service.ts +++ b/packages/unraid-api-plugin-connect/src/service/connect-api-key.service.ts @@ -14,7 +14,7 @@ export class ConnectApiKeyService implements ApiKeyService { constructor( @Inject(API_KEY_SERVICE_TOKEN) - private readonly apiKeyService: ApiKeyService, + private readonly apiKeyService: ApiKeyService ) {} async findById(id: string): Promise { diff --git a/packages/unraid-api-plugin-connect/src/service/connect-settings.service.ts b/packages/unraid-api-plugin-connect/src/service/connect-settings.service.ts index ec3ce788a6..42acff5e65 100644 --- a/packages/unraid-api-plugin-connect/src/service/connect-settings.service.ts +++ b/packages/unraid-api-plugin-connect/src/service/connect-settings.service.ts @@ -150,7 +150,7 @@ export class ConnectSettingsService { async signIn(input: ConnectSignInInput) { const status = this.configService.get('store.emhttp.status'); if (status === 'LOADED') { - const userInfo = input.idToken ? decodeJwt(input.idToken) : (input.userInfo ?? null); + const userInfo = input.userInfo ?? null; if ( !userInfo || diff --git a/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts b/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts index bbaec1004d..f7d606cada 100644 --- a/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts +++ b/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts @@ -1,8 +1,9 @@ import { ConfigService } from '@nestjs/config'; + import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ConnectConfigPersister } from '../service/config.persistence.js'; import { ConfigType } from '../model/connect-config.model.js'; +import { ConnectConfigPersister } from '../service/config.persistence.js'; describe('ConnectConfigPersister', () => { let service: ConnectConfigPersister; @@ -79,8 +80,8 @@ ssoSubIds="user1,user2" idtoken: '', refreshtoken: '', dynamicRemoteAccessType: 'DISABLED', - ssoSubIds: '' - } + ssoSubIds: '', + }, } as any; const result = await service.convertLegacyConfig(legacyConfig); @@ -106,8 +107,8 @@ ssoSubIds="user1,user2" idtoken: '', refreshtoken: '', dynamicRemoteAccessType: 'DISABLED', - ssoSubIds: '' - } + ssoSubIds: '', + }, } as any; const result = await service.convertLegacyConfig(legacyConfig); @@ -133,8 +134,8 @@ ssoSubIds="user1,user2" idtoken: '', refreshtoken: '', dynamicRemoteAccessType: 'DISABLED', - ssoSubIds: '' - } + ssoSubIds: '', + }, } as any; const result = await service.convertLegacyConfig(legacyConfig); @@ -161,8 +162,8 @@ ssoSubIds="user1,user2" idtoken: '', refreshtoken: '', dynamicRemoteAccessType: 'DISABLED', - ssoSubIds: '' - } + ssoSubIds: '', + }, } as any; const result = await service.convertLegacyConfig(legacyConfig); @@ -188,8 +189,8 @@ ssoSubIds="user1,user2" idtoken: '', refreshtoken: '', dynamicRemoteAccessType: 'DISABLED', - ssoSubIds: '' - } + ssoSubIds: '', + }, } as any; const result = await service.convertLegacyConfig(legacyConfig); @@ -215,21 +216,23 @@ ssoSubIds="user1,user2" idtoken: '', refreshtoken: '', dynamicRemoteAccessType: 'DISABLED', - ssoSubIds: '' - } + ssoSubIds: '', + }, } as any; const result = await service.convertLegacyConfig(legacyConfig); - expect(result.apikey).toBe('unraid_sfHboeSNzTzx24816QBssqi0A3nIT0f4Xg4c9Ht49WQfQKLMojU81Sb3f'); - expect(result.localApiKey).toBe('101d204832d24fc7e5d387f6fce47067ba230f8aa0ac3bcc6c12a415aa27dbd9'); + expect(result.apikey).toBe( + 'unraid_sfHboeSNzTzx24816QBssqi0A3nIT0f4Xg4c9Ht49WQfQKLMojU81Sb3f' + ); + expect(result.localApiKey).toBe( + '101d204832d24fc7e5d387f6fce47067ba230f8aa0ac3bcc6c12a415aa27dbd9' + ); expect(result.email).toBe('pujitm2009@gmail.com'); expect(result.username).toBe('pujitm2009@gmail.com'); expect(result.avatar).toBe(''); }); - - it('should merge all sections (api, local, remote) into single config object', async () => { const legacyConfig = { api: { version: '4.8.0+9485809', extraOrigins: 'https://example.com' }, @@ -248,8 +251,8 @@ ssoSubIds="user1,user2" idtoken: 'id_token_value', refreshtoken: 'refresh_token_value', dynamicRemoteAccessType: 'UPNP', - ssoSubIds: 'sub1,sub2' - } + ssoSubIds: 'sub1,sub2', + }, } as any; const result = await service.convertLegacyConfig(legacyConfig); @@ -284,8 +287,8 @@ ssoSubIds="user1,user2" idtoken: '', refreshtoken: '', dynamicRemoteAccessType: 'DISABLED', - ssoSubIds: '' - } + ssoSubIds: '', + }, } as any; await expect(service.convertLegacyConfig(legacyConfig)).rejects.toThrow(); @@ -317,7 +320,7 @@ ssoSubIds="sub1,sub2" // Parse the INI content const legacyConfig = service.parseLegacyConfig(iniContent); - + // Convert to new format const result = await service.convertLegacyConfig(legacyConfig); @@ -327,4 +330,4 @@ ssoSubIds="sub1,sub2" expect(result.upnpEnabled).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/packages/unraid-api-plugin-connect/src/test/url-resolver.service.test.ts b/packages/unraid-api-plugin-connect/src/test/url-resolver.service.test.ts index 3300c9900d..987018cdcb 100644 --- a/packages/unraid-api-plugin-connect/src/test/url-resolver.service.test.ts +++ b/packages/unraid-api-plugin-connect/src/test/url-resolver.service.test.ts @@ -112,7 +112,9 @@ describe('UrlResolverService', () => { const result = service.getServerIps(); expect(result.errors.length).toBeGreaterThan(0); - expect(result.errors.some(error => error.message.includes('Failed to parse URL'))).toBe(true); + expect(result.errors.some((error) => error.message.includes('Failed to parse URL'))).toBe( + true + ); }); it('should handle SSL mode variations', () => { @@ -159,7 +161,9 @@ describe('UrlResolverService', () => { const result = service.getServerIps(); if (testCase.shouldError) { - expect(result.errors.some(error => error.message.includes('SSL mode auto'))).toBe(true); + expect(result.errors.some((error) => error.message.includes('SSL mode auto'))).toBe( + true + ); } else { const lanUrl = result.urls.find( (url) => url.type === URL_TYPE.LAN && url.name === 'LAN IPv4' diff --git a/web/composables/gql/graphql.ts b/web/composables/gql/graphql.ts index 61e7c920ae..6187ecf0e1 100644 --- a/web/composables/gql/graphql.ts +++ b/web/composables/gql/graphql.ts @@ -487,14 +487,8 @@ export type ConnectSettingsValues = { }; export type ConnectSignInInput = { - /** The access token for authentication */ - accessToken?: InputMaybe; /** The API key for authentication */ apiKey: Scalars['String']['input']; - /** The ID token for authentication */ - idToken?: InputMaybe; - /** The refresh token for authentication */ - refreshToken?: InputMaybe; /** User information for the sign-in */ userInfo?: InputMaybe; }; @@ -1599,9 +1593,9 @@ export enum Temperature { export type Theme = { __typename?: 'Theme'; /** The background color of the header */ - headerBackgroundColor: Scalars['String']['output']; + headerBackgroundColor?: Maybe; /** The text color of the header */ - headerPrimaryTextColor: Scalars['String']['output']; + headerPrimaryTextColor?: Maybe; /** The secondary text color of the header */ headerSecondaryTextColor?: Maybe; /** The theme name */ @@ -2218,7 +2212,7 @@ export type ServerStateQuery = { __typename?: 'Query', cloud: ( export type GetThemeQueryVariables = Exact<{ [key: string]: never; }>; -export type GetThemeQuery = { __typename?: 'Query', publicTheme: { __typename?: 'Theme', name: ThemeName, showBannerImage: boolean, showBannerGradient: boolean, headerBackgroundColor: string, showHeaderDescription: boolean, headerPrimaryTextColor: string, headerSecondaryTextColor?: string | null } }; +export type GetThemeQuery = { __typename?: 'Query', publicTheme: { __typename?: 'Theme', name: ThemeName, showBannerImage: boolean, showBannerGradient: boolean, headerBackgroundColor?: string | null, showHeaderDescription: boolean, headerPrimaryTextColor?: string | null, headerSecondaryTextColor?: string | null } }; export const ApiKeyFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode; export const ApiKeyWithKeyFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyWithKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKeyWithSecret"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode; From d1144a287c61b303ff613d6fa4c639aaf3015f06 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 1 Jul 2025 10:57:39 -0400 Subject: [PATCH 3/7] build: replace hash with build increment in slackware txz pkg (#1449) ## Summary by CodeRabbit * **New Features** * Introduced support for specifying and propagating a build number throughout the build process, including command-line options and workflow inputs. * TXZ package naming and URLs now include the build number for improved traceability. * **Improvements** * Enhanced robustness in locating TXZ files with improved fallback logic, especially in CI environments. * Improved flexibility and validation of environment schema for plugin builds. * **Style** * Minor formatting corrections for consistency and readability. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1210677942019563 --- .github/workflows/build-plugin.yml | 8 +- .github/workflows/main.yml | 12 +++ plugin/builder/build-plugin.ts | 38 +++++-- plugin/builder/build-txz.ts | 2 +- plugin/builder/cli/common-environment.ts | 5 +- .../builder/cli/setup-plugin-environment.ts | 99 +++++++++++++------ plugin/builder/utils/bucket-urls.ts | 15 ++- plugin/builder/utils/consts.ts | 18 +++- plugin/builder/utils/paths.ts | 10 +- plugin/package.json | 2 +- pnpm-lock.yaml | 7 +- 11 files changed, 157 insertions(+), 59 deletions(-) diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 5d3fd0b0e9..a52acb9f1d 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -23,6 +23,10 @@ on: type: string required: true description: "Base URL for the plugin builds" + BUILD_NUMBER: + type: string + required: true + description: "Build number for the plugin builds" secrets: CF_ACCESS_KEY_ID: required: true @@ -108,8 +112,8 @@ jobs: id: build-plugin run: | cd ${{ github.workspace }}/plugin - pnpm run build:txz --tag="${{ inputs.TAG }}" --base-url="${{ inputs.BASE_URL }}" --api-version="${{ steps.vars.outputs.API_VERSION }}" - pnpm run build:plugin --tag="${{ inputs.TAG }}" --base-url="${{ inputs.BASE_URL }}" --api-version="${{ steps.vars.outputs.API_VERSION }}" + pnpm run build:txz --tag="${{ inputs.TAG }}" --base-url="${{ inputs.BASE_URL }}" --api-version="${{ steps.vars.outputs.API_VERSION }}" --build-number="${{ inputs.BUILD_NUMBER }}" + pnpm run build:plugin --tag="${{ inputs.TAG }}" --base-url="${{ inputs.BASE_URL }}" --api-version="${{ steps.vars.outputs.API_VERSION }}" --build-number="${{ inputs.BUILD_NUMBER }}" - name: Ensure Plugin Files Exist run: | diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 95caa90415..9f4e37d595 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -156,6 +156,8 @@ jobs: build-api: name: Build API runs-on: ubuntu-latest + outputs: + build_number: ${{ steps.buildnumber.outputs.build_number }} defaults: run: working-directory: api @@ -210,6 +212,14 @@ jobs: API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}") export API_VERSION echo "API_VERSION=${API_VERSION}" >> $GITHUB_ENV + echo "PACKAGE_LOCK_VERSION=${PACKAGE_LOCK_VERSION}" >> $GITHUB_OUTPUT + + - name: Generate build number + id: buildnumber + uses: onyxmueller/build-tag-number@v1 + with: + token: ${{secrets.github_token}} + prefix: ${{steps.vars.outputs.PACKAGE_LOCK_VERSION}} - name: Build run: | @@ -365,6 +375,7 @@ jobs: TAG: ${{ github.event.pull_request.number && format('PR{0}', github.event.pull_request.number) || '' }} BUCKET_PATH: ${{ github.event.pull_request.number && format('unraid-api/tag/PR{0}', github.event.pull_request.number) || 'unraid-api' }} BASE_URL: "https://preview.dl.unraid.net/unraid-api" + BUILD_NUMBER: ${{ needs.build-api.outputs.build_number }} secrets: CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }} CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }} @@ -387,6 +398,7 @@ jobs: TAG: "" BUCKET_PATH: unraid-api BASE_URL: "https://stable.dl.unraid.net/unraid-api" + BUILD_NUMBER: ${{ needs.build-api.outputs.build_number }} secrets: CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }} CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }} diff --git a/plugin/builder/build-plugin.ts b/plugin/builder/build-plugin.ts index 4c85155782..7238b8dfa9 100644 --- a/plugin/builder/build-plugin.ts +++ b/plugin/builder/build-plugin.ts @@ -2,7 +2,13 @@ import { readFile, writeFile, mkdir, rename } from "fs/promises"; import { $ } from "zx"; import { escape as escapeHtml } from "html-sloppy-escaper"; import { dirname, join } from "node:path"; -import { getTxzName, pluginName, startingDir, defaultArch, defaultBuild } from "./utils/consts"; +import { + getTxzName, + pluginName, + startingDir, + defaultArch, + defaultBuild, +} from "./utils/consts"; import { getPluginUrl } from "./utils/bucket-urls"; import { getMainTxzUrl } from "./utils/bucket-urls"; import { @@ -25,10 +31,17 @@ const checkGit = async () => { } }; -const moveTxzFile = async ({txzPath, apiVersion}: Pick) => { - const txzName = getTxzName(apiVersion); +const moveTxzFile = async ({ + txzPath, + apiVersion, + buildNumber, +}: Pick) => { + const txzName = getTxzName({ + version: apiVersion, + build: buildNumber.toString(), + }); const targetPath = join(deployDir, txzName); - + // Ensure the txz always has the full version name if (txzPath !== targetPath) { console.log(`Ensuring TXZ has correct name: ${txzPath} -> ${targetPath}`); @@ -54,13 +67,14 @@ function updateEntityValue( const buildPlugin = async ({ pluginVersion, baseUrl, + buildNumber, tag, txzSha256, releaseNotes, apiVersion, }: PluginEnv) => { console.log(`API version: ${apiVersion}`); - + // Update plg file let plgContent = await readFile(getRootPluginPath({ startingDir }), "utf8"); @@ -70,11 +84,19 @@ const buildPlugin = async ({ version: pluginVersion, api_version: apiVersion, arch: defaultArch, - build: defaultBuild, + build: buildNumber.toString(), plugin_url: getPluginUrl({ baseUrl, tag }), - txz_url: getMainTxzUrl({ baseUrl, apiVersion, tag }), + txz_url: getMainTxzUrl({ + baseUrl, + tag, + version: apiVersion, + build: buildNumber.toString(), + }), txz_sha256: txzSha256, - txz_name: getTxzName(apiVersion), + txz_name: getTxzName({ + version: apiVersion, + build: buildNumber.toString(), + }), ...(tag ? { tag } : {}), }; diff --git a/plugin/builder/build-txz.ts b/plugin/builder/build-txz.ts index 0a69df3dcc..524676e8af 100644 --- a/plugin/builder/build-txz.ts +++ b/plugin/builder/build-txz.ts @@ -158,7 +158,7 @@ const buildTxz = async (validatedEnv: TxzEnv) => { const version = validatedEnv.apiVersion; // Always use version when getting txz name - const txzName = getTxzName(version); + const txzName = getTxzName({ version, build: validatedEnv.buildNumber.toString() }); console.log(`Package name: ${txzName}`); const txzPath = join(validatedEnv.txzOutputDir, txzName); diff --git a/plugin/builder/cli/common-environment.ts b/plugin/builder/cli/common-environment.ts index 2a0d3890c3..261fce9125 100644 --- a/plugin/builder/cli/common-environment.ts +++ b/plugin/builder/cli/common-environment.ts @@ -10,6 +10,8 @@ export const baseEnvSchema = z.object({ apiVersion: z.string(), baseUrl: z.string().url(), tag: z.string().optional().default(""), + /** i.e. Slackware build number */ + buildNumber: z.coerce.number().int().default(1), }); export type BaseEnv = z.infer; @@ -43,5 +45,6 @@ export const addCommonOptions = (program: Command) => { "--tag ", "Tag (used for PR and staging builds)", process.env.TAG - ); + ) + .option("--build-number ", "Build number"); }; diff --git a/plugin/builder/cli/setup-plugin-environment.ts b/plugin/builder/cli/setup-plugin-environment.ts index 81065c74e8..7633140a39 100644 --- a/plugin/builder/cli/setup-plugin-environment.ts +++ b/plugin/builder/cli/setup-plugin-environment.ts @@ -8,22 +8,47 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { baseEnvSchema, addCommonOptions } from "./common-environment"; -const safeParseEnvSchema = baseEnvSchema.extend({ - txzPath: z.string().refine((val) => val.endsWith(".txz"), { - message: "TXZ Path must end with .txz", - }), +const basePluginSchema = baseEnvSchema.extend({ + txzPath: z + .string() + .refine((val) => val.endsWith(".txz"), { + message: "TXZ Path must end with .txz", + }) + .optional(), pluginVersion: z.string().regex(/^\d{4}\.\d{2}\.\d{2}\.\d{4}$/, { message: "Plugin version must be in the format YYYY.MM.DD.HHMM", }), releaseNotesPath: z.string().optional(), }); -const pluginEnvSchema = safeParseEnvSchema.extend({ - releaseNotes: z.string().nonempty("Release notes are required"), - txzSha256: z.string().refine((val) => val.length === 64, { - message: "TXZ SHA256 must be 64 characters long", - }), -}); +const safeParseEnvSchema = basePluginSchema.transform((data) => ({ + ...data, + txzPath: + data.txzPath || + getTxzPath({ + startingDir: process.cwd(), + version: data.apiVersion, + build: data.buildNumber.toString(), + }), +})); + +const pluginEnvSchema = basePluginSchema + .extend({ + releaseNotes: z.string().nonempty("Release notes are required"), + txzSha256: z.string().refine((val) => val.length === 64, { + message: "TXZ SHA256 must be 64 characters long", + }), + }) + .transform((data) => ({ + ...data, + txzPath: + data.txzPath || + getTxzPath({ + startingDir: process.cwd(), + version: data.apiVersion, + build: data.buildNumber.toString(), + }), + })); export type PluginEnv = z.infer; @@ -36,7 +61,11 @@ export type PluginEnv = z.infer; * @returns Object containing the resolved txz path and SHA256 hash * @throws Error if no valid txz file can be found */ -export const resolveTxzPath = async (txzPath: string, apiVersion: string, isCi?: boolean): Promise<{path: string, sha256: string}> => { +export const resolveTxzPath = async ( + txzPath: string, + apiVersion: string, + isCi?: boolean +): Promise<{ path: string; sha256: string }> => { if (existsSync(txzPath)) { await access(txzPath, constants.F_OK); console.log("Reading txz file from:", txzPath); @@ -46,35 +75,37 @@ export const resolveTxzPath = async (txzPath: string, apiVersion: string, isCi?: } return { path: txzPath, - sha256: getSha256(txzFile) + sha256: getSha256(txzFile), }; } console.log(`TXZ path not found at: ${txzPath}`); console.log(`Attempting to find TXZ using apiVersion: ${apiVersion}`); - + // Try different formats of generated TXZ name const deployDir = join(process.cwd(), "deploy"); - + // Try with exact apiVersion format const alternativePaths = [ join(deployDir, `dynamix.unraid.net-${apiVersion}-x86_64-1.txz`), ]; - + // In CI, we sometimes see unusual filenames, so try a glob-like approach if (isCi) { console.log("Checking for possible TXZ files in deploy directory"); - + try { // Using node's filesystem APIs to scan the directory - const fs = require('fs'); + const fs = require("fs"); const deployFiles = fs.readdirSync(deployDir); - + // Find any txz file that contains the apiVersion for (const file of deployFiles) { - if (file.endsWith('.txz') && - file.includes('dynamix.unraid.net') && - file.includes(apiVersion.split('+')[0])) { + if ( + file.endsWith(".txz") && + file.includes("dynamix.unraid.net") && + file.includes(apiVersion.split("+")[0]) + ) { alternativePaths.push(join(deployDir, file)); } } @@ -82,7 +113,7 @@ export const resolveTxzPath = async (txzPath: string, apiVersion: string, isCi?: console.log(`Error scanning deploy directory: ${error}`); } } - + // Check each path for (const path of alternativePaths) { if (existsSync(path)) { @@ -96,14 +127,16 @@ export const resolveTxzPath = async (txzPath: string, apiVersion: string, isCi?: } return { path, - sha256: getSha256(txzFile) + sha256: getSha256(txzFile), }; } console.log(`Could not find TXZ at: ${path}`); } - + // If we get here, we couldn't find a valid txz file - throw new Error(`Could not find any valid TXZ file. Tried original path: ${txzPath} and alternatives.`); + throw new Error( + `Could not find any valid TXZ file. Tried original path: ${txzPath} and alternatives.` + ); }; export const validatePluginEnv = async ( @@ -127,7 +160,11 @@ export const validatePluginEnv = async ( } // Resolve and validate the txz path - const { path, sha256 } = await resolveTxzPath(safeEnv.txzPath, safeEnv.apiVersion, safeEnv.ci); + const { path, sha256 } = await resolveTxzPath( + safeEnv.txzPath, + safeEnv.apiVersion, + safeEnv.ci + ); envArgs.txzPath = path; envArgs.txzSha256 = sha256; @@ -142,8 +179,9 @@ export const validatePluginEnv = async ( export const getPluginVersion = () => { const now = new Date(); - - const formatUtcComponent = (component: number) => String(component).padStart(2, '0'); + + const formatUtcComponent = (component: number) => + String(component).padStart(2, "0"); const year = now.getUTCFullYear(); const month = formatUtcComponent(now.getUTCMonth() + 1); @@ -162,13 +200,12 @@ export const setupPluginEnv = async (argv: string[]): Promise => { // Add common options addCommonOptions(program); - + // Add plugin-specific options program .option( "--txz-path ", - "Path to built package, will be used to generate the SHA256 and renamed with the plugin version", - getTxzPath({ startingDir: process.cwd(), pluginVersion: process.env.API_VERSION }) + "Path to built package, will be used to generate the SHA256 and renamed with the plugin version" ) .option( "--plugin-version ", diff --git a/plugin/builder/utils/bucket-urls.ts b/plugin/builder/utils/bucket-urls.ts index fc0f31c7a0..c3dd13a4c5 100644 --- a/plugin/builder/utils/bucket-urls.ts +++ b/plugin/builder/utils/bucket-urls.ts @@ -1,4 +1,11 @@ -import { getTxzName, LOCAL_BUILD_TAG, pluginNameWithExt, defaultArch, defaultBuild } from "./consts"; +import { + getTxzName, + LOCAL_BUILD_TAG, + pluginNameWithExt, + defaultArch, + defaultBuild, + TxzNameParams, +} from "./consts"; // Define a common interface for URL parameters interface UrlParams { @@ -6,9 +13,7 @@ interface UrlParams { tag?: string; } -interface TxzUrlParams extends UrlParams { - apiVersion: string; -} +interface TxzUrlParams extends UrlParams, TxzNameParams {} /** * Get the bucket path for the given tag @@ -47,4 +52,4 @@ export const getPluginUrl = (params: UrlParams): string => * ex. returns = BASE_URL/TAG/dynamix.unraid.net-4.1.3-x86_64-1.txz */ export const getMainTxzUrl = (params: TxzUrlParams): string => - getAssetUrl(params, getTxzName(params.apiVersion, defaultArch, defaultBuild)); + getAssetUrl(params, getTxzName(params)); diff --git a/plugin/builder/utils/consts.ts b/plugin/builder/utils/consts.ts index 62a5349948..883022e311 100644 --- a/plugin/builder/utils/consts.ts +++ b/plugin/builder/utils/consts.ts @@ -5,9 +5,21 @@ export const pluginNameWithExt = `${pluginName}.plg` as const; export const defaultArch = "x86_64" as const; export const defaultBuild = "1" as const; +export interface TxzNameParams { + version?: string; + arch?: string; + build?: string; +} + // Get the txz name following Slackware naming convention: name-version-arch-build.txz -export const getTxzName = (version?: string, arch: string = defaultArch, build: string = defaultBuild) => - version ? `${pluginName}-${version}-${arch}-${build}.txz` : `${pluginName}.txz`; +export const getTxzName = ({ + version, + arch = defaultArch, + build = defaultBuild, +}: TxzNameParams) => + version + ? `${pluginName}-${version}-${arch}-${build}.txz` + : `${pluginName}.txz`; export const startingDir = process.cwd(); export const BASE_URLS = { @@ -15,4 +27,4 @@ export const BASE_URLS = { PREVIEW: "https://preview.dl.unraid.net/unraid-api", } as const; -export const LOCAL_BUILD_TAG = "LOCAL_PLUGIN_BUILD" as const; \ No newline at end of file +export const LOCAL_BUILD_TAG = "LOCAL_PLUGIN_BUILD" as const; diff --git a/plugin/builder/utils/paths.ts b/plugin/builder/utils/paths.ts index 146c1889ae..20a8cdf364 100644 --- a/plugin/builder/utils/paths.ts +++ b/plugin/builder/utils/paths.ts @@ -4,15 +4,14 @@ import { pluginName, pluginNameWithExt, startingDir, + TxzNameParams, } from "./consts"; export interface PathConfig { startingDir: string; } -export interface TxzPathConfig extends PathConfig { - pluginVersion?: string; -} +export interface TxzPathConfig extends PathConfig, TxzNameParams {} export const deployDir = "deploy" as const; @@ -53,7 +52,8 @@ export function getDeployPluginPath({ startingDir }: PathConfig): string { */ export function getTxzPath({ startingDir, - pluginVersion, + version, + build, }: TxzPathConfig): string { - return join(startingDir, deployDir, getTxzName(pluginVersion)); + return join(startingDir, deployDir, getTxzName({ version, build })); } diff --git a/plugin/package.json b/plugin/package.json index 78e7eef70e..6ad53dca59 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -12,7 +12,7 @@ "tsx": "^4.19.2", "zod": "^3.24.1", "zx": "^8.3.2" -}, + }, "type": "module", "license": "GPL-2.0-or-later", "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 056b09d011..9767d9356e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11107,6 +11107,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.13.0: @@ -21480,6 +21481,8 @@ snapshots: dependencies: tabbable: 6.2.0 + follow-redirects@1.15.9: {} + follow-redirects@1.15.9(debug@4.3.7): optionalDependencies: debug: 4.3.7 @@ -22152,7 +22155,7 @@ snapshots: http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.9(debug@4.3.7) + follow-redirects: 1.15.9 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -26301,7 +26304,7 @@ snapshots: terser@5.43.1: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.1 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 From 25f4364d999152ca5d7b6345fe8cba4691cbdd7f Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 1 Jul 2025 11:13:02 -0400 Subject: [PATCH 4/7] chore: add `watch` recipe to connect plugin justfile --- packages/unraid-api-plugin-connect/justfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/unraid-api-plugin-connect/justfile b/packages/unraid-api-plugin-connect/justfile index 39f6d767d6..315e1e132f 100644 --- a/packages/unraid-api-plugin-connect/justfile +++ b/packages/unraid-api-plugin-connect/justfile @@ -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 From 5cbd97514403b639e1a20e41b60b32e5e731aa03 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 1 Jul 2025 11:44:48 -0400 Subject: [PATCH 5/7] fix: email validation in connect-config.model --- .../src/model/connect-config.model.ts | 8 +++++--- .../src/service/config.persistence.ts | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/unraid-api-plugin-connect/src/model/connect-config.model.ts b/packages/unraid-api-plugin-connect/src/model/connect-config.model.ts index 32f2d80eda..2a83588c4a 100644 --- a/packages/unraid-api-plugin-connect/src/model/connect-config.model.ts +++ b/packages/unraid-api-plugin-connect/src/model/connect-config.model.ts @@ -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'; @@ -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() @@ -194,7 +197,6 @@ export const emptyMyServersConfig = (): MyServersConfig => ({ upnpEnabled: false, apikey: '', localApiKey: '', - email: '', username: '', avatar: '', regWizTime: '', diff --git a/packages/unraid-api-plugin-connect/src/service/config.persistence.ts b/packages/unraid-api-plugin-connect/src/service/config.persistence.ts index 0daa752391..58de9fc47d 100644 --- a/packages/unraid-api-plugin-connect/src/service/config.persistence.ts +++ b/packages/unraid-api-plugin-connect/src/service/config.persistence.ts @@ -85,7 +85,9 @@ export class ConnectConfigPersister implements OnModuleInit, OnModuleDestroy { if (config instanceof MyServersConfig) { instance = config; } else { - instance = plainToInstance(MyServersConfig, config, { enableImplicitConversion: true }); + instance = plainToInstance(MyServersConfig, config, { + enableImplicitConversion: true, + }); } await validateOrReject(instance); return instance; @@ -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 { From 0d545ab973647163301ce90efd6cb2910b8cf32b Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 1 Jul 2025 12:18:49 -0400 Subject: [PATCH 6/7] add fuzz testing --- .../unraid-api-plugin-connect/package.json | 2 + .../src/service/config.persistence.ts | 2 +- .../src/test/config.persistence.test.ts | 245 ++++++++++++-- .../src/test/config.validation.test.ts | 313 ++++++++++++++++++ pnpm-lock.yaml | 41 ++- 5 files changed, 564 insertions(+), 39 deletions(-) create mode 100644 packages/unraid-api-plugin-connect/src/test/config.validation.test.ts diff --git a/packages/unraid-api-plugin-connect/package.json b/packages/unraid-api-plugin-connect/package.json index 19709df085..9a43c9c250 100644 --- a/packages/unraid-api-plugin-connect/package.json +++ b/packages/unraid-api-plugin-connect/package.json @@ -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", @@ -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", diff --git a/packages/unraid-api-plugin-connect/src/service/config.persistence.ts b/packages/unraid-api-plugin-connect/src/service/config.persistence.ts index 58de9fc47d..f8aa061137 100644 --- a/packages/unraid-api-plugin-connect/src/service/config.persistence.ts +++ b/packages/unraid-api-plugin-connect/src/service/config.persistence.ts @@ -80,7 +80,7 @@ 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; diff --git a/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts b/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts index f7d606cada..0ace0ef62e 100644 --- a/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts +++ b/packages/unraid-api-plugin-connect/src/test/config.persistence.test.ts @@ -1,8 +1,9 @@ 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', () => { @@ -21,7 +22,7 @@ describe('ConnectConfigPersister', () => { }, } as any; - service = new ConnectConfigPersister(configService); + service = new ConnectConfigPersister(configService as any); }); describe('parseLegacyConfig', () => { @@ -59,6 +60,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', () => { @@ -269,31 +344,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] @@ -324,10 +374,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(); + }); }); }); diff --git a/packages/unraid-api-plugin-connect/src/test/config.validation.test.ts b/packages/unraid-api-plugin-connect/src/test/config.validation.test.ts new file mode 100644 index 0000000000..b694e90436 --- /dev/null +++ b/packages/unraid-api-plugin-connect/src/test/config.validation.test.ts @@ -0,0 +1,313 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigService } from '@nestjs/config'; +import { faker } from '@faker-js/faker'; +import * as fc from 'fast-check'; + +import { MyServersConfig, DynamicRemoteAccessType } from '../model/connect-config.model.js'; +import { ConnectConfigPersister } from '../service/config.persistence.js'; + +describe('MyServersConfig Validation', () => { + let persister: ConnectConfigPersister; + let validConfig: Partial; + + beforeEach(() => { + const configService = { + getOrThrow: vi.fn().mockReturnValue('/mock/path'), + get: vi.fn(), + set: vi.fn(), + changes$: { + pipe: vi.fn(() => ({ + subscribe: vi.fn(), + })), + }, + } as any; + + persister = new ConnectConfigPersister(configService as any); + + validConfig = { + wanaccess: false, + wanport: 0, + upnpEnabled: false, + apikey: 'test-api-key', + localApiKey: 'test-local-key', + email: 'test@example.com', + username: 'testuser', + avatar: 'https://example.com/avatar.jpg', + regWizTime: '2024-01-01T00:00:00Z', + dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED, + upnpStatus: null, + }; + }); + + describe('Email validation', () => { + it('should accept valid email addresses', async () => { + const config = { ...validConfig, email: 'user@example.com' }; + const result = await persister.validate(config); + expect(result.email).toBe('user@example.com'); + }); + + it('should accept empty string for email', async () => { + const config = { ...validConfig, email: '' }; + const result = await persister.validate(config); + expect(result.email).toBe(''); + }); + + it('should accept null for email', async () => { + const config = { ...validConfig, email: null }; + const result = await persister.validate(config); + expect(result.email).toBeNull(); + }); + + it('should reject invalid email addresses', async () => { + const config = { ...validConfig, email: 'invalid-email' }; + await expect(persister.validate(config)).rejects.toThrow(); + }); + + it('should reject malformed email addresses', async () => { + const config = { ...validConfig, email: '@example.com' }; + await expect(persister.validate(config)).rejects.toThrow(); + }); + }); + + describe('Boolean field validation', () => { + it('should accept boolean values for wanaccess', async () => { + const config = { ...validConfig, wanaccess: true }; + const result = await persister.validate(config); + expect(result.wanaccess).toBe(true); + }); + + it('should accept boolean values for upnpEnabled', async () => { + const config = { ...validConfig, upnpEnabled: true }; + const result = await persister.validate(config); + expect(result.upnpEnabled).toBe(true); + }); + + it('should reject non-boolean values for wanaccess', async () => { + const config = { ...validConfig, wanaccess: 'yes' as any }; + await expect(persister.validate(config)).rejects.toThrow(); + }); + + it('should reject non-boolean values for upnpEnabled', async () => { + const config = { ...validConfig, upnpEnabled: 'no' as any }; + await expect(persister.validate(config)).rejects.toThrow(); + }); + }); + + describe('Number field validation', () => { + it('should accept number values for wanport', async () => { + const config = { ...validConfig, wanport: 8080 }; + const result = await persister.validate(config); + expect(result.wanport).toBe(8080); + }); + + it('should accept null for optional number fields', async () => { + const config = { ...validConfig, wanport: null }; + const result = await persister.validate(config); + expect(result.wanport).toBeNull(); + }); + + it('should reject non-number values for wanport', async () => { + const config = { ...validConfig, wanport: '8080' as any }; + await expect(persister.validate(config)).rejects.toThrow(); + }); + }); + + describe('String field validation', () => { + it('should accept string values for required string fields', async () => { + const config = { ...validConfig }; + const result = await persister.validate(config); + expect(result.apikey).toBe(validConfig.apikey); + expect(result.localApiKey).toBe(validConfig.localApiKey); + expect(result.username).toBe(validConfig.username); + }); + + it('should reject non-string values for required string fields', async () => { + const config = { ...validConfig, apikey: 123 as any }; + await expect(persister.validate(config)).rejects.toThrow(); + }); + }); + + describe('Enum validation', () => { + it('should accept valid enum values for dynamicRemoteAccessType', async () => { + const config = { ...validConfig, dynamicRemoteAccessType: DynamicRemoteAccessType.STATIC }; + const result = await persister.validate(config); + expect(result.dynamicRemoteAccessType).toBe(DynamicRemoteAccessType.STATIC); + }); + + it('should reject invalid enum values for dynamicRemoteAccessType', async () => { + const config = { ...validConfig, dynamicRemoteAccessType: 'INVALID' as any }; + await expect(persister.validate(config)).rejects.toThrow(); + }); + }); + + describe('Property-based validation testing', () => { + it('should accept valid email addresses generated by faker', () => { + fc.assert( + fc.asyncProperty( + fc.constant(null).map(() => faker.internet.email()), + async (email) => { + const config = { ...validConfig, email }; + const result = await persister.validate(config); + expect(result.email).toBe(email); + } + ), + { numRuns: 20 } + ); + }); + + it('should handle various boolean combinations', () => { + fc.assert( + fc.asyncProperty( + fc.boolean(), + fc.boolean(), + async (wanaccess, upnpEnabled) => { + const config = { ...validConfig, wanaccess, upnpEnabled }; + const result = await persister.validate(config); + expect(result.wanaccess).toBe(wanaccess); + expect(result.upnpEnabled).toBe(upnpEnabled); + } + ), + { numRuns: 10 } + ); + }); + + it('should handle valid port numbers', () => { + fc.assert( + fc.asyncProperty( + fc.integer({ min: 0, max: 65535 }), + async (port) => { + const config = { ...validConfig, wanport: port }; + const result = await persister.validate(config); + expect(result.wanport).toBe(port); + expect(typeof result.wanport).toBe('number'); + } + ), + { numRuns: 20 } + ); + }); + + it('should handle various usernames and API keys', () => { + fc.assert( + fc.asyncProperty( + fc.constant(null).map(() => faker.internet.username()), + fc.constant(null).map(() => `unraid_${faker.string.alphanumeric({ length: 32 })}`), + fc.constant(null).map(() => faker.string.alphanumeric({ length: 64 })), + async (username, apikey, localApiKey) => { + const config = { ...validConfig, username, apikey, localApiKey }; + const result = await persister.validate(config); + expect(result.username).toBe(username); + expect(result.apikey).toBe(apikey); + expect(result.localApiKey).toBe(localApiKey); + } + ), + { numRuns: 15 } + ); + }); + + it('should handle various enum values for dynamicRemoteAccessType', () => { + fc.assert( + fc.asyncProperty( + fc.constantFrom( + DynamicRemoteAccessType.DISABLED, + DynamicRemoteAccessType.STATIC, + DynamicRemoteAccessType.UPNP + ), + async (dynamicRemoteAccessType) => { + const config = { ...validConfig, dynamicRemoteAccessType }; + const result = await persister.validate(config); + expect(result.dynamicRemoteAccessType).toBe(dynamicRemoteAccessType); + } + ), + { numRuns: 10 } + ); + }); + + it('should reject invalid enum values', () => { + fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1 }).filter(s => + !Object.values(DynamicRemoteAccessType).includes(s as any) + ), + async (invalidEnumValue) => { + const config = { ...validConfig, dynamicRemoteAccessType: invalidEnumValue }; + await expect(persister.validate(config)).rejects.toThrow(); + } + ), + { numRuns: 10 } + ); + }); + + it('should reject invalid email formats using fuzzing', () => { + fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1 }).filter(s => + !s.includes('@') || s.startsWith('@') || s.endsWith('@') + ), + async (invalidEmail) => { + const config = { ...validConfig, email: invalidEmail }; + await expect(persister.validate(config)).rejects.toThrow(); + } + ), + { numRuns: 15 } + ); + }); + + it('should accept any number values for wanport (range validation is done at form level)', () => { + fc.assert( + fc.asyncProperty( + fc.integer({ min: -100000, max: 100000 }), + async (port) => { + const config = { ...validConfig, wanport: port }; + const result = await persister.validate(config); + expect(result.wanport).toBe(port); + expect(typeof result.wanport).toBe('number'); + } + ), + { numRuns: 10 } + ); + }); + }); + + describe('Complete config validation', () => { + it('should validate a complete valid config', async () => { + const result = await persister.validate(validConfig); + expect(result).toBeDefined(); + expect(result.email).toBe(validConfig.email); + expect(result.username).toBe(validConfig.username); + expect(result.wanaccess).toBe(validConfig.wanaccess); + expect(result.upnpEnabled).toBe(validConfig.upnpEnabled); + }); + + it('should validate config with minimal required fields using faker data', () => { + fc.assert( + fc.asyncProperty( + fc.constant(null).map(() => ({ + email: faker.internet.email(), + username: faker.internet.username(), + apikey: `unraid_${faker.string.alphanumeric({ length: 32 })}`, + localApiKey: faker.string.alphanumeric({ length: 64 }), + avatar: faker.image.avatarGitHub(), + regWizTime: faker.date.past().toISOString(), + })), + async (fakerData) => { + const minimalConfig = { + wanaccess: false, + upnpEnabled: false, + wanport: 0, + dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED, + upnpStatus: null, + ...fakerData, + }; + + const result = await persister.validate(minimalConfig); + expect(result.email).toBe(fakerData.email); + expect(result.username).toBe(fakerData.username); + expect(result.apikey).toBe(fakerData.apikey); + expect(result.localApiKey).toBe(fakerData.localApiKey); + } + ), + { numRuns: 10 } + ); + }); + }); +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9767d9356e..4e5dc083fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -506,6 +506,9 @@ importers: '@apollo/client': specifier: ^3.11.8 version: 3.13.8(@types/react@19.0.8)(graphql-ws@6.0.5(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.2))(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)) + '@faker-js/faker': + specifier: ^9.8.0 + version: 9.8.0 '@graphql-codegen/cli': specifier: ^5.0.3 version: 5.0.7(@parcel/watcher@2.5.1)(@types/node@22.15.32)(crossws@0.3.5)(enquirer@2.4.1)(graphql-sock@1.0.1(graphql@16.11.0))(graphql@16.11.0)(typescript@5.8.3) @@ -569,6 +572,9 @@ importers: execa: specifier: ^9.5.1 version: 9.6.0 + fast-check: + specifier: ^4.1.1 + version: 4.1.1 got: specifier: ^14.4.6 version: 14.4.7 @@ -2474,6 +2480,10 @@ packages: resolution: {integrity: sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@faker-js/faker@9.8.0': + resolution: {integrity: sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + '@fastify/ajv-compiler@4.0.2': resolution: {integrity: sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ==} @@ -7793,6 +7803,10 @@ packages: resolution: {integrity: sha512-He2AjQGHe46svIFq5+L2Nx/eHDTI1oKgoevBP+TthnjymXiKkeJQ3+ITeWey99Y5+2OaPFbI1qEsx/5RsGtWnQ==} engines: {node: '>=18'} + fast-check@4.1.1: + resolution: {integrity: sha512-8+yQYeNYqBfWem0Nmm7BUnh27wm+qwGvI0xln60c8RPM5rVekxZf/Ildng2GNBfjaG6utIebFmVBPlNtZlBLxg==} + engines: {node: '>=12.17.0'} + fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} @@ -11102,6 +11116,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + q@1.5.1: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} @@ -13065,12 +13082,12 @@ packages: vue-component-type-helpers@2.2.0: resolution: {integrity: sha512-cYrAnv2me7bPDcg9kIcGwjJiSB6Qyi08+jLDo9yuvoFQjzHiPTzML7RnkJB1+3P6KMsX/KbCD4QE3Tv/knEllw==} - vue-component-type-helpers@2.2.10: - resolution: {integrity: sha512-iDUO7uQK+Sab2tYuiP9D1oLujCWlhHELHMgV/cB13cuGbG4qwkLHvtfWb6FzvxrIOPDnU0oHsz2MlQjhYDeaHA==} - vue-component-type-helpers@2.2.8: resolution: {integrity: sha512-4bjIsC284coDO9om4HPA62M7wfsTvcmZyzdfR0aUlFXqq4tXxM1APyXpNVxPC8QazKw9OhmZNHBVDA6ODaZsrA==} + vue-component-type-helpers@3.0.0: + resolution: {integrity: sha512-J1HtqhZIqmYoNg4SLcYVFdCdsVUkMo4Z6/Wx4sQMfY8TFIIqDmd3mS2whfBIKzAA7dHMexarwYbvtB/fOUuEsw==} + vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} engines: {node: '>=12'} @@ -14752,6 +14769,8 @@ snapshots: '@eslint/core': 0.15.0 levn: 0.4.1 + '@faker-js/faker@9.8.0': {} + '@fastify/ajv-compiler@4.0.2': dependencies: ajv: 8.17.1 @@ -17208,7 +17227,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.5.17(typescript@5.8.3) - vue-component-type-helpers: 2.2.10 + vue-component-type-helpers: 3.0.0 '@stylistic/eslint-plugin@4.4.1(eslint@9.29.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: @@ -21229,6 +21248,10 @@ snapshots: fake-indexeddb@6.0.1: {} + fast-check@4.1.1: + dependencies: + pure-rand: 7.0.1 + fast-copy@3.0.2: {} fast-decode-uri-component@1.0.1: {} @@ -21481,8 +21504,6 @@ snapshots: dependencies: tabbable: 6.2.0 - follow-redirects@1.15.9: {} - follow-redirects@1.15.9(debug@4.3.7): optionalDependencies: debug: 4.3.7 @@ -22155,7 +22176,7 @@ snapshots: http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.9 + follow-redirects: 1.15.9(debug@4.3.7) requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -24982,6 +25003,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@7.0.1: {} + q@1.5.1: {} qs@6.13.0: @@ -27226,10 +27249,10 @@ snapshots: vue-component-type-helpers@2.2.0: {} - vue-component-type-helpers@2.2.10: {} - vue-component-type-helpers@2.2.8: {} + vue-component-type-helpers@3.0.0: {} + vue-demi@0.14.10(vue@3.5.17(typescript@5.8.3)): dependencies: vue: 3.5.17(typescript@5.8.3) From bccd9119f11767cb9d69b7fa9217bef2b66cabfb Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Tue, 1 Jul 2025 12:26:01 -0400 Subject: [PATCH 7/7] replace debounce with buffering/batching --- .../unraid-api/config/api-config.module.ts | 2 +- .../src/service/connection.service.ts | 4 +- .../src/templates/config.persistence.ts | 37 ++++++++++++------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/api/src/unraid-api/config/api-config.module.ts b/api/src/unraid-api/config/api-config.module.ts index d3c04a717c..6d001aa2dd 100644 --- a/api/src/unraid-api/config/api-config.module.ts +++ b/api/src/unraid-api/config/api-config.module.ts @@ -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'; diff --git a/packages/unraid-api-plugin-connect/src/service/connection.service.ts b/packages/unraid-api-plugin-connect/src/service/connection.service.ts index e1099724a7..315e51c119 100644 --- a/packages/unraid-api-plugin-connect/src/service/connection.service.ts +++ b/packages/unraid-api-plugin-connect/src/service/connection.service.ts @@ -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'; @@ -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: () => { diff --git a/packages/unraid-api-plugin-generator/src/templates/config.persistence.ts b/packages/unraid-api-plugin-generator/src/templates/config.persistence.ts index cd8b574c80..1fef4d4423 100644 --- a/packages/unraid-api-plugin-generator/src/templates/config.persistence.ts +++ b/packages/unraid-api-plugin-generator/src/templates/config.persistence.ts @@ -3,7 +3,7 @@ import { ConfigService } from "@nestjs/config"; import { existsSync, readFileSync } from "fs"; import { writeFile } from "fs/promises"; import path from "path"; -import { debounceTime } from "rxjs/operators"; +import { bufferTime } from "rxjs/operators"; import { PluginNameConfig } from "./config.entity.js"; @Injectable() @@ -31,40 +31,51 @@ export class PluginNameConfigPersister implements OnModuleInit { this.configService.set("plugin-name", configFromFile); this.logger.verbose(`Config loaded from ${this.configPath}`); } catch (error) { - this.logger.error(`Error reading or parsing config file at ${this.configPath}. Using defaults.`, error); + this.logger.error( + `Error reading or parsing config file at ${this.configPath}. Using defaults.`, + error + ); // If loading fails, ensure default config is set and persisted this.persist(); } } else { - this.logger.log(`Config file ${this.configPath} does not exist. Writing default config...`); + this.logger.log( + `Config file ${this.configPath} does not exist. Writing default config...` + ); // Persist the default configuration provided by configFeature this.persist(); } // Automatically persist changes to the config file after a short delay. - this.configService.changes$.pipe(debounceTime(25)).subscribe({ - next: ({ newValue, oldValue, path: changedPath }) => { - // Only persist if the change is within this plugin's config namespace - if (changedPath.startsWith("plugin-name.") && newValue !== oldValue) { - this.logger.debug(`Config changed: ${changedPath} from ${oldValue} to ${newValue}`); - // Persist the entire config object for this plugin - this.persist(); + this.configService.changes$.pipe(bufferTime(25)).subscribe({ + next: async (changes) => { + const pluginNameConfigChanged = changes.some(({ path }) => + path.startsWith("plugin-name.") + ); + if (pluginNameConfigChanged) { + this.logger.verbose("Plugin config changed"); + await this.persist(); } }, error: (err) => { - this.logger.error("Error subscribing to config changes:", err); + this.logger.error("Error receiving config changes:", err); }, }); } - async persist(config = this.configService.get("plugin-name")) { + async persist( + config = this.configService.get("plugin-name") + ) { const data = JSON.stringify(config, null, 2); this.logger.verbose(`Persisting config to ${this.configPath}: ${data}`); try { await writeFile(this.configPath, data); this.logger.verbose(`Config change persisted to ${this.configPath}`); } catch (error) { - this.logger.error(`Error persisting config to '${this.configPath}':`, error); + this.logger.error( + `Error persisting config to '${this.configPath}':`, + error + ); } } }