diff --git a/api/src/unraid-api/config/api-config.test.ts b/api/src/unraid-api/config/api-config.test.ts index 77d2467f8..87d877009 100644 --- a/api/src/unraid-api/config/api-config.test.ts +++ b/api/src/unraid-api/config/api-config.test.ts @@ -160,6 +160,62 @@ describe('OnboardingTracker', () => { mockPathsState.activationBase = '/activation'; }); + it('marks first boot as completed when no prior state exists', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === trackerPath) { + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + } + if (filePath === versionFilePath) { + return 'version="7.2.0"\n'; + } + if (filePath === UPGRADE_MARKER_PATH) { + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + const tracker = new OnboardingTracker(configService); + const alreadyCompleted = await tracker.ensureFirstBootCompleted(); + + expect(alreadyCompleted).toBe(false); + expect(mockAtomicWriteFile).toHaveBeenCalledWith( + trackerPath, + expect.stringContaining('"firstBootCompletedAt"'), + { mode: 0o644 } + ); + expect(setMock).toHaveBeenCalledWith( + 'onboardingTracker.firstBootCompletedAt', + expect.any(String) + ); + }); + + it('returns true when first boot was already recorded', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === trackerPath) { + return JSON.stringify({ + firstBootCompletedAt: '2025-01-01T00:00:00.000Z', + }); + } + if (filePath === versionFilePath) { + return 'version="7.2.0"\n'; + } + if (filePath === UPGRADE_MARKER_PATH) { + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + const tracker = new OnboardingTracker(configService); + const alreadyCompleted = await tracker.ensureFirstBootCompleted(); + + expect(alreadyCompleted).toBe(true); + expect(mockAtomicWriteFile).not.toHaveBeenCalled(); + expect(setMock).toHaveBeenCalledWith( + 'onboardingTracker.firstBootCompletedAt', + '2025-01-01T00:00:00.000Z' + ); + }); + it('keeps previous version when shutting down with pending steps', async () => { mockReadFile.mockImplementation(async (filePath) => { if (filePath === versionFilePath) { diff --git a/api/src/unraid-api/config/onboarding-tracker.model.ts b/api/src/unraid-api/config/onboarding-tracker.model.ts index 41ed2ffe9..ab5ab283a 100644 --- a/api/src/unraid-api/config/onboarding-tracker.model.ts +++ b/api/src/unraid-api/config/onboarding-tracker.model.ts @@ -9,6 +9,7 @@ export type TrackerState = { lastTrackedVersion?: string; updatedAt?: string; completedSteps?: Record; + firstBootCompletedAt?: string; }; export type UpgradeStepState = { diff --git a/api/src/unraid-api/config/onboarding-tracker.module.ts b/api/src/unraid-api/config/onboarding-tracker.module.ts index ded1d6840..5ecf8f74f 100644 --- a/api/src/unraid-api/config/onboarding-tracker.module.ts +++ b/api/src/unraid-api/config/onboarding-tracker.module.ts @@ -15,7 +15,7 @@ import { compare } from 'semver'; import type { ActivationStepContext, ActivationStepDefinition, -} from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; +} from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; import { PATHS_CONFIG_MODULES } from '@app/environment.js'; import { getters } from '@app/store/index.js'; import { @@ -28,7 +28,7 @@ import { ActivationOnboardingStepId } from '@app/unraid-api/graph/resolvers/cust import { findActivationCodeFile, resolveActivationStepDefinitions, -} from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; +} from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; const TRACKER_FILE_NAME = 'onboarding-tracker.json'; const CONFIG_PREFIX = 'onboardingTracker'; @@ -119,6 +119,26 @@ export class OnboardingTracker implements OnApplicationBootstrap, OnApplicationS await this.clearUpgradeMarker(); } + async ensureFirstBootCompleted(): Promise { + await this.ensureStateLoaded(); + + if (this.state.firstBootCompletedAt) { + this.syncConfig(this.currentVersion); + return true; + } + + const timestamp = new Date().toISOString(); + const updatedState: TrackerState = { + ...this.state, + firstBootCompletedAt: timestamp, + updatedAt: timestamp, + }; + + await this.writeTrackerState(updatedState); + this.syncConfig(this.currentVersion); + return false; + } + async getUpgradeSnapshot(): Promise { const currentVersion = this.currentVersion ?? @@ -337,6 +357,7 @@ export class OnboardingTracker implements OnApplicationBootstrap, OnApplicationS this.configService.set('store.emhttp.var.version', currentVersion); this.configService.set(`${CONFIG_PREFIX}.lastTrackedVersion`, this.sessionLastTrackedVersion); this.configService.set(`${CONFIG_PREFIX}.completedSteps`, completedStepsMap); + this.configService.set(`${CONFIG_PREFIX}.firstBootCompletedAt`, this.state.firstBootCompletedAt); } private async readCurrentVersion(): Promise { diff --git a/api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts b/api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts new file mode 100644 index 000000000..c72778f2e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts @@ -0,0 +1,76 @@ +import { Logger } from '@nestjs/common'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { ActivationOnboardingStepId } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; + +export async function findActivationCodeFile( + activationDir: string, + extension = '.activationcode', + logger?: Logger +): Promise { + try { + await fs.access(activationDir); + const files = await fs.readdir(activationDir); + const activationFile = files.find((file) => file.endsWith(extension)); + return activationFile ? path.join(activationDir, activationFile) : null; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + logger?.debug?.( + `Activation directory ${activationDir} not found when searching for activation code.` + ); + } else if (error instanceof Error) { + logger?.error?.('Error accessing activation directory or reading its content.', error); + } + return null; + } +} + +export type ActivationStepContext = { + hasActivationCode: boolean; + regState?: string; +}; + +export type ActivationStepDefinition = { + id: ActivationOnboardingStepId; + required: boolean; + introducedIn: string; + condition?: (context: ActivationStepContext) => boolean | Promise; +}; + +const activationStepDefinitions: ActivationStepDefinition[] = [ + { + id: ActivationOnboardingStepId.WELCOME, + required: false, + introducedIn: '7.0.0', + }, + { + id: ActivationOnboardingStepId.TIMEZONE, + required: true, + introducedIn: '7.0.0', + }, + { + id: ActivationOnboardingStepId.PLUGINS, + required: false, + introducedIn: '7.0.0', + }, + { + id: ActivationOnboardingStepId.ACTIVATION, + required: true, + introducedIn: '7.0.0', + condition: (context) => + context.hasActivationCode && Boolean(context.regState?.startsWith('ENOKEYFILE')), + }, +]; + +export async function resolveActivationStepDefinitions( + context: ActivationStepContext +): Promise { + const results: ActivationStepDefinition[] = []; + for (const definition of activationStepDefinitions) { + if (!definition.condition || (await definition.condition(context))) { + results.push(definition); + } + } + return results; +} diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts index 35e4871c7..b25a6e5c1 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; import { getters } from '@app/store/index.js'; +import { OnboardingTracker } from '@app/unraid-api/config/onboarding-tracker.module.js'; import { ActivationCode } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; @@ -96,6 +97,10 @@ vi.mock('@app/core/utils/misc/sleep.js', async () => { }; }); +const onboardingTrackerMock = { + ensureFirstBootCompleted: vi.fn<() => Promise>(), +}; + describe('OnboardingService', () => { let service: OnboardingService; let loggerDebugSpy; @@ -106,7 +111,6 @@ describe('OnboardingService', () => { // Resolved mock paths const activationDir = mockPaths.activationBase; const assetsDir = mockPaths.activation.assets; - const doneFlag = path.join(activationDir, 'applied.txt'); const userDynamixCfg = mockPaths['dynamix-config'][1]; const identCfg = mockPaths.identConfig; const webguiImagesDir = mockPaths.webguiImagesBase; @@ -138,9 +142,27 @@ describe('OnboardingService', () => { loggerLogSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); loggerWarnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + onboardingTrackerMock.ensureFirstBootCompleted.mockReset(); + onboardingTrackerMock.ensureFirstBootCompleted.mockResolvedValue(false); + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any); + vi.mocked(fs.access).mockReset(); + vi.mocked(fs.readdir).mockReset(); + vi.mocked(fs.readFile).mockReset(); + vi.mocked(fs.writeFile).mockReset(); + vi.mocked(fs.copyFile).mockReset(); + vi.mocked(fileExists).mockReset(); + vi.mocked(fs.access).mockResolvedValue(undefined as any); + vi.mocked(fs.readdir).mockResolvedValue([]); + vi.mocked(fs.readFile).mockResolvedValue(''); + vi.mocked(fs.writeFile).mockResolvedValue(undefined as any); + vi.mocked(fs.copyFile).mockResolvedValue(undefined as any); + vi.mocked(fileExists).mockResolvedValue(false); const module: TestingModule = await Test.createTestingModule({ - providers: [OnboardingService], + providers: [ + OnboardingService, + { provide: OnboardingTracker, useValue: onboardingTrackerMock }, + ], }).compile(); service = module.get(OnboardingService); @@ -154,6 +176,7 @@ describe('OnboardingService', () => { afterEach(() => { vi.useRealTimers(); + mockPaths['dynamix-config'] = ['/mock/default.cfg', '/mock/user/dynamix.cfg']; }); it('should be defined', () => { @@ -168,13 +191,9 @@ describe('OnboardingService', () => { await service.onModuleInit(); expect(loggerErrorSpy).toHaveBeenCalledWith( - 'Error accessing activation directory or reading its content.', - expect.objectContaining({ - message: "Cannot read properties of undefined (reading 'find')", - }) + 'User dynamix config path missing. Skipping activation setup.' ); - // The implementation actually calls writeFile to create the flag - // so we don't check that it's not called here + expect(onboardingTrackerMock.ensureFirstBootCompleted).not.toHaveBeenCalled(); mockPaths['dynamix-config'] = originalDynamixConfig; }); @@ -189,7 +208,6 @@ describe('OnboardingService', () => { 'Error during activation check/setup on init:', accessError ); - expect(fs.writeFile).not.toHaveBeenCalledWith(doneFlag, 'true'); // Should not proceed }); it('should skip setup if activation directory does not exist', async () => { @@ -204,16 +222,20 @@ describe('OnboardingService', () => { expect(loggerLogSpy).toHaveBeenCalledWith( `Activation directory ${activationDir} not found. Skipping activation setup.` ); - expect(vi.mocked(fs.writeFile)).not.toHaveBeenCalledWith(doneFlag, 'true'); // Should not create .done flag expect(fs.readdir).not.toHaveBeenCalled(); // Should not try to read dir }); - it('should skip customizations if .done flag exists', async () => { - vi.mocked(fileExists).mockImplementation(async (p) => p === doneFlag); // .done file exists + it('should skip customizations when first boot already completed', async () => { + onboardingTrackerMock.ensureFirstBootCompleted.mockResolvedValueOnce(true); await service.onModuleInit(); - expect(fs.readdir).not.toHaveBeenCalled(); // Should not read activation dir for JSON + expect(onboardingTrackerMock.ensureFirstBootCompleted).toHaveBeenCalledTimes(1); + expect(fs.readdir).not.toHaveBeenCalled(); + expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup flag file already exists.'); + expect(loggerLogSpy).toHaveBeenCalledWith( + 'First boot setup previously completed, skipping customizations.' + ); }); it('should create flag and apply customizations if activation dir exists and flag is missing', async () => { @@ -238,7 +260,7 @@ describe('OnboardingService', () => { await promise; // Check .done flag creation - expect(fs.writeFile).toHaveBeenCalledWith(doneFlag, 'true'); + expect(onboardingTrackerMock.ensureFirstBootCompleted).toHaveBeenCalledTimes(1); expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup flag file created.'); // Check activation data loaded @@ -307,8 +329,8 @@ describe('OnboardingService', () => { await promise; // --- Assertions --- - // 1. .done flag is still created - expect(fs.writeFile).toHaveBeenCalledWith(doneFlag, 'true'); + // 1. First boot completion is recorded + expect(onboardingTrackerMock.ensureFirstBootCompleted).toHaveBeenCalledTimes(1); expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup flag file created.'); // 2. Activation data loaded @@ -469,7 +491,6 @@ describe('OnboardingService', () => { beforeEach(() => { // Setup service state as if onModuleInit ran successfully before customizations (service as any).activationDir = activationDir; - (service as any).hasRunFirstBootSetup = doneFlag; (service as any).configFile = userDynamixCfg; (service as any).caseModelCfg = caseModelCfg; (service as any).identCfg = identCfg; @@ -891,9 +912,15 @@ describe('applyActivationCustomizations specific tests', () => { loggerLogSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); loggerWarnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + onboardingTrackerMock.ensureFirstBootCompleted.mockReset(); + onboardingTrackerMock.ensureFirstBootCompleted.mockResolvedValue(false); + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any); const module: TestingModule = await Test.createTestingModule({ - providers: [OnboardingService], + providers: [ + OnboardingService, + { provide: OnboardingTracker, useValue: onboardingTrackerMock }, + ], }).compile(); service = module.get(OnboardingService); @@ -1043,10 +1070,16 @@ describe('OnboardingService - updateCfgFile', () => { vi.clearAllMocks(); loggerLogSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + onboardingTrackerMock.ensureFirstBootCompleted.mockReset(); + onboardingTrackerMock.ensureFirstBootCompleted.mockResolvedValue(false); + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any); // Need to compile a module to get an instance, even though we test a private method const module: TestingModule = await Test.createTestingModule({ - providers: [OnboardingService], + providers: [ + OnboardingService, + { provide: OnboardingTracker, useValue: onboardingTrackerMock }, + ], }).compile(); service = module.get(OnboardingService); diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts index e5d14a5bd..db2ad7a12 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts @@ -12,118 +12,52 @@ import { fileExists } from '@app/core/utils/files/file-exists.js'; import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js'; import { getters, store } from '@app/store/index.js'; import { updateDynamixConfig } from '@app/store/modules/dynamix.js'; +import { OnboardingTracker } from '@app/unraid-api/config/onboarding-tracker.module.js'; import { ActivationCode, - ActivationOnboardingStepId, PublicPartnerInfo, } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { findActivationCodeFile } from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; -export async function findActivationCodeFile( - activationDir: string, - extension = '.activationcode', - logger?: Logger -): Promise { - try { - await fs.access(activationDir); - const files = await fs.readdir(activationDir); - const activationFile = files.find((file) => file.endsWith(extension)); - return activationFile ? path.join(activationDir, activationFile) : null; - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - logger?.debug?.( - `Activation directory ${activationDir} not found when searching for activation code.` - ); - } else if (error instanceof Error) { - logger?.error?.('Error accessing activation directory or reading its content.', error); - } - return null; - } -} - -export type ActivationStepContext = { - hasActivationCode: boolean; - regState?: string; -}; - -export type ActivationStepDefinition = { - id: ActivationOnboardingStepId; - required: boolean; - introducedIn: string; - condition?: (context: ActivationStepContext) => boolean | Promise; -}; - -const activationStepDefinitions: ActivationStepDefinition[] = [ - { - id: ActivationOnboardingStepId.WELCOME, - required: false, - introducedIn: '7.0.0', - }, - { - id: ActivationOnboardingStepId.TIMEZONE, - required: true, - introducedIn: '7.0.0', - }, - { - id: ActivationOnboardingStepId.PLUGINS, - required: false, - introducedIn: '7.0.0', - }, - { - id: ActivationOnboardingStepId.ACTIVATION, - required: true, - introducedIn: '7.0.0', - condition: (context) => - context.hasActivationCode && Boolean(context.regState?.startsWith('ENOKEYFILE')), - }, -]; - -export async function resolveActivationStepDefinitions( - context: ActivationStepContext -): Promise { - const results: ActivationStepDefinition[] = []; - for (const definition of activationStepDefinitions) { - if (!definition.condition || (await definition.condition(context))) { - results.push(definition); - } - } - return results; -} - @Injectable() export class OnboardingService implements OnModuleInit { private readonly logger = new Logger(OnboardingService.name); private readonly activationJsonExtension = '.activationcode'; - private readonly activationAppliedFilename = 'applied.txt'; private activationDir!: string; - private hasRunFirstBootSetup!: string; private configFile!: string; private caseModelCfg!: string; private identCfg!: string; private activationData: ActivationCode | null = null; - async createOrGetFirstBootSetupFlag(): Promise { + constructor(private readonly onboardingTracker: OnboardingTracker) {} + + private async ensureFirstBootCompletion(): Promise { await fs.mkdir(this.activationDir, { recursive: true }); - if (await fileExists(this.hasRunFirstBootSetup)) { + const alreadyCompleted = await this.onboardingTracker.ensureFirstBootCompleted(); + if (alreadyCompleted) { this.logger.log('First boot setup flag file already exists.'); - return true; // Indicate setup was already done based on flag presence + return true; } - await fs.writeFile(this.hasRunFirstBootSetup, 'true'); this.logger.log('First boot setup flag file created.'); - return false; // Indicate setup was just marked as done + return false; } async onModuleInit() { const paths = getters.paths(); this.activationDir = paths.activationBase; - this.hasRunFirstBootSetup = path.join(this.activationDir, this.activationAppliedFilename); this.configFile = paths['dynamix-config']?.[1]; this.identCfg = paths.identConfig; this.logger.log('OnboardingService initialized with paths from store.'); + if (!this.configFile) { + this.logger.error('User dynamix config path missing. Skipping activation setup.'); + return; + } + try { // Check if activation dir exists using the initialized path try { @@ -140,7 +74,7 @@ export class OnboardingService implements OnModuleInit { } // Proceed with first boot check and activation data retrieval ONLY if dir exists - const hasRunFirstBootSetup = await this.createOrGetFirstBootSetupFlag(); + const hasRunFirstBootSetup = await this.ensureFirstBootCompletion(); if (hasRunFirstBootSetup) { this.logger.log('First boot setup previously completed, skipping customizations.'); return;