diff --git a/.gitignore b/.gitignore index 8449fad731..ae87eb6004 100644 --- a/.gitignore +++ b/.gitignore @@ -122,7 +122,11 @@ api/dev/Unraid.net/myservers.cfg # local Mise settings .mise.toml +mise.toml # Compiled test pages (generated from Nunjucks templates) web/public/test-pages/*.html +# local scripts for testing and development +.dev-scripts/ + diff --git a/@tailwind-shared/css-variables.css b/@tailwind-shared/css-variables.css index 64cc47dfa0..c9ee45bf89 100644 --- a/@tailwind-shared/css-variables.css +++ b/@tailwind-shared/css-variables.css @@ -141,4 +141,4 @@ --background: 0 0% 3.9%; --foreground: 0 0% 98%; --border: 0 0% 14.9%; - } \ No newline at end of file + } diff --git a/api/dev/activation/activation_code_12345.activationcode b/api/dev/activation/activation_code_12345.activationcode index 54563c74be..5259fb9c45 100644 --- a/api/dev/activation/activation_code_12345.activationcode +++ b/api/dev/activation/activation_code_12345.activationcode @@ -1,13 +1,37 @@ { "code": "EXAMPLE_CODE_123", - "partnerName": "MyPartner Inc.", - "partnerUrl": "https://partner.example.com", - "serverName": "MyAwesomeServer", - "sysModel": "CustomBuild v1.0", - "comment": "This is a test activation code for development.", - "header": "#336699", - "headermetacolor": "#FFFFFF", - "background": "#F0F0F0", - "showBannerGradient": "yes", - "theme": "black" + "partner": { + "name": "MyPartner Inc.", + "url": "https://partner.example.com", + "hardwareSpecsUrl": "https://partner.example.com/specs/customBuild-v1", + "manualUrl": "https://partner.example.com/docs/customBuild-manual", + "supportUrl": "https://partner.example.com/support", + "extraLinks": [ + { + "title": "Community Forums", + "url": "https://partner.example.com/forums" + }, + { + "title": "Video Tutorials", + "url": "https://partner.example.com/tutorials" + } + ] + }, + "branding": { + "theme": "black", + "bannerImage": "./assets/banner.png", + "caseModelImage": "./assets/case-model.png", + "partnerLogoLightUrl": "./assets/partner-logo-light.svg", + "partnerLogoDarkUrl": "./assets/partner-logo-dark.svg", + "onboardingTitle": "Welcome to MyPartner", + "onboardingSubtitle": "Setting up your CustomBuild v1.0", + "header": "#336699", + "headermetacolor": "#FFFFFF", + "background": "#F0F0F0", + "showBannerGradient": true + }, + "system": { + "serverName": "MyAwesomeServer", + "model": "CustomBuild v1.0" + } } diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index f4f76b8f55..d57ab4fd43 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,9 +1,9 @@ { "version": "4.29.2", "extraOrigins": [], - "sandbox": true, + "sandbox": false, "ssoSubIds": [], "plugins": [ "unraid-api-plugin-connect" ] -} \ No newline at end of file +} diff --git a/api/dev/dynamix/default.cfg b/api/dev/dynamix/default.cfg index dca8ccff81..e9f17d4df2 100644 --- a/api/dev/dynamix/default.cfg +++ b/api/dev/dynamix/default.cfg @@ -28,6 +28,7 @@ hotssd="60" maxssd="70" power="" theme="white" +terminalButton="yes" locale="" raw="" rtl="" @@ -77,4 +78,4 @@ UseTLSCert="NO" TLSCert="" AuthMethod="login" AuthUser="" -AuthPass="" \ No newline at end of file +AuthPass="" diff --git a/api/dev/dynamix/dynamix.cfg b/api/dev/dynamix/dynamix.cfg index fcdac546cf..97dd143c5c 100644 --- a/api/dev/dynamix/dynamix.cfg +++ b/api/dev/dynamix/dynamix.cfg @@ -1,42 +1,52 @@ [display] -date=%c -time=%I:%M %p -number=., -scale=-1 -tabs=1 -users=Tasks:3 -resize=0 -wwn=0 -total=1 -usage=0 -banner=image -dashapps=icons -theme=black -text=1 -unit=C -warning=70 -critical=90 -hot=45 -max=55 -sysinfo=/Tools/SystemProfiler -header=336699 -headermetacolor=FFFFFF -background=F0F0F0 -showBannerGradient=yes - +date="%c" +time="%I:%M %p" +number=".," +scale="-1" +tabs="1" +users="Tasks:3" +resize="0" +wwn="0" +total="1" +usage="0" +banner="image" +dashapps="icons" +theme="black" +terminalButton="yes" +text="1" +unit="C" +warning="70" +critical="90" +hot="45" +max="55" +sysinfo="/Tools/SystemProfiler" +header="336699" +headermetacolor="FFFFFF" +background="F0F0F0" +showBannerGradient="yes" +width="" +font="" +tty="15" +hotssd="60" +maxssd="70" +power="" +locale="" +raw="" +rtl="" +headerdescription="yes" [notify] -entity=1 -normal=1 -warning=1 -alert=1 -unraid=1 -plugin=1 -docker_notify=1 -report=1 -display=0 -date=d-m-Y -time=H:i -position=top-right -path=./dev/notifications -system=*/1 * * * * +entity="1" +normal="1" +warning="1" +alert="1" +unraid="1" +plugin="1" +docker_notify="1" +report="1" +display="0" +date="d-m-Y" +time="H:i" +position="top-right" +path="./dev/notifications" +system="*/1 * * * *" diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 126a982ad6..b4b63d064b 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -781,6 +781,275 @@ type Settings implements Node { api: ApiConfig! } +type Theme { + """The theme name""" + name: ThemeName! + + """Whether to show the header banner image""" + showBannerImage: Boolean! + + """Whether to show the banner gradient""" + showBannerGradient: Boolean! + + """Whether to show the description in the header""" + showHeaderDescription: Boolean! + + """The background color of the header""" + headerBackgroundColor: String + + """The text color of the header""" + headerPrimaryTextColor: String + + """The secondary text color of the header""" + headerSecondaryTextColor: String +} + +"""The theme name""" +enum ThemeName { + azure + black + gray + white +} + +type InfoDisplayCase implements Node { + id: PrefixedID! + + """Case image URL""" + url: String! + + """Case icon identifier""" + icon: String! + + """Error message if any""" + error: String! + + """Base64 encoded case image""" + base64: String! +} + +type InfoDisplay implements Node { + id: PrefixedID! + + """Case display configuration""" + case: InfoDisplayCase! + + """UI theme name""" + theme: ThemeName! + + """Temperature unit (C or F)""" + unit: Temperature! + + """Enable UI scaling""" + scale: Boolean! + + """Show tabs in UI""" + tabs: Boolean! + + """Enable UI resize""" + resize: Boolean! + + """Show WWN identifiers""" + wwn: Boolean! + + """Show totals""" + total: Boolean! + + """Show usage statistics""" + usage: Boolean! + + """Show text labels""" + text: Boolean! + + """Warning temperature threshold""" + warning: Int! + + """Critical temperature threshold""" + critical: Int! + + """Hot temperature threshold""" + hot: Int! + + """Maximum temperature threshold""" + max: Int + + """Locale setting""" + locale: String +} + +"""Temperature unit""" +enum Temperature { + CELSIUS + FAHRENHEIT +} + +type Language { + """Language code (e.g. en_US)""" + code: String! + + """Language description/name""" + name: String! + + """URL to the language pack XML""" + url: String +} + +type PartnerLink { + """Display title for the link""" + title: String! + + """The URL""" + url: String! +} + +type PartnerConfig { + name: String + url: String + + """Link to hardware specifications for this system""" + hardwareSpecsUrl: String + + """Link to the system manual/documentation""" + manualUrl: String + + """Link to manufacturer support page""" + supportUrl: String + + """Additional custom links provided by the partner""" + extraLinks: [PartnerLink!] +} + +type BrandingConfig { + header: String + headermetacolor: String + background: String + showBannerGradient: Boolean + theme: String + + """ + Banner image source. Supports local path, remote URL, or data URI/base64. + """ + bannerImage: String + + """ + Case model image source. Supports local path, remote URL, or data URI/base64. + """ + caseModelImage: String + + """ + Partner logo source for light themes (azure/white). Supports local path, remote URL, or data URI/base64. + """ + partnerLogoLightUrl: String + + """ + Partner logo source for dark themes (black/gray). Supports local path, remote URL, or data URI/base64. + """ + partnerLogoDarkUrl: String + + """Indicates if a partner logo exists""" + hasPartnerLogo: Boolean! + + """Custom title for onboarding welcome step""" + onboardingTitle: String + + """Custom subtitle for onboarding welcome step""" + onboardingSubtitle: String + + """Custom title for fresh install onboarding""" + onboardingTitleFreshInstall: String + + """Custom subtitle for fresh install onboarding""" + onboardingSubtitleFreshInstall: String + + """Custom title for upgrade onboarding""" + onboardingTitleUpgrade: String + + """Custom subtitle for upgrade onboarding""" + onboardingSubtitleUpgrade: String + + """Custom title for downgrade onboarding""" + onboardingTitleDowngrade: String + + """Custom subtitle for downgrade onboarding""" + onboardingSubtitleDowngrade: String + + """Custom title for incomplete onboarding""" + onboardingTitleIncomplete: String + + """Custom subtitle for incomplete onboarding""" + onboardingSubtitleIncomplete: String +} + +type SystemConfig { + serverName: String + model: String + comment: String +} + +type ActivationCode { + code: String + partner: PartnerConfig + branding: BrandingConfig + system: SystemConfig +} + +type OnboardingState { + registrationState: RegistrationState + + """Indicates whether the system is registered""" + isRegistered: Boolean! + + """Indicates whether the system is a fresh install""" + isFreshInstall: Boolean! + + """Indicates whether an activation code is present""" + hasActivationCode: Boolean! + + """Indicates whether activation is required based on current state""" + activationRequired: Boolean! +} + +"""Onboarding completion state and context""" +type Onboarding { + """ + The current onboarding status (INCOMPLETE, UPGRADE, DOWNGRADE, or COMPLETED) + """ + status: OnboardingStatus! + + """Whether this is a partner/OEM build with activation code""" + isPartnerBuild: Boolean! + + """Whether the onboarding flow has been completed""" + completed: Boolean! + + """The OS version when onboarding was completed""" + completedAtVersion: String + + """The activation code from the .activationcode file, if present""" + activationCode: String + + """Runtime onboarding state values used by the onboarding flow""" + onboardingState: OnboardingState! +} + +""" +The current onboarding status based on completion state and version relationship +""" +enum OnboardingStatus { + INCOMPLETE + UPGRADE + DOWNGRADE + COMPLETED +} + +type Customization { + activationCode: ActivationCode + + """Onboarding completion state and context""" + onboarding: Onboarding! + availableLanguages: [Language!] +} + type RCloneDrive { """Provider name""" name: String! @@ -816,6 +1085,58 @@ type RCloneRemote { config: JSON! } +"""Represents a tracked plugin installation operation""" +type PluginInstallOperation { + """Unique identifier of the operation""" + id: ID! + + """Plugin URL passed to the installer""" + url: String! + + """Optional plugin name for display purposes""" + name: String + + """Current status of the operation""" + status: PluginInstallStatus! + + """Timestamp when the operation was created""" + createdAt: DateTime! + + """Timestamp for the last update to this operation""" + updatedAt: DateTime + + """Timestamp when the operation finished, if applicable""" + finishedAt: DateTime + + """ + Collected output lines generated by the installer (capped at recent lines) + """ + output: [String!]! +} + +"""Status of a plugin installation operation""" +enum PluginInstallStatus { + QUEUED + RUNNING + SUCCEEDED + FAILED +} + +"""Emitted event representing progress for a plugin installation""" +type PluginInstallEvent { + """Identifier of the related plugin installation operation""" + operationId: ID! + + """Status reported with this event""" + status: PluginInstallStatus! + + """Output lines newly emitted since the previous event""" + output: [String!] + + """Timestamp when the event was emitted""" + timestamp: DateTime! +} + type ArrayMutations { """Set array state""" setState(input: ArrayStateInput!): UnraidArray! @@ -983,14 +1304,12 @@ type CustomizationMutations { """Theme to apply""" theme: ThemeName! ): Theme! -} -"""The theme name""" -enum ThemeName { - azure - black - gray - white + """Update the display locale (language)""" + setLocale( + """Locale code to apply (e.g. en_US)""" + locale: String! + ): String! } """ @@ -1029,983 +1348,1015 @@ input DeleteRCloneRemoteInput { name: String! } -type Config implements Node { - id: PrefixedID! - valid: Boolean - error: String -} +"""Onboarding related mutations""" +type OnboardingMutations { + """Mark onboarding as completed""" + completeOnboarding: Onboarding! -type PublicPartnerInfo { - partnerName: String + """Reset onboarding progress (for testing)""" + resetOnboarding: Onboarding! - """Indicates if a partner logo exists""" - hasPartnerLogo: Boolean! - partnerUrl: String + """Override onboarding state for testing (in-memory only)""" + setOnboardingOverride(input: OnboardingOverrideInput!): Onboarding! - """ - The path to the partner logo image on the flash drive, relative to the activation code file - """ - partnerLogoUrl: String + """Clear onboarding override state and reload from disk""" + clearOnboardingOverride: Onboarding! } -type ActivationCode { +"""Onboarding override input for testing""" +input OnboardingOverrideInput { + onboarding: OnboardingOverrideCompletionInput + activationCode: ActivationCodeOverrideInput + partnerInfo: PartnerInfoOverrideInput + registrationState: RegistrationState +} + +"""Onboarding completion override input""" +input OnboardingOverrideCompletionInput { + completed: Boolean + completedAtVersion: String +} + +"""Activation code override input""" +input ActivationCodeOverrideInput { code: String - partnerName: String - partnerUrl: String - serverName: String - sysModel: String - comment: String + partner: PartnerConfigInput + branding: BrandingConfigInput + system: SystemConfigInput +} + +input PartnerConfigInput { + name: String + url: String + hardwareSpecsUrl: String + manualUrl: String + supportUrl: String + extraLinks: [PartnerLinkInput!] +} + +"""Partner link input for custom links""" +input PartnerLinkInput { + title: String! + url: String! +} + +input BrandingConfigInput { header: String headermetacolor: String background: String showBannerGradient: Boolean theme: String + bannerImage: String + caseModelImage: String + partnerLogoLightUrl: String + partnerLogoDarkUrl: String + hasPartnerLogo: Boolean + onboardingTitle: String + onboardingSubtitle: String + onboardingTitleFreshInstall: String + onboardingSubtitleFreshInstall: String + onboardingTitleUpgrade: String + onboardingSubtitleUpgrade: String + onboardingTitleDowngrade: String + onboardingSubtitleDowngrade: String + onboardingTitleIncomplete: String + onboardingSubtitleIncomplete: String +} + +input SystemConfigInput { + serverName: String + model: String + comment: String } -type Customization { - activationCode: ActivationCode - partnerInfo: PublicPartnerInfo - theme: Theme! +"""Partner info override input""" +input PartnerInfoOverrideInput { + partner: PartnerConfigInput + branding: BrandingConfigInput } -type Theme { - """The theme name""" - name: ThemeName! +"""Unraid plugin management mutations""" +type UnraidPluginsMutations { + """Install an Unraid plugin and track installation progress""" + installPlugin(input: InstallPluginInput!): PluginInstallOperation! - """Whether to show the header banner image""" - showBannerImage: Boolean! + """Install an Unraid language pack and track installation progress""" + installLanguage(input: InstallPluginInput!): PluginInstallOperation! +} - """Whether to show the banner gradient""" - showBannerGradient: Boolean! - - """Whether to show the description in the header""" - showHeaderDescription: Boolean! - - """The background color of the header""" - headerBackgroundColor: String - - """The text color of the header""" - headerPrimaryTextColor: String - - """The secondary text color of the header""" - headerSecondaryTextColor: String -} +"""Input payload for installing a plugin""" +input InstallPluginInput { + """Plugin installation URL (.plg)""" + url: String! -type ExplicitStatusItem { - name: String! - updateStatus: UpdateStatus! -} + """Optional human-readable plugin name used for logging""" + name: String -"""Update status of a container.""" -enum UpdateStatus { - UP_TO_DATE - UPDATE_AVAILABLE - REBUILD_READY - UNKNOWN + """ + Force installation even when plugin is already present. Defaults to true to mirror the existing UI behaviour. + """ + forced: Boolean } -type ContainerPort { - ip: String - privatePort: Port - publicPort: Port - type: ContainerPortType! +type Config implements Node { + id: PrefixedID! + valid: Boolean + error: String } -""" -A field whose value is a valid TCP port within the range of 0 to 65535: https://en.wikipedia.org/wiki/Transmission_Control_Protocol#TCP_ports -""" -scalar Port +type InfoGpu implements Node { + id: PrefixedID! -enum ContainerPortType { - TCP - UDP -} + """GPU type/manufacturer""" + type: String! -type DockerPortConflictContainer { - id: PrefixedID! - name: String! -} + """GPU type identifier""" + typeid: String! -type DockerContainerPortConflict { - privatePort: Port! - type: ContainerPortType! - containers: [DockerPortConflictContainer!]! -} + """Whether GPU is blacklisted""" + blacklisted: Boolean! -type DockerLanPortConflict { - lanIpPort: String! - publicPort: Port - type: ContainerPortType! - containers: [DockerPortConflictContainer!]! -} + """Device class""" + class: String! -type DockerPortConflicts { - containerPorts: [DockerContainerPortConflict!]! - lanPorts: [DockerLanPortConflict!]! -} + """Product ID""" + productid: String! -type ContainerHostConfig { - networkMode: String! + """Vendor name""" + vendorname: String } -type DockerContainer implements Node { +type InfoNetwork implements Node { id: PrefixedID! - names: [String!]! - image: String! - imageId: String! - command: String! - created: Int! - ports: [ContainerPort!]! - """List of LAN-accessible host:port values""" - lanIpPorts: [String!] - - """Total size of all files in the container (in bytes)""" - sizeRootFs: BigInt + """Network interface name""" + iface: String! - """Size of writable layer (in bytes)""" - sizeRw: BigInt + """Network interface model""" + model: String - """Size of container logs (in bytes)""" - sizeLog: BigInt - labels: JSON - state: ContainerState! - status: String! - hostConfig: ContainerHostConfig - networkSettings: JSON - mounts: [JSON!] - autoStart: Boolean! + """Network vendor""" + vendor: String - """Zero-based order in the auto-start list""" - autoStartOrder: Int + """MAC address""" + mac: String - """Wait time in seconds applied after start""" - autoStartWait: Int - templatePath: String + """Virtual interface flag""" + virtual: Boolean - """Project/Product homepage URL""" - projectUrl: String + """Network speed""" + speed: String - """Registry/Docker Hub URL""" - registryUrl: String + """DHCP enabled flag""" + dhcp: Boolean +} - """Support page/thread URL""" - supportUrl: String +type InfoPci implements Node { + id: PrefixedID! - """Icon URL""" - iconUrl: String + """Device type/manufacturer""" + type: String! - """Resolved WebUI URL from template""" - webUiUrl: String + """Type identifier""" + typeid: String! - """Shell to use for console access (from template)""" - shell: String + """Vendor name""" + vendorname: String - """Port mappings from template (used when container is not running)""" - templatePorts: [ContainerPort!] + """Vendor ID""" + vendorid: String! - """Whether the container is orphaned (no template found)""" - isOrphaned: Boolean! - isUpdateAvailable: Boolean - isRebuildReady: Boolean + """Product name""" + productname: String - """Whether Tailscale is enabled for this container""" - tailscaleEnabled: Boolean! + """Product ID""" + productid: String! - """Tailscale status for this container (fetched via docker exec)""" - tailscaleStatus(forceRefresh: Boolean = false): TailscaleStatus -} + """Blacklisted status""" + blacklisted: String! -enum ContainerState { - RUNNING - PAUSED - EXITED + """Device class""" + class: String! } -type DockerNetwork implements Node { +type InfoUsb implements Node { id: PrefixedID! - name: String! - created: String! - scope: String! - driver: String! - enableIPv6: Boolean! - ipam: JSON! - internal: Boolean! - attachable: Boolean! - ingress: Boolean! - configFrom: JSON! - configOnly: Boolean! - containers: JSON! - options: JSON! - labels: JSON! -} -type DockerContainerLogLine { - timestamp: DateTime! - message: String! -} + """USB device name""" + name: String! -type DockerContainerLogs { - containerId: PrefixedID! - lines: [DockerContainerLogLine!]! + """USB bus number""" + bus: String - """ - Cursor that can be passed back through the since argument to continue streaming logs. - """ - cursor: DateTime + """USB device number""" + device: String } -type DockerContainerStats { +type InfoDevices implements Node { id: PrefixedID! - """CPU Usage Percentage""" - cpuPercent: Float! - - """Memory Usage String (e.g. 100MB / 1GB)""" - memUsage: String! + """List of GPU devices""" + gpu: [InfoGpu!] - """Memory Usage Percentage""" - memPercent: Float! + """List of network interfaces""" + network: [InfoNetwork!] - """Network I/O String (e.g. 100MB / 1GB)""" - netIO: String! + """List of PCI devices""" + pci: [InfoPci!] - """Block I/O String (e.g. 100MB / 1GB)""" - blockIO: String! + """List of USB devices""" + usb: [InfoUsb!] } -"""Tailscale exit node connection status""" -type TailscaleExitNodeStatus { - """Whether the exit node is online""" - online: Boolean! - - """Tailscale IPs of the exit node""" - tailscaleIps: [String!] -} +"""CPU load for a single core""" +type CpuLoad { + """The total CPU load on a single core, in percent.""" + percentTotal: Float! -"""Tailscale status for a Docker container""" -type TailscaleStatus { - """Whether Tailscale is online in the container""" - online: Boolean! + """The percentage of time the CPU spent in user space.""" + percentUser: Float! - """Current Tailscale version""" - version: String + """The percentage of time the CPU spent in kernel space.""" + percentSystem: Float! - """Latest available Tailscale version""" - latestVersion: String + """ + The percentage of time the CPU spent on low-priority (niced) user space processes. + """ + percentNice: Float! - """Whether a Tailscale update is available""" - updateAvailable: Boolean! + """The percentage of time the CPU was idle.""" + percentIdle: Float! - """Configured Tailscale hostname""" - hostname: String - - """Actual Tailscale DNS name""" - dnsName: String + """The percentage of time the CPU spent servicing hardware interrupts.""" + percentIrq: Float! - """DERP relay code""" - relay: String + """The percentage of time the CPU spent running virtual machines (guest).""" + percentGuest: Float! - """DERP relay region name""" - relayName: String + """The percentage of CPU time stolen by the hypervisor.""" + percentSteal: Float! +} - """Tailscale IPv4 and IPv6 addresses""" - tailscaleIps: [String!] +type CpuPackages implements Node { + id: PrefixedID! - """Advertised subnet routes""" - primaryRoutes: [String!] + """Total CPU package power draw (W)""" + totalPower: Float! - """Whether this container is an exit node""" - isExitNode: Boolean! + """Power draw per package (W)""" + power: [Float!]! - """Status of the connected exit node (if using one)""" - exitNodeStatus: TailscaleExitNodeStatus + """Temperature per package (°C)""" + temp: [Float!]! +} - """Tailscale Serve/Funnel WebUI URL""" - webUiUrl: String +type CpuUtilization implements Node { + id: PrefixedID! - """Tailscale key expiry date""" - keyExpiry: DateTime + """Total CPU load in percent""" + percentTotal: Float! - """Days until key expires""" - keyExpiryDays: Int + """CPU load for each core""" + cpus: [CpuLoad!]! +} - """Whether the Tailscale key has expired""" - keyExpired: Boolean! +type InfoCpu implements Node { + id: PrefixedID! - """Tailscale backend state (Running, NeedsLogin, Stopped, etc.)""" - backendState: String + """CPU manufacturer""" + manufacturer: String - """Authentication URL if Tailscale needs login""" - authUrl: String -} + """CPU brand name""" + brand: String -type Docker implements Node { - id: PrefixedID! - containers(skipCache: Boolean! = false): [DockerContainer!]! - networks(skipCache: Boolean! = false): [DockerNetwork!]! - portConflicts(skipCache: Boolean! = false): DockerPortConflicts! + """CPU vendor""" + vendor: String - """ - Access container logs. Requires specifying a target container id through resolver arguments. - """ - logs(id: PrefixedID!, since: DateTime, tail: Int): DockerContainerLogs! - container(id: PrefixedID!): DockerContainer - organizer(skipCache: Boolean! = false): ResolvedOrganizerV1! - containerUpdateStatuses: [ExplicitStatusItem!]! -} + """CPU family""" + family: String -type DockerTemplateSyncResult { - scanned: Int! - matched: Int! - skipped: Int! - errors: [String!]! -} + """CPU model""" + model: String -type ResolvedOrganizerView { - id: String! - name: String! - rootId: String! - flatEntries: [FlatOrganizerEntry!]! - prefs: JSON -} + """CPU stepping""" + stepping: Int -type ResolvedOrganizerV1 { - version: Float! - views: [ResolvedOrganizerView!]! -} + """CPU revision""" + revision: String -type FlatOrganizerEntry { - id: String! - type: String! - name: String! - parentId: String - depth: Float! - position: Float! - path: [String!]! - hasChildren: Boolean! - childrenIds: [String!]! - meta: DockerContainer -} + """CPU voltage""" + voltage: String -type NotificationCounts { - info: Int! - warning: Int! - alert: Int! - total: Int! -} + """Current CPU speed in GHz""" + speed: Float -type NotificationOverview { - unread: NotificationCounts! - archive: NotificationCounts! -} + """Minimum CPU speed in GHz""" + speedmin: Float -type Notification implements Node { - id: PrefixedID! + """Maximum CPU speed in GHz""" + speedmax: Float - """Also known as 'event'""" - title: String! - subject: String! - description: String! - importance: NotificationImportance! - link: String - type: NotificationType! + """Number of CPU threads""" + threads: Int - """ISO Timestamp for when the notification occurred""" - timestamp: String - formattedTimestamp: String -} + """Number of CPU cores""" + cores: Int -enum NotificationImportance { - ALERT - INFO - WARNING -} + """Number of physical processors""" + processors: Int -enum NotificationType { - UNREAD - ARCHIVE -} + """CPU socket type""" + socket: String -type Notifications implements Node { - id: PrefixedID! + """CPU cache information""" + cache: JSON - """A cached overview of the notifications in the system & their severity.""" - overview: NotificationOverview! - list(filter: NotificationFilter!): [Notification!]! + """CPU feature flags""" + flags: [String!] """ - Deduplicated list of unread warning and alert notifications, sorted latest first. + Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] """ - warningsAndAlerts: [Notification!]! + topology: [[[Int!]!]!]! + packages: CpuPackages! } -input NotificationFilter { - importance: NotificationImportance - type: NotificationType! - offset: Int! - limit: Int! -} +type MemoryLayout implements Node { + id: PrefixedID! -type FlashBackupStatus { - """Status message indicating the outcome of the backup initiation.""" - status: String! + """Memory module size in bytes""" + size: BigInt! - """Job ID if available, can be used to check job status.""" - jobId: String -} + """Memory bank location (e.g., BANK 0)""" + bank: String -type Flash implements Node { - id: PrefixedID! - guid: String! - vendor: String! - product: String! -} + """Memory type (e.g., DDR4, DDR5)""" + type: String -type InfoGpu implements Node { - id: PrefixedID! + """Memory clock speed in MHz""" + clockSpeed: Int - """GPU type/manufacturer""" - type: String! + """Part number of the memory module""" + partNum: String - """GPU type identifier""" - typeid: String! + """Serial number of the memory module""" + serialNum: String - """Whether GPU is blacklisted""" - blacklisted: Boolean! + """Memory manufacturer""" + manufacturer: String - """Device class""" - class: String! + """Form factor (e.g., DIMM, SODIMM)""" + formFactor: String - """Product ID""" - productid: String! + """Configured voltage in millivolts""" + voltageConfigured: Int - """Vendor name""" - vendorname: String + """Minimum voltage in millivolts""" + voltageMin: Int + + """Maximum voltage in millivolts""" + voltageMax: Int } -type InfoNetwork implements Node { +type MemoryUtilization implements Node { id: PrefixedID! - """Network interface name""" - iface: String! + """Total system memory in bytes""" + total: BigInt! - """Network interface model""" - model: String + """Used memory in bytes""" + used: BigInt! - """Network vendor""" - vendor: String + """Free memory in bytes""" + free: BigInt! - """MAC address""" - mac: String + """Available memory in bytes""" + available: BigInt! - """Virtual interface flag""" - virtual: Boolean + """Active memory in bytes""" + active: BigInt! - """Network speed""" - speed: String + """Buffer/cache memory in bytes""" + buffcache: BigInt! - """DHCP enabled flag""" - dhcp: Boolean -} - -type InfoPci implements Node { - id: PrefixedID! - - """Device type/manufacturer""" - type: String! - - """Type identifier""" - typeid: String! + """Memory usage percentage""" + percentTotal: Float! - """Vendor name""" - vendorname: String + """Total swap memory in bytes""" + swapTotal: BigInt! - """Vendor ID""" - vendorid: String! + """Used swap memory in bytes""" + swapUsed: BigInt! - """Product name""" - productname: String + """Free swap memory in bytes""" + swapFree: BigInt! - """Product ID""" - productid: String! + """Swap usage percentage""" + percentSwapTotal: Float! +} - """Blacklisted status""" - blacklisted: String! +type InfoMemory implements Node { + id: PrefixedID! - """Device class""" - class: String! + """Physical memory layout""" + layout: [MemoryLayout!]! } -type InfoUsb implements Node { +type InfoNetworkInterface implements Node { id: PrefixedID! - """USB device name""" + """Interface name (e.g. eth0)""" name: String! - """USB bus number""" - bus: String + """Interface description/label""" + description: String - """USB device number""" - device: String -} + """MAC Address""" + macAddress: String -type InfoDevices implements Node { - id: PrefixedID! + """Connection status""" + status: String - """List of GPU devices""" - gpu: [InfoGpu!] + """IPv4 Protocol mode""" + protocol: String - """List of network interfaces""" - network: [InfoNetwork!] + """IPv4 Address""" + ipAddress: String - """List of PCI devices""" - pci: [InfoPci!] + """IPv4 Netmask""" + netmask: String - """List of USB devices""" - usb: [InfoUsb!] -} + """IPv4 Gateway""" + gateway: String -type InfoDisplayCase implements Node { - id: PrefixedID! + """Using DHCP for IPv4""" + useDhcp: Boolean - """Case image URL""" - url: String! + """IPv6 Address""" + ipv6Address: String - """Case icon identifier""" - icon: String! + """IPv6 Netmask""" + ipv6Netmask: String - """Error message if any""" - error: String! + """IPv6 Gateway""" + ipv6Gateway: String - """Base64 encoded case image""" - base64: String! + """Using DHCP for IPv6""" + useDhcp6: Boolean } -type InfoDisplay implements Node { +type InfoOs implements Node { id: PrefixedID! - """Case display configuration""" - case: InfoDisplayCase! - - """UI theme name""" - theme: ThemeName! - - """Temperature unit (C or F)""" - unit: Temperature! + """Operating system platform""" + platform: String - """Enable UI scaling""" - scale: Boolean! + """Linux distribution name""" + distro: String - """Show tabs in UI""" - tabs: Boolean! + """OS release version""" + release: String - """Enable UI resize""" - resize: Boolean! + """OS codename""" + codename: String - """Show WWN identifiers""" - wwn: Boolean! + """Kernel version""" + kernel: String - """Show totals""" - total: Boolean! + """OS architecture""" + arch: String - """Show usage statistics""" - usage: Boolean! + """Hostname""" + hostname: String - """Show text labels""" - text: Boolean! + """Fully qualified domain name""" + fqdn: String - """Warning temperature threshold""" - warning: Int! + """OS build identifier""" + build: String - """Critical temperature threshold""" - critical: Int! + """Service pack version""" + servicepack: String - """Hot temperature threshold""" - hot: Int! + """Boot time ISO string""" + uptime: String - """Maximum temperature threshold""" - max: Int + """OS logo name""" + logofile: String - """Locale setting""" - locale: String -} + """OS serial number""" + serial: String -"""Temperature unit""" -enum Temperature { - CELSIUS - FAHRENHEIT + """OS started via UEFI""" + uefi: Boolean } -"""CPU load for a single core""" -type CpuLoad { - """The total CPU load on a single core, in percent.""" - percentTotal: Float! +type InfoSystem implements Node { + id: PrefixedID! - """The percentage of time the CPU spent in user space.""" - percentUser: Float! + """System manufacturer""" + manufacturer: String - """The percentage of time the CPU spent in kernel space.""" - percentSystem: Float! + """System model""" + model: String - """ - The percentage of time the CPU spent on low-priority (niced) user space processes. - """ - percentNice: Float! + """System version""" + version: String - """The percentage of time the CPU was idle.""" - percentIdle: Float! + """System serial number""" + serial: String - """The percentage of time the CPU spent servicing hardware interrupts.""" - percentIrq: Float! + """System UUID""" + uuid: String - """The percentage of time the CPU spent running virtual machines (guest).""" - percentGuest: Float! + """System SKU""" + sku: String - """The percentage of CPU time stolen by the hypervisor.""" - percentSteal: Float! + """Virtual machine flag""" + virtual: Boolean } -type CpuPackages implements Node { +type InfoBaseboard implements Node { id: PrefixedID! - """Total CPU package power draw (W)""" - totalPower: Float! + """Motherboard manufacturer""" + manufacturer: String - """Power draw per package (W)""" - power: [Float!]! + """Motherboard model""" + model: String - """Temperature per package (°C)""" - temp: [Float!]! -} + """Motherboard version""" + version: String -type CpuUtilization implements Node { - id: PrefixedID! + """Motherboard serial number""" + serial: String - """Total CPU load in percent""" - percentTotal: Float! + """Motherboard asset tag""" + assetTag: String - """CPU load for each core""" - cpus: [CpuLoad!]! -} + """Maximum memory capacity in bytes""" + memMax: Float -type InfoCpu implements Node { - id: PrefixedID! + """Number of memory slots""" + memSlots: Float +} - """CPU manufacturer""" - manufacturer: String +type CoreVersions { + """Unraid version""" + unraid: String - """CPU brand name""" - brand: String + """Unraid API version""" + api: String - """CPU vendor""" - vendor: String + """Kernel version""" + kernel: String +} - """CPU family""" - family: String +type PackageVersions { + """OpenSSL version""" + openssl: String - """CPU model""" - model: String + """Node.js version""" + node: String - """CPU stepping""" - stepping: Int + """npm version""" + npm: String - """CPU revision""" - revision: String + """pm2 version""" + pm2: String - """CPU voltage""" - voltage: String + """Git version""" + git: String - """Current CPU speed in GHz""" - speed: Float + """nginx version""" + nginx: String - """Minimum CPU speed in GHz""" - speedmin: Float + """PHP version""" + php: String - """Maximum CPU speed in GHz""" - speedmax: Float + """Docker version""" + docker: String +} - """Number of CPU threads""" - threads: Int +type InfoVersions implements Node { + id: PrefixedID! - """Number of CPU cores""" - cores: Int + """Core system versions""" + core: CoreVersions! - """Number of physical processors""" - processors: Int + """Software package versions""" + packages: PackageVersions +} - """CPU socket type""" - socket: String +type Info implements Node { + id: PrefixedID! - """CPU cache information""" - cache: JSON + """Current server time""" + time: DateTime! - """CPU feature flags""" - flags: [String!] + """Motherboard information""" + baseboard: InfoBaseboard! - """ - Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] - """ - topology: [[[Int!]!]!]! - packages: CpuPackages! -} + """CPU information""" + cpu: InfoCpu! -type MemoryLayout implements Node { - id: PrefixedID! + """Device information""" + devices: InfoDevices! - """Memory module size in bytes""" - size: BigInt! + """Display configuration""" + display: InfoDisplay! - """Memory bank location (e.g., BANK 0)""" - bank: String + """Machine ID""" + machineId: ID - """Memory type (e.g., DDR4, DDR5)""" - type: String + """Memory information""" + memory: InfoMemory! - """Memory clock speed in MHz""" - clockSpeed: Int + """Operating system information""" + os: InfoOs! - """Part number of the memory module""" - partNum: String + """System information""" + system: InfoSystem! - """Serial number of the memory module""" - serialNum: String + """Software versions""" + versions: InfoVersions! - """Memory manufacturer""" - manufacturer: String + """Network interfaces""" + networkInterfaces: [InfoNetworkInterface!]! - """Form factor (e.g., DIMM, SODIMM)""" - formFactor: String + """Primary management interface""" + primaryNetwork: InfoNetworkInterface +} - """Configured voltage in millivolts""" - voltageConfigured: Int +type ExplicitStatusItem { + name: String! + updateStatus: UpdateStatus! +} - """Minimum voltage in millivolts""" - voltageMin: Int +"""Update status of a container.""" +enum UpdateStatus { + UP_TO_DATE + UPDATE_AVAILABLE + REBUILD_READY + UNKNOWN +} - """Maximum voltage in millivolts""" - voltageMax: Int +type ContainerPort { + ip: String + privatePort: Port + publicPort: Port + type: ContainerPortType! } -type MemoryUtilization implements Node { - id: PrefixedID! +""" +A field whose value is a valid TCP port within the range of 0 to 65535: https://en.wikipedia.org/wiki/Transmission_Control_Protocol#TCP_ports +""" +scalar Port - """Total system memory in bytes""" - total: BigInt! +enum ContainerPortType { + TCP + UDP +} - """Used memory in bytes""" - used: BigInt! +type DockerPortConflictContainer { + id: PrefixedID! + name: String! +} - """Free memory in bytes""" - free: BigInt! +type DockerContainerPortConflict { + privatePort: Port! + type: ContainerPortType! + containers: [DockerPortConflictContainer!]! +} - """Available memory in bytes""" - available: BigInt! +type DockerLanPortConflict { + lanIpPort: String! + publicPort: Port + type: ContainerPortType! + containers: [DockerPortConflictContainer!]! +} - """Active memory in bytes""" - active: BigInt! +type DockerPortConflicts { + containerPorts: [DockerContainerPortConflict!]! + lanPorts: [DockerLanPortConflict!]! +} - """Buffer/cache memory in bytes""" - buffcache: BigInt! +type ContainerHostConfig { + networkMode: String! +} - """Memory usage percentage""" - percentTotal: Float! +type DockerContainer implements Node { + id: PrefixedID! + names: [String!]! + image: String! + imageId: String! + command: String! + created: Int! + ports: [ContainerPort!]! - """Total swap memory in bytes""" - swapTotal: BigInt! + """List of LAN-accessible host:port values""" + lanIpPorts: [String!] - """Used swap memory in bytes""" - swapUsed: BigInt! + """Total size of all files in the container (in bytes)""" + sizeRootFs: BigInt - """Free swap memory in bytes""" - swapFree: BigInt! + """Size of writable layer (in bytes)""" + sizeRw: BigInt - """Swap usage percentage""" - percentSwapTotal: Float! -} + """Size of container logs (in bytes)""" + sizeLog: BigInt + labels: JSON + state: ContainerState! + status: String! + hostConfig: ContainerHostConfig + networkSettings: JSON + mounts: [JSON!] + autoStart: Boolean! -type InfoMemory implements Node { - id: PrefixedID! + """Zero-based order in the auto-start list""" + autoStartOrder: Int - """Physical memory layout""" - layout: [MemoryLayout!]! -} + """Wait time in seconds applied after start""" + autoStartWait: Int + templatePath: String -type InfoOs implements Node { - id: PrefixedID! + """Project/Product homepage URL""" + projectUrl: String - """Operating system platform""" - platform: String + """Registry/Docker Hub URL""" + registryUrl: String - """Linux distribution name""" - distro: String + """Support page/thread URL""" + supportUrl: String - """OS release version""" - release: String + """Icon URL""" + iconUrl: String - """OS codename""" - codename: String + """Resolved WebUI URL from template""" + webUiUrl: String - """Kernel version""" - kernel: String + """Shell to use for console access (from template)""" + shell: String - """OS architecture""" - arch: String + """Port mappings from template (used when container is not running)""" + templatePorts: [ContainerPort!] - """Hostname""" - hostname: String + """Whether the container is orphaned (no template found)""" + isOrphaned: Boolean! + isUpdateAvailable: Boolean + isRebuildReady: Boolean - """Fully qualified domain name""" - fqdn: String + """Whether Tailscale is enabled for this container""" + tailscaleEnabled: Boolean! - """OS build identifier""" - build: String + """Tailscale status for this container (fetched via docker exec)""" + tailscaleStatus(forceRefresh: Boolean = false): TailscaleStatus +} - """Service pack version""" - servicepack: String +enum ContainerState { + RUNNING + PAUSED + EXITED +} - """Boot time ISO string""" - uptime: String +type DockerNetwork implements Node { + id: PrefixedID! + name: String! + created: String! + scope: String! + driver: String! + enableIPv6: Boolean! + ipam: JSON! + internal: Boolean! + attachable: Boolean! + ingress: Boolean! + configFrom: JSON! + configOnly: Boolean! + containers: JSON! + options: JSON! + labels: JSON! +} - """OS logo name""" - logofile: String +type DockerContainerLogLine { + timestamp: DateTime! + message: String! +} - """OS serial number""" - serial: String +type DockerContainerLogs { + containerId: PrefixedID! + lines: [DockerContainerLogLine!]! - """OS started via UEFI""" - uefi: Boolean + """ + Cursor that can be passed back through the since argument to continue streaming logs. + """ + cursor: DateTime } -type InfoSystem implements Node { +type DockerContainerStats { id: PrefixedID! - """System manufacturer""" - manufacturer: String + """CPU Usage Percentage""" + cpuPercent: Float! + + """Memory Usage String (e.g. 100MB / 1GB)""" + memUsage: String! + + """Memory Usage Percentage""" + memPercent: Float! + + """Network I/O String (e.g. 100MB / 1GB)""" + netIO: String! + + """Block I/O String (e.g. 100MB / 1GB)""" + blockIO: String! +} + +"""Tailscale exit node connection status""" +type TailscaleExitNodeStatus { + """Whether the exit node is online""" + online: Boolean! + + """Tailscale IPs of the exit node""" + tailscaleIps: [String!] +} - """System model""" - model: String +"""Tailscale status for a Docker container""" +type TailscaleStatus { + """Whether Tailscale is online in the container""" + online: Boolean! - """System version""" + """Current Tailscale version""" version: String - """System serial number""" - serial: String + """Latest available Tailscale version""" + latestVersion: String - """System UUID""" - uuid: String + """Whether a Tailscale update is available""" + updateAvailable: Boolean! - """System SKU""" - sku: String + """Configured Tailscale hostname""" + hostname: String - """Virtual machine flag""" - virtual: Boolean -} + """Actual Tailscale DNS name""" + dnsName: String -type InfoBaseboard implements Node { - id: PrefixedID! + """DERP relay code""" + relay: String - """Motherboard manufacturer""" - manufacturer: String + """DERP relay region name""" + relayName: String - """Motherboard model""" - model: String + """Tailscale IPv4 and IPv6 addresses""" + tailscaleIps: [String!] - """Motherboard version""" - version: String + """Advertised subnet routes""" + primaryRoutes: [String!] - """Motherboard serial number""" - serial: String + """Whether this container is an exit node""" + isExitNode: Boolean! - """Motherboard asset tag""" - assetTag: String + """Status of the connected exit node (if using one)""" + exitNodeStatus: TailscaleExitNodeStatus - """Maximum memory capacity in bytes""" - memMax: Float + """Tailscale Serve/Funnel WebUI URL""" + webUiUrl: String - """Number of memory slots""" - memSlots: Float -} + """Tailscale key expiry date""" + keyExpiry: DateTime -type CoreVersions { - """Unraid version""" - unraid: String + """Days until key expires""" + keyExpiryDays: Int - """Unraid API version""" - api: String + """Whether the Tailscale key has expired""" + keyExpired: Boolean! - """Kernel version""" - kernel: String + """Tailscale backend state (Running, NeedsLogin, Stopped, etc.)""" + backendState: String + + """Authentication URL if Tailscale needs login""" + authUrl: String } -type PackageVersions { - """OpenSSL version""" - openssl: String +type Docker implements Node { + id: PrefixedID! + containers(skipCache: Boolean! = false): [DockerContainer!]! + networks(skipCache: Boolean! = false): [DockerNetwork!]! + portConflicts(skipCache: Boolean! = false): DockerPortConflicts! - """Node.js version""" - node: String + """ + Access container logs. Requires specifying a target container id through resolver arguments. + """ + logs(id: PrefixedID!, since: DateTime, tail: Int): DockerContainerLogs! + container(id: PrefixedID!): DockerContainer + organizer(skipCache: Boolean! = false): ResolvedOrganizerV1! + containerUpdateStatuses: [ExplicitStatusItem!]! +} - """npm version""" - npm: String +type DockerTemplateSyncResult { + scanned: Int! + matched: Int! + skipped: Int! + errors: [String!]! +} - """pm2 version""" - pm2: String +type ResolvedOrganizerView { + id: String! + name: String! + rootId: String! + flatEntries: [FlatOrganizerEntry!]! + prefs: JSON +} - """Git version""" - git: String +type ResolvedOrganizerV1 { + version: Float! + views: [ResolvedOrganizerView!]! +} - """nginx version""" - nginx: String +type FlatOrganizerEntry { + id: String! + type: String! + name: String! + parentId: String + depth: Float! + position: Float! + path: [String!]! + hasChildren: Boolean! + childrenIds: [String!]! + meta: DockerContainer +} - """PHP version""" - php: String +type NotificationCounts { + info: Int! + warning: Int! + alert: Int! + total: Int! +} - """Docker version""" - docker: String +type NotificationOverview { + unread: NotificationCounts! + archive: NotificationCounts! } -type InfoVersions implements Node { +type Notification implements Node { id: PrefixedID! - """Core system versions""" - core: CoreVersions! + """Also known as 'event'""" + title: String! + subject: String! + description: String! + importance: NotificationImportance! + link: String + type: NotificationType! - """Software package versions""" - packages: PackageVersions + """ISO Timestamp for when the notification occurred""" + timestamp: String + formattedTimestamp: String } -type Info implements Node { - id: PrefixedID! - - """Current server time""" - time: DateTime! - - """Motherboard information""" - baseboard: InfoBaseboard! +enum NotificationImportance { + ALERT + INFO + WARNING +} - """CPU information""" - cpu: InfoCpu! +enum NotificationType { + UNREAD + ARCHIVE +} - """Device information""" - devices: InfoDevices! +type Notifications implements Node { + id: PrefixedID! - """Display configuration""" - display: InfoDisplay! + """A cached overview of the notifications in the system & their severity.""" + overview: NotificationOverview! + list(filter: NotificationFilter!): [Notification!]! - """Machine ID""" - machineId: ID + """ + Deduplicated list of unread warning and alert notifications, sorted latest first. + """ + warningsAndAlerts: [Notification!]! +} - """Memory information""" - memory: InfoMemory! +input NotificationFilter { + importance: NotificationImportance + type: NotificationType! + offset: Int! + limit: Int! +} - """Operating system information""" - os: InfoOs! +type FlashBackupStatus { + """Status message indicating the outcome of the backup initiation.""" + status: String! - """System information""" - system: InfoSystem! + """Job ID if available, can be used to check job status.""" + jobId: String +} - """Software versions""" - versions: InfoVersions! +type Flash implements Node { + id: PrefixedID! + guid: String! + vendor: String! + product: String! } type LogFile { @@ -2067,6 +2418,9 @@ type Server implements Node { apikey: String! name: String! + """Server description/comment""" + comment: String + """Whether this server is online or offline""" status: ServerStatus! wanip: String! @@ -2201,6 +2555,30 @@ type PublicOidcProvider { buttonStyle: String } +"""System time configuration and current status""" +type SystemTime { + """Current server time in ISO-8601 format (UTC)""" + currentTime: String! + + """IANA timezone identifier currently in use""" + timeZone: String! + + """Whether NTP/PTP time synchronization is enabled""" + useNtp: Boolean! + + """Configured NTP servers (empty strings indicate unused slots)""" + ntpServers: [String!]! +} + +"""Selectable timezone option from the system list""" +type TimeZoneOption { + """IANA timezone identifier""" + value: String! + + """Display label for the timezone""" + label: String! +} + type UPSBattery { """ Battery charge level as a percentage (0-100). Unit: percent (%). Example: 100 means battery is fully charged @@ -2586,6 +2964,7 @@ type Query { """Get JSON Schema for API key creation form""" getApiKeyCreationFormSchema: ApiKeyFormSettings! config: Config! + display: InfoDisplay! flash: Flash! me: UserAccount! @@ -2599,20 +2978,21 @@ type Query { services: [Service!]! shares: [Share!]! vars: Vars! - isInitialSetup: Boolean! """Get information about all VMs on the system""" vms: Vms! parityHistory: [ParityCheck!]! array: UnraidArray! customization: Customization - publicPartnerInfo: PublicPartnerInfo + + """Whether the system is a fresh install (no license key)""" + isFreshInstall: Boolean! publicTheme: Theme! + info: Info! docker: Docker! disks: [Disk!]! disk(id: PrefixedID!): Disk! rclone: RCloneBackupSettings! - info: Info! logFiles: [LogFile!]! logFile(path: String!, lines: Int, startLine: Int): LogFileContent! settings: Settings! @@ -2633,10 +3013,25 @@ type Query { """Validate an OIDC session token (internal use for CLI validation)""" validateOidcSession(token: String!): OidcSessionValidation! metrics: Metrics! + + """Retrieve current system time configuration""" + systemTime: SystemTime! + + """Retrieve available time zone options""" + timeZoneOptions: [TimeZoneOption!]! upsDevices: [UPSDevice!]! upsDeviceById(id: String!): UPSDevice upsConfiguration: UPSConfiguration! + """Retrieve a plugin installation operation by identifier""" + pluginInstallOperation(operationId: ID!): PluginInstallOperation + + """List all tracked plugin installation operations""" + pluginInstallOperations: [PluginInstallOperation!]! + + """List installed Unraid OS plugins by .plg filename""" + installedUnraidPlugins: [String!]! + """List all installed plugins with their metadata""" plugins: [Plugin!]! remoteAccess: RemoteAccess! @@ -2677,6 +3072,12 @@ type Mutation { apiKey: ApiKeyMutations! customization: CustomizationMutations! rclone: RCloneMutations! + onboarding: OnboardingMutations! + unraidPlugins: UnraidPluginsMutations! + + """Update server name, comment, and model""" + updateServerIdentity(name: String!, comment: String, sysModel: String): Server! + updateSshSettings(input: UpdateSshInput!): Vars! createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1! setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1! deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1! @@ -2696,6 +3097,9 @@ type Mutation { """Initiates a flash drive backup using a configured remote.""" initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus! updateSettings(input: JSON!): UpdateSettingsResponse! + + """Update system time configuration""" + updateSystemTime(input: UpdateSystemTimeInput!): SystemTime! configureUps(config: UPSConfigInput!): Boolean! """ @@ -2722,6 +3126,13 @@ input NotificationData { link: String } +input UpdateSshInput { + enabled: Boolean! + + """SSH Port (default 22)""" + port: Int! +} + input InitiateFlashBackupInput { """The name of the remote configuration to use for the backup.""" remoteName: String! @@ -2738,6 +3149,24 @@ input InitiateFlashBackupInput { options: JSON } +input UpdateSystemTimeInput { + """New IANA timezone identifier to apply""" + timeZone: String + + """Enable or disable NTP-based synchronization""" + useNtp: Boolean + + """ + Ordered list of up to four NTP servers. Supply empty strings to clear positions. + """ + ntpServers: [String!] + + """ + Manual date/time to apply when disabling NTP, expected format YYYY-MM-DD HH:mm:ss + """ + manualDateTime: String +} + input UPSConfigInput { """Enable or disable the UPS monitoring service""" service: UPSServiceState @@ -2890,6 +3319,7 @@ input AccessUrlInput { } type Subscription { + displaySubscription: InfoDisplay! notificationAdded: Notification! notificationsOverview: NotificationOverview! notificationsWarningsAndAlerts: [Notification!]! @@ -2903,4 +3333,5 @@ type Subscription { systemMetricsCpuTelemetry: CpuPackages! systemMetricsMemory: MemoryUtilization! upsUpdates: UPSDevice! + pluginInstallUpdates(operationId: ID!): PluginInstallEvent! } \ No newline at end of file diff --git a/api/package.json b/api/package.json index a8c0006d7b..34b0edaf7f 100644 --- a/api/package.json +++ b/api/package.json @@ -39,6 +39,7 @@ "// Testing": "", "test": "NODE_ENV=test vitest run", "test:watch": "NODE_ENV=test vitest --ui", + "test:modifications:update": "rm -rf src/unraid-api/unraid-file-modifier/modifications/__test__/__fixtures__/downloaded src/unraid-api/unraid-file-modifier/modifications/patches src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots && NODE_ENV=test vitest run src/unraid-api/unraid-file-modifier/modifications/__test__/generic-modification.spec.ts -u", "coverage": "NODE_ENV=test vitest run --coverage", "// Docker": "", "container:build": "./scripts/dc.sh build dev", @@ -46,6 +47,7 @@ "container:stop": "./scripts/dc.sh stop dev", "container:test": "./scripts/dc.sh run --rm builder pnpm run test", "container:enter": "./scripts/dc.sh exec dev /bin/bash", + "docker:build-and-run": "pnpm --filter @unraid/connect-plugin docker:build-and-run", "// Migration Scripts": "", "migration:codefirst": "tsx ./src/unraid-api/graph/migration-script.ts" }, diff --git a/api/src/__test__/core/utils/misc/parse-config.test.ts b/api/src/__test__/core/utils/misc/parse-config.test.ts index 0eb9274ba4..1599a1f390 100644 --- a/api/src/__test__/core/utils/misc/parse-config.test.ts +++ b/api/src/__test__/core/utils/misc/parse-config.test.ts @@ -61,6 +61,12 @@ test('it loads a config from disk properly', () => { expect(res.shareCount).toEqual('0'); }); +test('it infers the config type from file extension when type not provided', () => { + const path = './dev/states/var.ini'; + const res = parseConfig({ filePath: path }); + expect(res.shareCount).toEqual('0'); +}); + test('Confirm Multi-Ini Parser Still Broken', () => { const parser = new MultiIniParser(); const res = parser.parse(iniTestData); diff --git a/api/src/__test__/core/utils/shares/get-shares.test.ts b/api/src/__test__/core/utils/shares/get-shares.test.ts index df6d04010d..63e8df152b 100644 --- a/api/src/__test__/core/utils/shares/get-shares.test.ts +++ b/api/src/__test__/core/utils/shares/get-shares.test.ts @@ -140,7 +140,7 @@ test('Returns both disk and user shares', async () => { ], } `); -}); +}, 15000); test('Returns shares by type', async () => { await store.dispatch(loadStateFiles()); @@ -299,7 +299,7 @@ test('Returns shares by type', async () => { `); expect(getShares('disk')).toMatchInlineSnapshot('null'); expect(getShares('disks')).toMatchInlineSnapshot('[]'); -}); +}, 15000); test('Returns shares by name', async () => { await store.dispatch(loadStateFiles()); @@ -330,4 +330,4 @@ test('Returns shares by name', async () => { // @TODO: disk shares need to be added to the dev ini files expect(getShares('disk', { name: 'disk1' })).toMatchInlineSnapshot('null'); expect(getShares('disk', { name: 'non-existent-disk-share' })).toMatchInlineSnapshot('null'); -}); +}, 15000); diff --git a/api/src/__test__/store/state-parsers/slots.test.ts b/api/src/__test__/store/state-parsers/slots.test.ts index c34d9eab3d..eca474f7d4 100644 --- a/api/src/__test__/store/state-parsers/slots.test.ts +++ b/api/src/__test__/store/state-parsers/slots.test.ts @@ -193,4 +193,4 @@ test('Returns parsed state file', async () => { }, ] `); -}); +}, 15000); diff --git a/api/src/__test__/store/state-parsers/var.test.ts b/api/src/__test__/store/state-parsers/var.test.ts index b0a244faa2..1fb4700dd7 100644 --- a/api/src/__test__/store/state-parsers/var.test.ts +++ b/api/src/__test__/store/state-parsers/var.test.ts @@ -172,4 +172,4 @@ test('Returns parsed state file', async () => { "wsdOpt": "", } `); -}); +}, 15000); diff --git a/api/src/common/dashboard/boot-timestamp.ts b/api/src/common/dashboard/boot-timestamp.ts index 2e8675aa73..02d179c4ae 100644 --- a/api/src/common/dashboard/boot-timestamp.ts +++ b/api/src/common/dashboard/boot-timestamp.ts @@ -1,4 +1,13 @@ import { uptime } from 'os'; +function getSafeUptimeSeconds(): number { + try { + return uptime(); + } catch { + // Some restricted environments can throw EPERM for os.uptime(). + return 0; + } +} + // Get uptime on boot and convert to date -export const bootTimestamp = new Date(new Date().getTime() - uptime() * 1_000); +export const bootTimestamp = new Date(Date.now() - getSafeUptimeSeconds() * 1_000); diff --git a/api/src/core/log.ts b/api/src/core/log.ts index 55ae39cc94..eb859399e2 100644 --- a/api/src/core/log.ts +++ b/api/src/core/log.ts @@ -36,7 +36,7 @@ const stream = SUPPRESS_LOGS levelFirst: false, ignore: 'hostname,pid', destination: logDestination, - translateTime: 'HH:mm:ss', + translateTime: 'SYS:HH:mm:ss', customPrettifiers: { time: (timestamp: string | object) => `[${timestamp}`, level: (_logLevel: string | object, _key: string, log: any, extras: any) => { diff --git a/api/src/core/utils/clients/emcmd.ts b/api/src/core/utils/clients/emcmd.ts index dfd29a4697..0cf2bee1dd 100644 --- a/api/src/core/utils/clients/emcmd.ts +++ b/api/src/core/utils/clients/emcmd.ts @@ -1,4 +1,7 @@ +import { readFile } from 'node:fs/promises'; + import { got } from 'got'; +import * as ini from 'ini'; import retry from 'p-retry'; import { AppError } from '@app/core/errors/app-error.js'; @@ -8,6 +11,79 @@ import { store } from '@app/store/index.js'; import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; import { StateFileKey } from '@app/store/types.js'; +const VAR_INI_PATH = '/var/local/emhttp/var.ini'; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + return String(error); +}; + +const hasErrorCode = (error: unknown): error is { code: string } => { + return Boolean(error && typeof error === 'object' && 'code' in error); +}; + +const readCsrfTokenFromVarIni = async (): Promise => { + try { + const iniContents = await readFile(VAR_INI_PATH, 'utf-8'); + const parsed = ini.parse(iniContents); + const token = parsed?.csrf_token; + return typeof token === 'string' ? token : undefined; + } catch (error) { + appLogger.debug( + { error: getErrorMessage(error) }, + `Unable to read CSRF token from ${VAR_INI_PATH}` + ); + return undefined; + } +}; + +const ensureCsrfToken = async ( + currentToken: string | undefined, + waitForToken: boolean +): Promise => { + if (currentToken) { + return currentToken; + } + + const tokenFromIni = await readCsrfTokenFromVarIni(); + if (tokenFromIni) { + return tokenFromIni; + } + + if (!waitForToken) { + return undefined; + } + + return retry( + async (retries) => { + if (retries > 1) { + appLogger.info('Waiting for CSRF token...'); + } + const loadedState = await store.dispatch(loadSingleStateFile(StateFileKey.var)).unwrap(); + + const token = loadedState && 'var' in loadedState ? loadedState.var.csrfToken : undefined; + if (!token) { + throw new Error('CSRF token not found yet'); + } + return token; + }, + { + minTimeout: 5000, + maxTimeout: 10000, + retries: 10, + } + ).catch((error) => { + if (error instanceof Error) { + appLogger.error({ error }, 'Failed to load CSRF token after multiple retries'); + } else { + appLogger.error('Failed to load CSRF token after multiple retries'); + } + throw new AppError('Failed to load CSRF token after multiple retries'); + }); +}; + /** * Run a command with emcmd. */ @@ -23,54 +99,54 @@ export const emcmd = async ( throw new AppError('No emhttpd socket path found'); } - let { csrfToken } = getters.emhttp().var; - - if (!csrfToken && waitForToken) { - csrfToken = await retry( - async (retries) => { - if (retries > 1) { - appLogger.info('Waiting for CSRF token...'); - } - const loadedState = await store.dispatch(loadSingleStateFile(StateFileKey.var)).unwrap(); - - let token: string | undefined; - if (loadedState && 'var' in loadedState) { - token = loadedState.var.csrfToken; - } - if (!token) { - throw new Error('CSRF token not found yet'); - } - return token; - }, - { - minTimeout: 5000, - maxTimeout: 10000, - retries: 10, - } - ).catch((error) => { - appLogger.error('Failed to load CSRF token after multiple retries', error); - throw new AppError('Failed to load CSRF token after multiple retries'); - }); - } + const stateToken = getters.emhttp().var?.csrfToken; + const csrfToken = await ensureCsrfToken(stateToken, waitForToken); appLogger.debug(`Executing emcmd with commands: ${JSON.stringify(commands)}`); try { - const paramsObj = { ...commands, csrf_token: csrfToken }; - const params = new URLSearchParams(paramsObj); - const response = await got.get(`http://unix:${socketPath}:/update.htm`, { + const params = new URLSearchParams(); + Object.entries({ ...commands }).forEach(([key, value]) => { + const stringValue = value == null ? '' : String(value); + params.append(key, stringValue); + }); + params.append('csrf_token', csrfToken ?? ''); + + const response = await got.post(`http://unix:${socketPath}:/update`, { enableUnixSockets: true, - searchParams: params, + body: params.toString(), + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + throwHttpErrors: false, }); + if (response.statusCode >= 400) { + throw new Error(`emcmd request failed with status ${response.statusCode}`); + } + + const trimmedBody = response.body?.trim(); + if (trimmedBody) { + throw new Error(trimmedBody); + } + appLogger.debug('emcmd executed successfully'); return response; - } catch (error: any) { - if (error.code === 'ENOENT') { - appLogger.error('emhttpd socket unavailable.', error); + } catch (error: unknown) { + if (hasErrorCode(error) && error.code === 'ENOENT') { + if (error instanceof Error) { + appLogger.error({ error }, 'emhttpd socket unavailable.'); + } else { + appLogger.error('emhttpd socket unavailable.'); + } throw new Error('emhttpd socket unavailable.'); } - appLogger.error(`emcmd execution failed: ${error.message}`, error); - throw error; + const message = getErrorMessage(error); + if (error instanceof Error) { + appLogger.error({ error }, `emcmd execution failed: ${message}`); + } else { + appLogger.error(`emcmd execution failed: ${message}`); + } + throw error instanceof Error ? error : new Error(message); } }; diff --git a/api/src/core/utils/misc/parse-config.ts b/api/src/core/utils/misc/parse-config.ts index 5aadc19b0e..c736200ef7 100644 --- a/api/src/core/utils/misc/parse-config.ts +++ b/api/src/core/utils/misc/parse-config.ts @@ -124,6 +124,13 @@ const fixObjectArrays = (object: Record) => { export const getExtensionFromPath = (filePath: string): string => extname(filePath); +const normalizeExtension = (extension: string): string => { + if (!extension) { + return extension; + } + return extension.startsWith('.') ? extension.slice(1).toLowerCase() : extension.toLowerCase(); +}; + const isFilePathOptions = ( options: OptionsWithLoadedFile | OptionsWithPath ): options is OptionsWithPath => Object.keys(options).includes('filePath'); @@ -141,7 +148,10 @@ export const loadFileFromPathSync = (filePath: string): string => { * @param extension File extension * @returns boolean whether extension is ini or cfg */ -const isValidConfigExtension = (extension: string): boolean => ['ini', 'cfg'].includes(extension); +const isValidConfigExtension = (extension: string): boolean => { + const normalized = normalizeExtension(extension); + return ['ini', 'cfg'].includes(normalized); +}; export const parseConfig = >( options: OptionsWithLoadedFile | OptionsWithPath diff --git a/api/src/store/actions/load-dynamix-config-file.spec.ts b/api/src/store/actions/load-dynamix-config-file.spec.ts new file mode 100644 index 0000000000..b916d48774 --- /dev/null +++ b/api/src/store/actions/load-dynamix-config-file.spec.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js'; + +describe('loadDynamixConfigFromDiskSync', () => { + const tempDirs: string[] = []; + + afterEach(() => { + tempDirs.forEach((dir) => rmSync(dir, { recursive: true, force: true })); + tempDirs.length = 0; + }); + + it('deep merges section keys across config files', () => { + const dir = mkdtempSync(join(tmpdir(), 'dynamix-config-merge-')); + tempDirs.push(dir); + + const defaultConfigPath = join(dir, 'default.cfg'); + const userConfigPath = join(dir, 'dynamix.cfg'); + + writeFileSync( + defaultConfigPath, + [ + '[display]', + 'theme=white', + 'terminalButton=yes', + 'locale=en_US', + '', + '[notify]', + 'display=0', + '', + ].join('\n') + ); + + writeFileSync(userConfigPath, ['[display]', 'theme=gray', ''].join('\n')); + + const result = loadDynamixConfigFromDiskSync([defaultConfigPath, userConfigPath]); + + expect(result).toEqual({ + display: { + theme: 'gray', + terminalButton: 'yes', + locale: 'en_US', + }, + notify: { + display: '0', + }, + }); + }); + + it('returns empty object when no config paths are provided', () => { + const result = loadDynamixConfigFromDiskSync([]); + expect(result).toEqual({}); + }); +}); diff --git a/api/src/store/actions/load-dynamix-config-file.ts b/api/src/store/actions/load-dynamix-config-file.ts index 557885ea1c..fdcf72e85c 100644 --- a/api/src/store/actions/load-dynamix-config-file.ts +++ b/api/src/store/actions/load-dynamix-config-file.ts @@ -22,6 +22,30 @@ function loadConfigFileSync(path: string): RecursivePartial => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const deepMergeConfig = ( + base: RecursivePartial, + override: RecursivePartial +): RecursivePartial => { + const output: Record = { ...(base as Record) }; + + Object.entries(override as Record).forEach(([key, value]) => { + const existingValue = output[key]; + if (isRecord(existingValue) && isRecord(value)) { + output[key] = deepMergeConfig( + existingValue as RecursivePartial, + value as RecursivePartial + ); + return; + } + output[key] = value; + }); + + return output as RecursivePartial; +}; + type ConfigPaths = readonly (string | undefined | null)[]; const CACHE_WINDOW_MS = 250; @@ -39,10 +63,7 @@ const memoizedConfigLoader = createTtlMemoizedLoader< } const configFiles = validPaths.map((path) => loadConfigFileSync(path)); return configFiles.reduce>( - (accumulator, configFile) => ({ - ...accumulator, - ...configFile, - }), + (accumulator, configFile) => deepMergeConfig(accumulator, configFile), {} ); }, diff --git a/api/src/store/modules/paths.ts b/api/src/store/modules/paths.ts index 548dfb777e..4cafb9dfe3 100644 --- a/api/src/store/modules/paths.ts +++ b/api/src/store/modules/paths.ts @@ -66,9 +66,7 @@ const initialState = { passwd: resolvePath(process.env.PATHS_PASSWD ?? ('/boot/config/passwd' as const)), 'libvirt-pid': '/var/run/libvirt/libvirtd.pid' as const, // Customization paths - activationBase: resolvePath( - process.env.PATHS_ACTIVATION_BASE ?? ('/boot/config/activation' as const) - ), + activationBase: resolvePath(process.env.PATHS_ACTIVATION_BASE ?? ('/boot/config/activate' as const)), webGuiBase: '/usr/local/emhttp/webGui' as const, identConfig: resolvePath(process.env.PATHS_IDENT_CONFIG ?? ('/boot/config/ident.cfg' as const)), }; diff --git a/api/src/store/watch/state-watch.ts b/api/src/store/watch/state-watch.ts index e341a90fd0..7272d60cfe 100644 --- a/api/src/store/watch/state-watch.ts +++ b/api/src/store/watch/state-watch.ts @@ -45,6 +45,26 @@ export class StateManager { return StateFileKey[parsed.name]; } + private async handleStateFileUpdate(eventPath: string, event: 'add' | 'change') { + const stateFile = this.getStateFileKeyFromPath(eventPath); + if (!stateFile) { + emhttpLogger.trace('Failed to resolve a stateFileKey from path: %s', eventPath); + return; + } + + try { + emhttpLogger.debug('Loading state file for %s after %s event', stateFile, event); + await store.dispatch(loadSingleStateFile(stateFile)); + } catch (error: unknown) { + emhttpLogger.error( + 'Failed to load state file: [%s] after %s event\nerror: %o', + stateFile, + event, + error as object + ); + } + } + private readonly setupChokidarWatchForState = () => { const { states } = getters.paths(); for (const key of Object.values(StateFileKey)) { @@ -52,23 +72,8 @@ export class StateManager { const pathToWatch = join(states, `${key}.ini`); emhttpLogger.debug('Setting up watch for path: %s', pathToWatch); const stateWatch = watch(pathToWatch, chokidarOptionsForStateKey(key)); - stateWatch.on('change', async (path) => { - const stateFile = this.getStateFileKeyFromPath(path); - if (stateFile) { - try { - emhttpLogger.debug('Loading state file for %s', stateFile); - await store.dispatch(loadSingleStateFile(stateFile)); - } catch (error: unknown) { - emhttpLogger.error( - 'Failed to load state file: [%s]\nerror: %o', - stateFile, - error as object - ); - } - } else { - emhttpLogger.trace('Failed to resolve a stateFileKey from path: %s', path); - } - }); + stateWatch.on('add', async (path) => this.handleStateFileUpdate(path, 'add')); + stateWatch.on('change', async (path) => this.handleStateFileUpdate(path, 'change')); this.fileWatchers.push(stateWatch); } } diff --git a/api/src/unraid-api/auth/auth.service.ts b/api/src/unraid-api/auth/auth.service.ts index fd76202271..7a13880542 100644 --- a/api/src/unraid-api/auth/auth.service.ts +++ b/api/src/unraid-api/auth/auth.service.ts @@ -23,6 +23,15 @@ import { batchProcess, handleAuthError } from '@app/utils.js'; export class AuthService { private readonly logger = new Logger(AuthService.name); + private logTrace(message: string): void { + const traceLogger = this.logger as unknown as { trace?: (msg: string) => void }; + if (typeof traceLogger.trace === 'function') { + traceLogger.trace(message); + return; + } + this.logger.verbose(message); + } + constructor( private cookieService: CookieService, private apiKeyService: ApiKeyService, @@ -84,7 +93,7 @@ export class AuthService { // Now get the updated roles const existingRoles = await this.authzService.getRolesForUser(user.id); - this.logger.debug(`User ${user.id} has roles: ${existingRoles}`); + this.logTrace(`User ${user.id} has roles: ${existingRoles}`); return user; } catch (error: unknown) { @@ -108,7 +117,7 @@ export class AuthService { // Now get the updated roles const existingRoles = await this.authzService.getRolesForUser(user.id); - this.logger.debug(`Local session user ${user.id} has roles: ${existingRoles}`); + this.logTrace(`Local session user ${user.id} has roles: ${existingRoles}`); return user; } catch (error: unknown) { @@ -264,7 +273,7 @@ export class AuthService { ...rolesToRemove.map((role) => this.authzService.deleteRoleForUser(userId, role)), ]); - this.logger.debug( + this.logTrace( `Synced roles for user ${userId}. Added: ${rolesToAdd.join( ',' )}, Removed: ${rolesToRemove.join(',')}` diff --git a/api/src/unraid-api/cli/__test__/api-report.service.test.ts b/api/src/unraid-api/cli/__test__/api-report.service.test.ts index 8dc1c6cde5..60d471ac9a 100644 --- a/api/src/unraid-api/cli/__test__/api-report.service.test.ts +++ b/api/src/unraid-api/cli/__test__/api-report.service.test.ts @@ -372,14 +372,14 @@ describe('ApiReportService', () => { expect(result.system.machineId).toBe('REDACTED'); }); - it('should handle connect with error gracefully', async () => { - const mockConnectDataWithError = { + it('should handle connect when dynamic remote access is disabled', async () => { + const mockConnectDataWithDynamicRemoteAccessDisabled = { connect: { id: 'connect', dynamicRemoteAccess: { - enabledType: 'STATIC', + enabledType: 'DISABLED', runningType: 'DISABLED', - error: 'Port forwarding failed', + error: null, }, }, }; @@ -388,7 +388,7 @@ describe('ApiReportService', () => { if (query === SYSTEM_REPORT_QUERY) { return Promise.resolve({ data: mockSystemData }); } else if (query === CONNECT_STATUS_QUERY) { - return Promise.resolve({ data: mockConnectDataWithError }); + return Promise.resolve({ data: mockConnectDataWithDynamicRemoteAccessDisabled }); } else if (query === SERVICES_QUERY) { return Promise.resolve({ data: mockServicesData }); } @@ -400,9 +400,9 @@ describe('ApiReportService', () => { expect(result.connect).toMatchObject({ installed: true, dynamicRemoteAccess: { - enabledType: 'STATIC', + enabledType: 'DISABLED', runningType: 'DISABLED', - error: 'Port forwarding failed', + error: null, }, }); }); diff --git a/api/src/unraid-api/cli/__test__/developer-tools.service.test.ts b/api/src/unraid-api/cli/__test__/developer-tools.service.test.ts index 0bd584502a..805a1508ad 100644 --- a/api/src/unraid-api/cli/__test__/developer-tools.service.test.ts +++ b/api/src/unraid-api/cli/__test__/developer-tools.service.test.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { access, readFile, unlink, writeFile } from 'fs/promises'; +import { access, unlink, writeFile } from 'fs/promises'; import type { CanonicalInternalClientService } from '@unraid/shared'; import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared'; @@ -103,7 +103,6 @@ describe('DeveloperToolsService', () => { it('should create modal test page file', async () => { vi.mocked(access).mockResolvedValue(undefined); vi.mocked(writeFile).mockResolvedValue(undefined); - vi.mocked(readFile).mockResolvedValue(''); await service.enableModalTest(); @@ -196,7 +195,7 @@ describe('DeveloperToolsService', () => { expect(guide).toBeInstanceOf(Array); expect(guide[0]).toBe('Modal Testing Guide'); - expect(guide).toContainEqual(' - Show/hide the Welcome Modal'); + expect(guide).toContainEqual(' - Show/hide the Activation Modal'); }); }); }); diff --git a/api/src/unraid-api/cli/developer/developer-tools.service.ts b/api/src/unraid-api/cli/developer/developer-tools.service.ts index 5dc0784f56..78b2af5cd8 100644 --- a/api/src/unraid-api/cli/developer/developer-tools.service.ts +++ b/api/src/unraid-api/cli/developer/developer-tools.service.ts @@ -1,5 +1,5 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; -import { access, readFile, unlink, writeFile } from 'fs/promises'; +import { Inject, Injectable } from '@nestjs/common'; +import { access, unlink, writeFile } from 'fs/promises'; import * as path from 'path'; import type { CanonicalInternalClientService } from '@unraid/shared'; @@ -8,16 +8,11 @@ import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared'; import { LogService } from '@app/unraid-api/cli/log.service.js'; import { UPDATE_SANDBOX_MUTATION } from '@app/unraid-api/cli/queries/developer.mutation.js'; import { RestartCommand } from '@app/unraid-api/cli/restart.command.js'; -import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification.js'; @Injectable() export class DeveloperToolsService { private readonly modalPageFilePath = '/usr/local/emhttp/plugins/dynamix.my.servers/DevModalTest.page'; - private readonly loginPagePath = new SSOFileModification(new Logger(DeveloperToolsService.name)) - .filePath; - private readonly welcomeModalInclude = - ''; private readonly modalPageContent = `Menu="UNRAID-OS:99" Title="Dev Modal Test" Icon="icon-code" @@ -103,15 +98,10 @@ unraid-dev-modal-test { // Create the modal test page await writeFile(this.modalPageFilePath, this.modalPageContent); - // Inject welcome modal into login page - await this.injectWelcomeModalIntoLoginPage(); - this.logger.info('✓ Modal test tool ENABLED'); this.logger.info('\nAccess the tool at: Menu > UNRAID-OS > Dev Modal Test'); - this.logger.info('✓ Welcome modal injected into login page'); this.logger.info('\nYou can now:'); - this.logger.info(' 1. Navigate to /login to see the welcome modal'); - this.logger.info(' 2. Use the Dev Modal Test page to control modals'); + this.logger.info(' 1. Use the Dev Modal Test page to control modals'); this.logger.info('\nNote: You may need to refresh your browser to see changes.\n'); } catch (error) { this.logger.error('Failed to enable modal test tool:', error); @@ -130,10 +120,6 @@ unraid-dev-modal-test { } else { this.logger.info('Modal test tool is already disabled.'); } - - // Remove welcome modal from login page - await this.removeWelcomeModalFromLoginPage(); - this.logger.info('✓ Welcome modal removed from login page'); } catch (error) { this.logger.error('Failed to disable modal test tool:', error); throw error; @@ -162,74 +148,16 @@ unraid-dev-modal-test { '==================', '', 'When modal test mode is enabled:', - ' - Welcome modal is injected into the login page (/login)', ' - Dev Modal Test page is available in the menu', '', 'The Dev Modal Test page provides buttons to:', - ' - Show/hide the Welcome Modal', ' - Show/hide the Activation Modal', ' - Clear all modal states (reset to default)', ' - Navigate to key pages (set-password, registration)', '', 'Modal Behavior:', - ' - Welcome Modal: Shows on /login (in test mode), /welcome page, and during set-password flow', ' - Activation Modal: Shows after password is set on fresh installs', ' - Use the buttons on the Dev Modal Test page to control modal visibility directly', ]; } - - private async injectWelcomeModalIntoLoginPage(): Promise { - try { - // Read the current login page content - let loginContent = await readFile(this.loginPagePath, 'utf-8'); - - // Check if welcome modal is already injected - if (loginContent.includes(this.welcomeModalInclude)) { - this.logger.info('Welcome modal already injected into login page'); - return; - } - - // Find the closing body tag and inject the welcome modal before it - const bodyEndTag = ''; - if (loginContent.includes(bodyEndTag)) { - loginContent = loginContent.replace( - bodyEndTag, - `${this.welcomeModalInclude}\n${bodyEndTag}` - ); - - // Write the modified content back - await writeFile(this.loginPagePath, loginContent); - this.logger.info('Welcome modal successfully injected into login page'); - } else { - throw new Error('Could not find tag in login page'); - } - } catch (error) { - this.logger.error('Failed to inject welcome modal into login page:', error); - throw error; - } - } - - private async removeWelcomeModalFromLoginPage(): Promise { - try { - // Read the current login page content - let loginContent = await readFile(this.loginPagePath, 'utf-8'); - - // Check if welcome modal is injected - if (!loginContent.includes(this.welcomeModalInclude)) { - this.logger.info('Welcome modal not found in login page'); - return; - } - - // Remove the welcome modal include - loginContent = loginContent.replace(`${this.welcomeModalInclude}\n`, ''); - loginContent = loginContent.replace(this.welcomeModalInclude, ''); - - // Write the modified content back - await writeFile(this.loginPagePath, loginContent); - this.logger.info('Welcome modal successfully removed from login page'); - } catch (error) { - this.logger.error('Failed to remove welcome modal from login page:', error); - // Don't throw here as we want to continue with cleanup even if this fails - } - } } diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 27032c24b9..99abc0de99 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -106,17 +106,18 @@ export type AccessUrlObjectInput = { export type ActivationCode = { __typename?: 'ActivationCode'; - background?: Maybe; + branding?: Maybe; code?: Maybe; - comment?: Maybe; - header?: Maybe; - headermetacolor?: Maybe; - partnerName?: Maybe; - partnerUrl?: Maybe; - serverName?: Maybe; - showBannerGradient?: Maybe; - sysModel?: Maybe; - theme?: Maybe; + partner?: Maybe; + system?: Maybe; +}; + +/** Activation code override input */ +export type ActivationCodeOverrideInput = { + branding?: InputMaybe; + code?: InputMaybe; + partner?: InputMaybe; + system?: InputMaybe; }; export type AddPermissionInput = { @@ -407,6 +408,68 @@ export enum AuthorizationRuleMode { OR = 'OR' } +export type BrandingConfig = { + __typename?: 'BrandingConfig'; + background?: Maybe; + /** Banner image source. Supports local path, remote URL, or data URI/base64. */ + bannerImage?: Maybe; + /** Case model image source. Supports local path, remote URL, or data URI/base64. */ + caseModelImage?: Maybe; + /** Indicates if a partner logo exists */ + hasPartnerLogo: Scalars['Boolean']['output']; + header?: Maybe; + headermetacolor?: Maybe; + /** Custom subtitle for onboarding welcome step */ + onboardingSubtitle?: Maybe; + /** Custom subtitle for downgrade onboarding */ + onboardingSubtitleDowngrade?: Maybe; + /** Custom subtitle for fresh install onboarding */ + onboardingSubtitleFreshInstall?: Maybe; + /** Custom subtitle for incomplete onboarding */ + onboardingSubtitleIncomplete?: Maybe; + /** Custom subtitle for upgrade onboarding */ + onboardingSubtitleUpgrade?: Maybe; + /** Custom title for onboarding welcome step */ + onboardingTitle?: Maybe; + /** Custom title for downgrade onboarding */ + onboardingTitleDowngrade?: Maybe; + /** Custom title for fresh install onboarding */ + onboardingTitleFreshInstall?: Maybe; + /** Custom title for incomplete onboarding */ + onboardingTitleIncomplete?: Maybe; + /** Custom title for upgrade onboarding */ + onboardingTitleUpgrade?: Maybe; + /** Partner logo source for dark themes (black/gray). Supports local path, remote URL, or data URI/base64. */ + partnerLogoDarkUrl?: Maybe; + /** Partner logo source for light themes (azure/white). Supports local path, remote URL, or data URI/base64. */ + partnerLogoLightUrl?: Maybe; + showBannerGradient?: Maybe; + theme?: Maybe; +}; + +export type BrandingConfigInput = { + background?: InputMaybe; + bannerImage?: InputMaybe; + caseModelImage?: InputMaybe; + hasPartnerLogo?: InputMaybe; + header?: InputMaybe; + headermetacolor?: InputMaybe; + onboardingSubtitle?: InputMaybe; + onboardingSubtitleDowngrade?: InputMaybe; + onboardingSubtitleFreshInstall?: InputMaybe; + onboardingSubtitleIncomplete?: InputMaybe; + onboardingSubtitleUpgrade?: InputMaybe; + onboardingTitle?: InputMaybe; + onboardingTitleDowngrade?: InputMaybe; + onboardingTitleFreshInstall?: InputMaybe; + onboardingTitleIncomplete?: InputMaybe; + onboardingTitleUpgrade?: InputMaybe; + partnerLogoDarkUrl?: InputMaybe; + partnerLogoLightUrl?: InputMaybe; + showBannerGradient?: InputMaybe; + theme?: InputMaybe; +}; + export type Capacity = { __typename?: 'Capacity'; /** Free capacity */ @@ -560,6 +623,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -587,8 +661,30 @@ export type CreateRCloneRemoteInput = { export type Customization = { __typename?: 'Customization'; activationCode?: Maybe; - partnerInfo?: Maybe; - theme: Theme; + availableLanguages?: Maybe>; + /** Onboarding completion state and context */ + onboarding: Onboarding; +}; + +/** Customization related mutations */ +export type CustomizationMutations = { + __typename?: 'CustomizationMutations'; + /** Update the display locale (language) */ + setLocale: Scalars['String']['output']; + /** Update the UI theme (writes dynamix.cfg) */ + setTheme: Theme; +}; + + +/** Customization related mutations */ +export type CustomizationMutationsSetLocaleArgs = { + locale: Scalars['String']['input']; +}; + + +/** Customization related mutations */ +export type CustomizationMutationsSetThemeArgs = { + theme: ThemeName; }; export type DeleteApiKeyInput = { @@ -1019,8 +1115,12 @@ export type Info = Node & { machineId?: Maybe; /** Memory information */ memory: InfoMemory; + /** Network interfaces */ + networkInterfaces: Array; /** Operating system information */ os: InfoOs; + /** Primary management interface */ + primaryNetwork?: Maybe; /** System information */ system: InfoSystem; /** Current server time */ @@ -1065,6 +1165,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -1081,6 +1182,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1191,6 +1294,37 @@ export type InfoNetwork = Node & { virtual?: Maybe; }; +export type InfoNetworkInterface = Node & { + __typename?: 'InfoNetworkInterface'; + /** Interface description/label */ + description?: Maybe; + /** IPv4 Gateway */ + gateway?: Maybe; + id: Scalars['PrefixedID']['output']; + /** IPv4 Address */ + ipAddress?: Maybe; + /** IPv6 Address */ + ipv6Address?: Maybe; + /** IPv6 Gateway */ + ipv6Gateway?: Maybe; + /** IPv6 Netmask */ + ipv6Netmask?: Maybe; + /** MAC Address */ + macAddress?: Maybe; + /** Interface name (e.g. eth0) */ + name: Scalars['String']['output']; + /** IPv4 Netmask */ + netmask?: Maybe; + /** IPv4 Protocol mode */ + protocol?: Maybe; + /** Connection status */ + status?: Maybe; + /** Using DHCP for IPv4 */ + useDhcp?: Maybe; + /** Using DHCP for IPv6 */ + useDhcp6?: Maybe; +}; + export type InfoOs = Node & { __typename?: 'InfoOs'; /** OS architecture */ @@ -1295,12 +1429,32 @@ export type InitiateFlashBackupInput = { sourcePath: Scalars['String']['input']; }; +/** Input payload for installing a plugin */ +export type InstallPluginInput = { + /** Force installation even when plugin is already present. Defaults to true to mirror the existing UI behaviour. */ + forced?: InputMaybe; + /** Optional human-readable plugin name used for logging */ + name?: InputMaybe; + /** Plugin installation URL (.plg) */ + url: Scalars['String']['input']; +}; + export type KeyFile = { __typename?: 'KeyFile'; contents?: Maybe; location?: Maybe; }; +export type Language = { + __typename?: 'Language'; + /** Language code (e.g. en_US) */ + code: Scalars['String']['output']; + /** Language description/name */ + name: Scalars['String']['output']; + /** URL to the language pack XML */ + url?: Maybe; +}; + export type LogFile = { __typename?: 'LogFile'; /** Last modified timestamp */ @@ -1422,6 +1576,7 @@ export type Mutation = { createDockerFolderWithItems: ResolvedOrganizerV1; /** Creates a new notification record */ createNotification: Notification; + customization: CustomizationMutations; /** Deletes all archived notifications on server. */ deleteArchivedNotifications: NotificationOverview; deleteDockerEntries: ResolvedOrganizerV1; @@ -1434,6 +1589,7 @@ export type Mutation = { moveDockerItemsToPosition: ResolvedOrganizerV1; /** Creates a notification if an equivalent unread notification does not already exist. */ notifyIfUnique?: Maybe; + onboarding: OnboardingMutations; parityCheck: ParityCheckMutations; rclone: RCloneMutations; /** Reads each notification to recompute & update the overview. */ @@ -1449,11 +1605,17 @@ export type Mutation = { syncDockerTemplatePaths: DockerTemplateSyncResult; unarchiveAll: NotificationOverview; unarchiveNotifications: NotificationOverview; + unraidPlugins: UnraidPluginsMutations; /** Marks a notification as unread. */ unreadNotification: Notification; updateApiSettings: ConnectSettingsValues; updateDockerViewPreferences: ResolvedOrganizerV1; + /** Update server name, comment, and model */ + updateServerIdentity: Server; updateSettings: UpdateSettingsResponse; + updateSshSettings: Vars; + /** Update system time configuration */ + updateSystemTime: SystemTime; vm: VmMutations; }; @@ -1595,10 +1757,27 @@ export type MutationUpdateDockerViewPreferencesArgs = { }; +export type MutationUpdateServerIdentityArgs = { + comment?: InputMaybe; + name: Scalars['String']['input']; + sysModel?: InputMaybe; +}; + + export type MutationUpdateSettingsArgs = { input: Scalars['JSON']['input']; }; + +export type MutationUpdateSshSettingsArgs = { + input: UpdateSshInput; +}; + + +export type MutationUpdateSystemTimeArgs = { + input: UpdateSystemTimeInput; +}; + export type Network = Node & { __typename?: 'Network'; accessUrls?: Maybe>; @@ -1737,6 +1916,77 @@ export type OidcSessionValidation = { valid: Scalars['Boolean']['output']; }; +/** Onboarding completion state and context */ +export type Onboarding = { + __typename?: 'Onboarding'; + /** The activation code from the .activationcode file, if present */ + activationCode?: Maybe; + /** Whether the onboarding flow has been completed */ + completed: Scalars['Boolean']['output']; + /** The OS version when onboarding was completed */ + completedAtVersion?: Maybe; + /** Whether this is a partner/OEM build with activation code */ + isPartnerBuild: Scalars['Boolean']['output']; + /** Runtime onboarding state values used by the onboarding flow */ + onboardingState: OnboardingState; + /** The current onboarding status (INCOMPLETE, UPGRADE, DOWNGRADE, or COMPLETED) */ + status: OnboardingStatus; +}; + +/** Onboarding related mutations */ +export type OnboardingMutations = { + __typename?: 'OnboardingMutations'; + /** Clear onboarding override state and reload from disk */ + clearOnboardingOverride: Onboarding; + /** Mark onboarding as completed */ + completeOnboarding: Onboarding; + /** Reset onboarding progress (for testing) */ + resetOnboarding: Onboarding; + /** Override onboarding state for testing (in-memory only) */ + setOnboardingOverride: Onboarding; +}; + + +/** Onboarding related mutations */ +export type OnboardingMutationsSetOnboardingOverrideArgs = { + input: OnboardingOverrideInput; +}; + +/** Onboarding completion override input */ +export type OnboardingOverrideCompletionInput = { + completed?: InputMaybe; + completedAtVersion?: InputMaybe; +}; + +/** Onboarding override input for testing */ +export type OnboardingOverrideInput = { + activationCode?: InputMaybe; + onboarding?: InputMaybe; + partnerInfo?: InputMaybe; + registrationState?: InputMaybe; +}; + +export type OnboardingState = { + __typename?: 'OnboardingState'; + /** Indicates whether activation is required based on current state */ + activationRequired: Scalars['Boolean']['output']; + /** Indicates whether an activation code is present */ + hasActivationCode: Scalars['Boolean']['output']; + /** Indicates whether the system is a fresh install */ + isFreshInstall: Scalars['Boolean']['output']; + /** Indicates whether the system is registered */ + isRegistered: Scalars['Boolean']['output']; + registrationState?: Maybe; +}; + +/** The current onboarding status based on completion state and version relationship */ +export enum OnboardingStatus { + COMPLETED = 'COMPLETED', + DOWNGRADE = 'DOWNGRADE', + INCOMPLETE = 'INCOMPLETE', + UPGRADE = 'UPGRADE' +} + export type Owner = { __typename?: 'Owner'; avatar: Scalars['String']['output']; @@ -1814,6 +2064,49 @@ export enum ParityCheckStatus { RUNNING = 'RUNNING' } +export type PartnerConfig = { + __typename?: 'PartnerConfig'; + /** Additional custom links provided by the partner */ + extraLinks?: Maybe>; + /** Link to hardware specifications for this system */ + hardwareSpecsUrl?: Maybe; + /** Link to the system manual/documentation */ + manualUrl?: Maybe; + name?: Maybe; + /** Link to manufacturer support page */ + supportUrl?: Maybe; + url?: Maybe; +}; + +export type PartnerConfigInput = { + extraLinks?: InputMaybe>; + hardwareSpecsUrl?: InputMaybe; + manualUrl?: InputMaybe; + name?: InputMaybe; + supportUrl?: InputMaybe; + url?: InputMaybe; +}; + +/** Partner info override input */ +export type PartnerInfoOverrideInput = { + branding?: InputMaybe; + partner?: InputMaybe; +}; + +export type PartnerLink = { + __typename?: 'PartnerLink'; + /** Display title for the link */ + title: Scalars['String']['output']; + /** The URL */ + url: Scalars['String']['output']; +}; + +/** Partner link input for custom links */ +export type PartnerLinkInput = { + title: Scalars['String']['input']; + url: Scalars['String']['input']; +}; + export type Permission = { __typename?: 'Permission'; /** Actions allowed on this resource */ @@ -1833,6 +2126,48 @@ export type Plugin = { version: Scalars['String']['output']; }; +/** Emitted event representing progress for a plugin installation */ +export type PluginInstallEvent = { + __typename?: 'PluginInstallEvent'; + /** Identifier of the related plugin installation operation */ + operationId: Scalars['ID']['output']; + /** Output lines newly emitted since the previous event */ + output?: Maybe>; + /** Status reported with this event */ + status: PluginInstallStatus; + /** Timestamp when the event was emitted */ + timestamp: Scalars['DateTime']['output']; +}; + +/** Represents a tracked plugin installation operation */ +export type PluginInstallOperation = { + __typename?: 'PluginInstallOperation'; + /** Timestamp when the operation was created */ + createdAt: Scalars['DateTime']['output']; + /** Timestamp when the operation finished, if applicable */ + finishedAt?: Maybe; + /** Unique identifier of the operation */ + id: Scalars['ID']['output']; + /** Optional plugin name for display purposes */ + name?: Maybe; + /** Collected output lines generated by the installer (capped at recent lines) */ + output: Array; + /** Current status of the operation */ + status: PluginInstallStatus; + /** Timestamp for the last update to this operation */ + updatedAt?: Maybe; + /** Plugin URL passed to the installer */ + url: Scalars['String']['output']; +}; + +/** Status of a plugin installation operation */ +export enum PluginInstallStatus { + FAILED = 'FAILED', + QUEUED = 'QUEUED', + RUNNING = 'RUNNING', + SUCCEEDED = 'SUCCEEDED' +} + export type PluginManagementInput = { /** Whether to treat plugins as bundled plugins. Bundled plugins are installed to node_modules at build time and controlled via config only. */ bundled?: Scalars['Boolean']['input']; @@ -1860,16 +2195,6 @@ export type PublicOidcProvider = { name: Scalars['String']['output']; }; -export type PublicPartnerInfo = { - __typename?: 'PublicPartnerInfo'; - /** Indicates if a partner logo exists */ - hasPartnerLogo: Scalars['Boolean']['output']; - /** The path to the partner logo image on the flash drive, relative to the activation code file */ - partnerLogoUrl?: Maybe; - partnerName?: Maybe; - partnerUrl?: Maybe; -}; - export type Query = { __typename?: 'Query'; apiKey?: Maybe; @@ -1885,6 +2210,7 @@ export type Query = { customization?: Maybe; disk: Disk; disks: Array; + display: InfoDisplay; docker: Docker; flash: Flash; /** Get JSON Schema for API key creation form */ @@ -1894,7 +2220,10 @@ export type Query = { /** Get the actual permissions that would be granted by a set of roles */ getPermissionsForRoles: Array; info: Info; - isInitialSetup: Scalars['Boolean']['output']; + /** List installed Unraid OS plugins by .plg filename */ + installedUnraidPlugins: Array; + /** Whether the system is a fresh install (no license key) */ + isFreshInstall: Scalars['Boolean']['output']; isSSOEnabled: Scalars['Boolean']['output']; logFile: LogFileContent; logFiles: Array; @@ -1912,13 +2241,16 @@ export type Query = { online: Scalars['Boolean']['output']; owner: Owner; parityHistory: Array; + /** Retrieve a plugin installation operation by identifier */ + pluginInstallOperation?: Maybe; + /** List all tracked plugin installation operations */ + pluginInstallOperations: Array; /** List all installed plugins with their metadata */ plugins: Array; /** Preview the effective permissions for a combination of roles and explicit permissions */ previewEffectivePermissions: Array; /** Get public OIDC provider information for login buttons */ publicOidcProviders: Array; - publicPartnerInfo?: Maybe; publicTheme: Theme; rclone: RCloneBackupSettings; registration?: Maybe; @@ -1928,6 +2260,10 @@ export type Query = { services: Array; settings: Settings; shares: Array; + /** Retrieve current system time configuration */ + systemTime: SystemTime; + /** Retrieve available time zone options */ + timeZoneOptions: Array; upsConfiguration: UpsConfiguration; upsDeviceById?: Maybe; upsDevices: Array; @@ -1966,6 +2302,11 @@ export type QueryOidcProviderArgs = { }; +export type QueryPluginInstallOperationArgs = { + operationId: Scalars['ID']['input']; +}; + + export type QueryPreviewEffectivePermissionsArgs = { permissions?: InputMaybe>; roles?: InputMaybe>; @@ -2168,6 +2509,8 @@ export enum Role { export type Server = Node & { __typename?: 'Server'; apikey: Scalars['String']['output']; + /** Server description/comment */ + comment?: Maybe; guid: Scalars['String']['output']; id: Scalars['PrefixedID']['output']; lanip: Scalars['String']['output']; @@ -2260,6 +2603,7 @@ export type SsoSettings = Node & { export type Subscription = { __typename?: 'Subscription'; arraySubscription: UnraidArray; + displaySubscription: InfoDisplay; dockerContainerStats: DockerContainerStats; logFile: LogFileContent; notificationAdded: Notification; @@ -2267,8 +2611,10 @@ export type Subscription = { notificationsWarningsAndAlerts: Array; ownerSubscription: Owner; parityHistorySubscription: ParityCheck; + pluginInstallUpdates: PluginInstallEvent; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; @@ -2278,6 +2624,37 @@ export type SubscriptionLogFileArgs = { path: Scalars['String']['input']; }; + +export type SubscriptionPluginInstallUpdatesArgs = { + operationId: Scalars['ID']['input']; +}; + +export type SystemConfig = { + __typename?: 'SystemConfig'; + comment?: Maybe; + model?: Maybe; + serverName?: Maybe; +}; + +export type SystemConfigInput = { + comment?: InputMaybe; + model?: InputMaybe; + serverName?: InputMaybe; +}; + +/** System time configuration and current status */ +export type SystemTime = { + __typename?: 'SystemTime'; + /** Current server time in ISO-8601 format (UTC) */ + currentTime: Scalars['String']['output']; + /** Configured NTP servers (empty strings indicate unused slots) */ + ntpServers: Array; + /** IANA timezone identifier currently in use */ + timeZone: Scalars['String']['output']; + /** Whether NTP/PTP time synchronization is enabled */ + useNtp: Scalars['Boolean']['output']; +}; + /** Tailscale exit node connection status */ export type TailscaleExitNodeStatus = { __typename?: 'TailscaleExitNodeStatus'; @@ -2360,6 +2737,15 @@ export enum ThemeName { WHITE = 'white' } +/** Selectable timezone option from the system list */ +export type TimeZoneOption = { + __typename?: 'TimeZoneOption'; + /** Display label for the timezone */ + label: Scalars['String']['output']; + /** IANA timezone identifier */ + value: Scalars['String']['output']; +}; + export type UpsBattery = { __typename?: 'UPSBattery'; /** Battery charge level as a percentage (0-100). Unit: percent (%). Example: 100 means battery is fully charged */ @@ -2522,6 +2908,27 @@ export type UnraidArray = Node & { state: ArrayState; }; +/** Unraid plugin management mutations */ +export type UnraidPluginsMutations = { + __typename?: 'UnraidPluginsMutations'; + /** Install an Unraid language pack and track installation progress */ + installLanguage: PluginInstallOperation; + /** Install an Unraid plugin and track installation progress */ + installPlugin: PluginInstallOperation; +}; + + +/** Unraid plugin management mutations */ +export type UnraidPluginsMutationsInstallLanguageArgs = { + input: InstallPluginInput; +}; + + +/** Unraid plugin management mutations */ +export type UnraidPluginsMutationsInstallPluginArgs = { + input: InstallPluginInput; +}; + export type UpdateApiKeyInput = { description?: InputMaybe; id: Scalars['PrefixedID']['input']; @@ -2540,6 +2947,12 @@ export type UpdateSettingsResponse = { warnings?: Maybe>; }; +export type UpdateSshInput = { + enabled: Scalars['Boolean']['input']; + /** SSH Port (default 22) */ + port: Scalars['Int']['input']; +}; + /** Update status of a container. */ export enum UpdateStatus { REBUILD_READY = 'REBUILD_READY', @@ -2548,6 +2961,17 @@ export enum UpdateStatus { UP_TO_DATE = 'UP_TO_DATE' } +export type UpdateSystemTimeInput = { + /** Manual date/time to apply when disabling NTP, expected format YYYY-MM-DD HH:mm:ss */ + manualDateTime?: InputMaybe; + /** Ordered list of up to four NTP servers. Supply empty strings to clear positions. */ + ntpServers?: InputMaybe>; + /** New IANA timezone identifier to apply */ + timeZone?: InputMaybe; + /** Enable or disable NTP-based synchronization */ + useNtp?: InputMaybe; +}; + export type Uptime = { __typename?: 'Uptime'; timestamp?: Maybe; diff --git a/api/src/unraid-api/config/api-config.module.ts b/api/src/unraid-api/config/api-config.module.ts index a3daf5f88a..53b3ca0937 100644 --- a/api/src/unraid-api/config/api-config.module.ts +++ b/api/src/unraid-api/config/api-config.module.ts @@ -8,6 +8,7 @@ import { csvStringToArray } from '@unraid/shared/util/data.js'; import { isConnectPluginInstalled } from '@app/connect-plugin-cleanup.js'; import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js'; +import { OnboardingTrackerModule } from '@app/unraid-api/config/onboarding-tracker.module.js'; export { type ApiConfig }; @@ -118,7 +119,8 @@ export class ApiConfigPersistence // apiConfig should be registered in root config in app.module.ts, not here. @Module({ + imports: [OnboardingTrackerModule], providers: [ApiConfigPersistence], - exports: [ApiConfigPersistence], + exports: [ApiConfigPersistence, OnboardingTrackerModule], }) export class ApiConfigModule {} diff --git a/api/src/unraid-api/config/api-config.test.ts b/api/src/unraid-api/config/api-config.test.ts index ede0b15149..231306488d 100644 --- a/api/src/unraid-api/config/api-config.test.ts +++ b/api/src/unraid-api/config/api-config.test.ts @@ -1,11 +1,17 @@ import { ConfigService } from '@nestjs/config'; +import { readFile } from 'fs/promises'; +import path from 'path'; +import type { ApiConfig } from '@unraid/shared/services/api-config.js'; +import { writeFile as atomicWriteFile } from 'atomically'; +import { Subject } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js'; import { ApiConfigPersistence, loadApiConfig } from '@app/unraid-api/config/api-config.module.js'; +import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; +import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; -// Mock file utilities vi.mock('@app/core/utils/files/file-exists.js', () => ({ fileExists: vi.fn(), })); @@ -14,185 +20,266 @@ vi.mock('@unraid/shared/util/file.js', () => ({ fileExists: vi.fn(), })); -// Mock fs/promises for file I/O operations vi.mock('fs/promises', () => ({ readFile: vi.fn(), + readdir: vi.fn(), + access: vi.fn(), writeFile: vi.fn(), + unlink: vi.fn(), })); +const mockEmhttpState = { var: { regState: 'PRO' } } as any; +const mockPathsState = { activationBase: '/activation' } as any; + +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: vi.fn(() => mockEmhttpState), + paths: vi.fn(() => mockPathsState), + }, +})); + +vi.mock('atomically', () => ({ + writeFile: vi.fn(), +})); + +const mockReadFile = vi.mocked(readFile); +const mockAtomicWriteFile = vi.mocked(atomicWriteFile); + +const createOnboardingTracker = (configService: ConfigService) => { + const overrides = new OnboardingOverrideService(); + return new OnboardingTrackerService(configService, overrides); +}; + describe('ApiConfigPersistence', () => { let service: ApiConfigPersistence; let configService: ConfigService; + let configChanges$: Subject<{ path?: string }>; + let setMock: ReturnType; + let getMock: ReturnType; beforeEach(() => { + configChanges$ = new Subject<{ path?: string }>(); + setMock = vi.fn(); + getMock = vi.fn(); + configService = { - get: vi.fn(), - set: vi.fn(), + get: getMock, + set: setMock, getOrThrow: vi.fn().mockReturnValue('test-config-path'), + changes$: configChanges$, } as any; service = new ApiConfigPersistence(configService); }); - describe('required ConfigFilePersister methods', () => { - it('should return correct file name', () => { - expect(service.fileName()).toBe('api.json'); - }); + it('should return correct file name', () => { + expect(service.fileName()).toBe('api.json'); + }); - it('should return correct config key', () => { - expect(service.configKey()).toBe('api'); - }); + it('should return correct config key', () => { + expect(service.configKey()).toBe('api'); + }); - it('should return default config', () => { - const defaultConfig = service.defaultConfig(); - expect(defaultConfig).toEqual({ - version: expect.any(String), - extraOrigins: [], - sandbox: false, - ssoSubIds: [], - plugins: [], - }); + it('should return default config', () => { + const defaultConfig = service.defaultConfig(); + expect(defaultConfig).toEqual({ + version: API_VERSION, + extraOrigins: [], + sandbox: false, + ssoSubIds: [], + plugins: [], }); + }); - it('should migrate config from legacy format', async () => { - const mockLegacyConfig = { - local: { sandbox: 'yes' }, - api: { extraOrigins: 'https://example.com,https://test.com' }, - remote: { ssoSubIds: 'sub1,sub2' }, - }; - - vi.mocked(configService.get).mockReturnValue(mockLegacyConfig); + it('should migrate config from legacy format', async () => { + const legacyConfig = { + local: { sandbox: 'yes' }, + api: { extraOrigins: 'https://example.com,https://test.com' }, + remote: { ssoSubIds: 'sub1,sub2' }, + }; + + getMock.mockImplementation((key: string) => { + if (key === 'store.config') { + return legacyConfig; + } + return undefined; + }); - const result = await service.migrateConfig(); + const result = await service.migrateConfig(); - expect(result).toEqual({ - version: expect.any(String), - extraOrigins: ['https://example.com', 'https://test.com'], - sandbox: true, - ssoSubIds: ['sub1', 'sub2'], - plugins: [], - }); + expect(result).toEqual({ + version: API_VERSION, + extraOrigins: ['https://example.com', 'https://test.com'], + sandbox: true, + ssoSubIds: ['sub1', 'sub2'], + plugins: [], }); }); - describe('convertLegacyConfig', () => { - it('should migrate sandbox from string "yes" to boolean true', () => { - const legacyConfig = { - local: { sandbox: 'yes' }, - api: { extraOrigins: '' }, - remote: { ssoSubIds: '' }, - }; + it('sets api.version on bootstrap', async () => { + await service.onApplicationBootstrap(); + expect(setMock).toHaveBeenCalledWith('api.version', API_VERSION); + }); +}); - const result = service.convertLegacyConfig(legacyConfig); +describe('OnboardingTracker', () => { + const trackerPath = path.join(PATHS_CONFIG_MODULES, 'onboarding-tracker.json'); + const dataDir = '/tmp/unraid-data'; + const versionFilePath = path.join(dataDir, 'unraid-version'); + let configService: ConfigService; + let setMock: ReturnType; + let configStore: Record; - expect(result.sandbox).toBe(true); + beforeEach(() => { + configStore = {}; + setMock = vi.fn((key: string, value: unknown) => { + configStore[key] = value; }); + configStore['PATHS_UNRAID_DATA'] = dataDir; + configService = { + set: setMock, + get: vi.fn((key: string) => configStore[key]), + getOrThrow: vi.fn(), + } as any; - it('should migrate sandbox from string "no" to boolean false', () => { - const legacyConfig = { - local: { sandbox: 'no' }, - api: { extraOrigins: '' }, - remote: { ssoSubIds: '' }, - }; + mockReadFile.mockReset(); + mockAtomicWriteFile.mockReset(); - const result = service.convertLegacyConfig(legacyConfig); + mockEmhttpState.var.regState = 'PRO'; + mockPathsState.activationBase = '/activation'; + }); - expect(result.sandbox).toBe(false); + it('returns not 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'; + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); }); - it('should migrate extraOrigins from comma-separated string to array', () => { - const legacyConfig = { - local: { sandbox: 'no' }, - api: { extraOrigins: 'https://example.com,https://test.com' }, - remote: { ssoSubIds: '' }, - }; + const tracker = createOnboardingTracker(configService); + await tracker.onApplicationBootstrap(); - const result = service.convertLegacyConfig(legacyConfig); + const state = tracker.getState(); + expect(state.completed).toBe(false); + expect(state.completedAtVersion).toBeUndefined(); + }); - expect(result.extraOrigins).toEqual(['https://example.com', 'https://test.com']); + it('returns completed state when previously marked', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === trackerPath) { + return JSON.stringify({ + completed: true, + completedAtVersion: '7.1.0', + }); + } + if (filePath === versionFilePath) { + return 'version="7.2.0"\n'; + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); }); - it('should filter out non-HTTP origins from extraOrigins', () => { - const legacyConfig = { - local: { sandbox: 'no' }, - api: { - extraOrigins: 'https://example.com,invalid-origin,http://test.com,ftp://bad.com', - }, - remote: { ssoSubIds: '' }, - }; + const tracker = createOnboardingTracker(configService); + await tracker.onApplicationBootstrap(); - const result = service.convertLegacyConfig(legacyConfig); + const state = tracker.getState(); + expect(state.completed).toBe(true); + expect(state.completedAtVersion).toBe('7.1.0'); + }); - expect(result.extraOrigins).toEqual(['https://example.com', 'http://test.com']); + it('marks onboarding as completed with current version', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === versionFilePath) { + return 'version="7.2.0"\n'; + } + if (filePath === trackerPath) { + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); }); - it('should handle empty extraOrigins string', () => { - const legacyConfig = { - local: { sandbox: 'no' }, - api: { extraOrigins: '' }, - remote: { ssoSubIds: '' }, - }; + const tracker = createOnboardingTracker(configService); + await tracker.onApplicationBootstrap(); - const result = service.convertLegacyConfig(legacyConfig); + const result = await tracker.markCompleted(); - expect(result.extraOrigins).toEqual([]); - }); - - it('should migrate ssoSubIds from comma-separated string to array', () => { - const legacyConfig = { - local: { sandbox: 'no' }, - api: { extraOrigins: '' }, - remote: { ssoSubIds: 'user1,user2,user3' }, - }; - - const result = service.convertLegacyConfig(legacyConfig); + expect(result.completed).toBe(true); + expect(result.completedAtVersion).toBe('7.2.0'); + expect(mockAtomicWriteFile).toHaveBeenCalledWith( + trackerPath, + expect.stringContaining('"completed": true'), + { mode: 0o644 } + ); + }); - expect(result.ssoSubIds).toEqual(['user1', 'user2', 'user3']); + it('resets onboarding state', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === trackerPath) { + return JSON.stringify({ + completed: true, + completedAtVersion: '7.1.0', + }); + } + if (filePath === versionFilePath) { + return 'version="7.2.0"\n'; + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); }); - it('should handle empty ssoSubIds string', () => { - const legacyConfig = { - local: { sandbox: 'no' }, - api: { extraOrigins: '' }, - remote: { ssoSubIds: '' }, - }; + const tracker = createOnboardingTracker(configService); + await tracker.onApplicationBootstrap(); - const result = service.convertLegacyConfig(legacyConfig); + const result = await tracker.reset(); - expect(result.ssoSubIds).toEqual([]); - }); + expect(result.completed).toBe(false); + expect(result.completedAtVersion).toBeUndefined(); + }); - it('should handle undefined config sections', () => { - const legacyConfig = {}; + it('handles missing version file gracefully', async () => { + mockReadFile.mockRejectedValue(new Error('permission denied')); - const result = service.convertLegacyConfig(legacyConfig); + const tracker = createOnboardingTracker(configService); + await tracker.onApplicationBootstrap(); - expect(result.sandbox).toBe(false); - expect(result.extraOrigins).toEqual([]); - expect(result.ssoSubIds).toEqual([]); + expect(setMock).toHaveBeenCalledWith('onboardingTracker.currentVersion', undefined); + }); + + it('respects override state', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === trackerPath) { + return JSON.stringify({ + completed: false, + completedAtVersion: undefined, + }); + } + if (filePath === versionFilePath) { + return 'version="7.2.0"\n'; + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); }); - it('should handle complete migration with all fields', () => { - const legacyConfig = { - local: { sandbox: 'yes' }, - api: { extraOrigins: 'https://app1.example.com,https://app2.example.com' }, - remote: { ssoSubIds: 'sub1,sub2,sub3' }, - }; - - const result = service.convertLegacyConfig(legacyConfig); - - expect(result.sandbox).toBe(true); - expect(result.extraOrigins).toEqual([ - 'https://app1.example.com', - 'https://app2.example.com', - ]); - expect(result.ssoSubIds).toEqual(['sub1', 'sub2', 'sub3']); + const overrides = new OnboardingOverrideService(); + overrides.setState({ + onboarding: { + completed: true, + completedAtVersion: '6.12.0', + }, }); + + const tracker = new OnboardingTrackerService(configService, overrides); + await tracker.onApplicationBootstrap(); + + const state = tracker.getState(); + expect(state.completed).toBe(true); + expect(state.completedAtVersion).toBe('6.12.0'); }); }); describe('loadApiConfig', () => { - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks(); }); @@ -200,7 +287,7 @@ describe('loadApiConfig', () => { const result = await loadApiConfig(); expect(result).toEqual({ - version: expect.any(String), + version: API_VERSION, extraOrigins: [], sandbox: false, ssoSubIds: [], @@ -212,7 +299,7 @@ describe('loadApiConfig', () => { const result = await loadApiConfig(); expect(result).toEqual({ - version: expect.any(String), + version: API_VERSION, extraOrigins: [], sandbox: false, ssoSubIds: [], diff --git a/api/src/unraid-api/config/onboarding-override.model.ts b/api/src/unraid-api/config/onboarding-override.model.ts new file mode 100644 index 0000000000..f7280173bd --- /dev/null +++ b/api/src/unraid-api/config/onboarding-override.model.ts @@ -0,0 +1,26 @@ +import type { + ActivationCode, + PublicPartnerInfo, +} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import type { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; + +/** + * Simplified onboarding override state for testing. + */ +export type OnboardingOverride = { + /** Whether onboarding has been completed */ + completed?: boolean; + /** The OS version when onboarding was completed */ + completedAtVersion?: string | null; +}; + +export type OnboardingOverrideState = { + /** Override for onboarding completion state */ + onboarding?: OnboardingOverride; + /** Override for activation code data */ + activationCode?: ActivationCode | null; + /** Override for partner info */ + partnerInfo?: PublicPartnerInfo | null; + /** Override for registration state */ + registrationState?: RegistrationState; +}; diff --git a/api/src/unraid-api/config/onboarding-override.module.ts b/api/src/unraid-api/config/onboarding-override.module.ts new file mode 100644 index 0000000000..71d6146a40 --- /dev/null +++ b/api/src/unraid-api/config/onboarding-override.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; + +@Module({ + providers: [OnboardingOverrideService], + exports: [OnboardingOverrideService], +}) +export class OnboardingOverrideModule {} diff --git a/api/src/unraid-api/config/onboarding-override.service.ts b/api/src/unraid-api/config/onboarding-override.service.ts new file mode 100644 index 0000000000..f51b233123 --- /dev/null +++ b/api/src/unraid-api/config/onboarding-override.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; + +import type { OnboardingOverrideState } from '@app/unraid-api/config/onboarding-override.model.js'; + +@Injectable() +export class OnboardingOverrideService { + private state: OnboardingOverrideState | null = null; + + getState(): OnboardingOverrideState | null { + return this.state; + } + + setState(state: OnboardingOverrideState): void { + this.state = state; + } + + clearState(): void { + this.state = null; + } +} diff --git a/api/src/unraid-api/config/onboarding-state.module.ts b/api/src/unraid-api/config/onboarding-state.module.ts new file mode 100644 index 0000000000..00ad6965c2 --- /dev/null +++ b/api/src/unraid-api/config/onboarding-state.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { OnboardingOverrideModule } from '@app/unraid-api/config/onboarding-override.module.js'; +import { OnboardingStateService } from '@app/unraid-api/config/onboarding-state.service.js'; + +@Module({ + imports: [OnboardingOverrideModule], + providers: [OnboardingStateService], + exports: [OnboardingStateService], +}) +export class OnboardingStateModule {} diff --git a/api/src/unraid-api/config/onboarding-state.service.spec.ts b/api/src/unraid-api/config/onboarding-state.service.spec.ts new file mode 100644 index 0000000000..ba8216d811 --- /dev/null +++ b/api/src/unraid-api/config/onboarding-state.service.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { OnboardingStateService } from '@app/unraid-api/config/onboarding-state.service.js'; +import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; + +describe('OnboardingStateService', () => { + const createService = () => { + const overrides = { + getState: vi.fn().mockReturnValue(null), + }; + + return new OnboardingStateService(overrides as any); + }; + + it.each([ + RegistrationState.ENOKEYFILE, + RegistrationState.ENOKEYFILE1, + RegistrationState.ENOKEYFILE2, + ])('requiresActivationStep returns true for %s', (state) => { + const service = createService(); + expect(service.requiresActivationStep(state)).toBe(true); + }); + + it('requiresActivationStep returns false for non-activation states', () => { + const service = createService(); + expect(service.requiresActivationStep(RegistrationState.BASIC)).toBe(false); + }); + + it('activationRequired uses activation-step states, including ENOKEYFILE1/2', async () => { + const service = createService(); + vi.spyOn(service, 'hasActivationCode').mockResolvedValue(true); + vi.spyOn(service, 'getRegistrationState').mockReturnValue(RegistrationState.ENOKEYFILE1); + + await expect(service.activationRequired()).resolves.toBe(true); + }); +}); diff --git a/api/src/unraid-api/config/onboarding-state.service.ts b/api/src/unraid-api/config/onboarding-state.service.ts new file mode 100644 index 0000000000..a872f76c8a --- /dev/null +++ b/api/src/unraid-api/config/onboarding-state.service.ts @@ -0,0 +1,100 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import type { ActivationStepContext } from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; +import { getters } from '@app/store/index.js'; +import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; +import { + findActivationCodeFileInDirs, + getActivationDirCandidates, +} from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; +import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; + +const REGISTERED_STATES = new Set([ + RegistrationState.TRIAL, + RegistrationState.BASIC, + RegistrationState.PLUS, + RegistrationState.PRO, + RegistrationState.STARTER, + RegistrationState.UNLEASHED, + RegistrationState.LIFETIME, +]); + +const ACTIVATION_STEP_STATES = new Set([ + RegistrationState.ENOKEYFILE, + RegistrationState.ENOKEYFILE1, + RegistrationState.ENOKEYFILE2, +]); + +@Injectable() +export class OnboardingStateService { + private readonly logger = new Logger(OnboardingStateService.name); + + constructor(private readonly onboardingOverrides: OnboardingOverrideService) {} + + getRegistrationState(): RegistrationState | undefined { + const override = this.onboardingOverrides.getState(); + if (override?.registrationState !== undefined) { + return override.registrationState; + } + + return (getters.emhttp().var?.regState as RegistrationState | undefined) ?? undefined; + } + + isFreshInstall(regState: RegistrationState | undefined = this.getRegistrationState()): boolean { + if (!regState) { + return false; + } + // Only ENOKEYFILE (without number suffix) indicates a fresh install. + // ENOKEYFILE1 and ENOKEYFILE2 are error states that can occur on existing installations. + return regState === RegistrationState.ENOKEYFILE; + } + + requiresActivationStep( + regState: RegistrationState | undefined = this.getRegistrationState() + ): boolean { + if (!regState) { + return false; + } + return ACTIVATION_STEP_STATES.has(regState); + } + + isRegistered(regState: RegistrationState | undefined = this.getRegistrationState()): boolean { + if (!regState) { + return false; + } + return REGISTERED_STATES.has(regState); + } + + async hasActivationCode(): Promise { + const override = this.onboardingOverrides.getState(); + if (override?.activationCode !== undefined) { + return Boolean(override.activationCode); + } + + const paths = getters.paths?.() ?? {}; + const activationBase = paths.activationBase as string | undefined; + if (!activationBase) { + return false; + } + + const activationPath = await findActivationCodeFileInDirs( + getActivationDirCandidates(activationBase), + '.activationcode', + this.logger + ); + return Boolean(activationPath); + } + + async activationRequired(): Promise { + return (await this.hasActivationCode()) && this.requiresActivationStep(); + } + + async getActivationStepContext(): Promise { + const regState = this.getRegistrationState(); + const hasActivationCode = await this.hasActivationCode(); + return { + hasActivationCode, + regState, + }; + } +} diff --git a/api/src/unraid-api/config/onboarding-tracker.model.ts b/api/src/unraid-api/config/onboarding-tracker.model.ts new file mode 100644 index 0000000000..42d5153778 --- /dev/null +++ b/api/src/unraid-api/config/onboarding-tracker.model.ts @@ -0,0 +1,10 @@ +/** + * Simplified onboarding tracker state. + * Tracks whether onboarding has been completed and at which version. + */ +export type TrackerState = { + /** Whether the onboarding flow has been completed */ + completed?: boolean; + /** The OS version when onboarding was completed (for future upgrade detection) */ + completedAtVersion?: string; +}; diff --git a/api/src/unraid-api/config/onboarding-tracker.module.ts b/api/src/unraid-api/config/onboarding-tracker.module.ts new file mode 100644 index 0000000000..0b988827bc --- /dev/null +++ b/api/src/unraid-api/config/onboarding-tracker.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { OnboardingOverrideModule } from '@app/unraid-api/config/onboarding-override.module.js'; +import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.service.js'; + +export { OnboardingTrackerService }; + +@Module({ + imports: [OnboardingOverrideModule], + providers: [OnboardingTrackerService], + exports: [OnboardingTrackerService], +}) +export class OnboardingTrackerModule {} diff --git a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts new file mode 100644 index 0000000000..4a17584954 --- /dev/null +++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts @@ -0,0 +1,89 @@ +import { ConfigService } from '@nestjs/config'; +import { readFile } from 'fs/promises'; + +import { writeFile as atomicWriteFile } from 'atomically'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; +import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.service.js'; + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), +})); + +vi.mock('atomically', () => ({ + writeFile: vi.fn(), +})); + +const mockReadFile = vi.mocked(readFile); +const mockAtomicWriteFile = vi.mocked(atomicWriteFile); + +const createConfigService = (dataDir = '/tmp/unraid-data') => { + const set = vi.fn(); + const get = vi.fn((key: string) => { + if (key === 'PATHS_UNRAID_DATA') { + return dataDir; + } + return undefined; + }); + + return { + set, + get, + } as unknown as ConfigService; +}; + +describe('OnboardingTrackerService write retries', () => { + beforeEach(() => { + mockReadFile.mockReset(); + mockAtomicWriteFile.mockReset(); + }); + + it('retries failed writes and succeeds on a later attempt', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + mockAtomicWriteFile + .mockRejectedValueOnce(new Error('transient-write-failure-1')) + .mockRejectedValueOnce(new Error('transient-write-failure-2')) + .mockResolvedValue(undefined as never); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + const result = await tracker.markCompleted(); + + expect(result.completed).toBe(true); + expect(result.completedAtVersion).toBe('7.2.0'); + expect(mockAtomicWriteFile).toHaveBeenCalledTimes(3); + }); + + it('throws when all write retries fail', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + mockAtomicWriteFile.mockImplementation(async () => + Promise.reject(new Error('persistent-write-failure')) + ); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + await expect(tracker.markCompleted()).rejects.toThrow('persistent-write-failure'); + expect(mockAtomicWriteFile).toHaveBeenCalledTimes(3); + }); +}); diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts new file mode 100644 index 0000000000..a7fb9d785f --- /dev/null +++ b/api/src/unraid-api/config/onboarding-tracker.service.ts @@ -0,0 +1,202 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { readFile } from 'fs/promises'; +import path from 'path'; + +import { writeFile } from 'atomically'; + +import type { TrackerState } from '@app/unraid-api/config/onboarding-tracker.model.js'; +import { PATHS_CONFIG_MODULES } from '@app/environment.js'; +import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; + +const TRACKER_FILE_NAME = 'onboarding-tracker.json'; +const CONFIG_PREFIX = 'onboardingTracker'; +const DEFAULT_OS_VERSION_FILE_PATH = '/etc/unraid-version'; +const WRITE_RETRY_ATTEMPTS = 3; +const WRITE_RETRY_DELAY_MS = 100; + +/** + * Simplified onboarding tracker service. + * Tracks whether onboarding has been completed and at which version. + */ +@Injectable() +export class OnboardingTrackerService implements OnApplicationBootstrap { + private readonly logger = new Logger(OnboardingTrackerService.name); + private readonly trackerPath = path.join(PATHS_CONFIG_MODULES, TRACKER_FILE_NAME); + private state: TrackerState = {}; + private currentVersion?: string; + private readonly versionFilePath: string; + + constructor( + private readonly configService: ConfigService, + private readonly onboardingOverrides: OnboardingOverrideService + ) { + const unraidDataDir = this.configService.get('PATHS_UNRAID_DATA'); + this.versionFilePath = unraidDataDir + ? path.join(unraidDataDir, 'unraid-version') + : DEFAULT_OS_VERSION_FILE_PATH; + } + + async onApplicationBootstrap() { + this.currentVersion = await this.readCurrentVersion(); + const previousState = await this.readTrackerState(); + this.state = previousState ?? {}; + this.syncConfig(); + } + + /** + * Get the current onboarding state. + */ + getState(): { completed: boolean; completedAtVersion?: string } { + // Check for override first (for testing) + const overrideState = this.onboardingOverrides.getState(); + if (overrideState?.onboarding !== undefined) { + return { + completed: overrideState.onboarding.completed ?? false, + completedAtVersion: overrideState.onboarding.completedAtVersion ?? undefined, + }; + } + + return { + completed: this.state.completed ?? false, + completedAtVersion: this.state.completedAtVersion, + }; + } + + /** + * Check if onboarding is completed. + */ + isCompleted(): boolean { + return this.getState().completed; + } + + /** + * Get the version at which onboarding was completed. + */ + getCompletedAtVersion(): string | undefined { + return this.getState().completedAtVersion; + } + + /** + * Get the current OS version. + */ + getCurrentVersion(): string | undefined { + return this.currentVersion; + } + + /** + * Mark onboarding as completed for the current OS version. + */ + async markCompleted(): Promise<{ completed: boolean; completedAtVersion?: string }> { + // Check for override first + const overrideState = this.onboardingOverrides.getState(); + if (overrideState?.onboarding !== undefined) { + const updatedOverride = { + ...overrideState, + onboarding: { + ...overrideState.onboarding, + completed: true, + completedAtVersion: + this.currentVersion ?? overrideState.onboarding.completedAtVersion, + }, + }; + this.onboardingOverrides.setState(updatedOverride); + return this.getState(); + } + + const updatedState: TrackerState = { + completed: true, + completedAtVersion: this.currentVersion, + }; + + await this.writeTrackerState(updatedState); + this.syncConfig(); + + return this.getState(); + } + + /** + * Reset onboarding state (for testing). + */ + async reset(): Promise<{ completed: boolean; completedAtVersion?: string }> { + // Check for override first + const overrideState = this.onboardingOverrides.getState(); + if (overrideState?.onboarding !== undefined) { + const updatedOverride = { + ...overrideState, + onboarding: { + ...overrideState.onboarding, + completed: false, + completedAtVersion: undefined, + }, + }; + this.onboardingOverrides.setState(updatedOverride); + return this.getState(); + } + + const updatedState: TrackerState = { + completed: false, + completedAtVersion: undefined, + }; + + await this.writeTrackerState(updatedState); + this.syncConfig(); + + return this.getState(); + } + + private syncConfig() { + this.configService.set(`${CONFIG_PREFIX}.completed`, this.state.completed); + this.configService.set(`${CONFIG_PREFIX}.completedAtVersion`, this.state.completedAtVersion); + this.configService.set(`${CONFIG_PREFIX}.currentVersion`, this.currentVersion); + } + + private async readCurrentVersion(): Promise { + try { + const contents = await readFile(this.versionFilePath, 'utf8'); + const match = contents.match(/^\s*version\s*=\s*"([^"]+)"\s*$/m); + return match?.[1]?.trim() || undefined; + } catch (error) { + this.logger.debug( + `Failed to read current OS version from ${this.versionFilePath}: ${error}` + ); + return undefined; + } + } + + private async readTrackerState(): Promise { + try { + const content = await readFile(this.trackerPath, 'utf8'); + return JSON.parse(content) as TrackerState; + } catch (error) { + this.logger.debug( + `Unable to read onboarding tracker state at ${this.trackerPath}: ${error}` + ); + return undefined; + } + } + + private async writeTrackerState(state: TrackerState): Promise { + let lastError: unknown = null; + + for (let attempt = 1; attempt <= WRITE_RETRY_ATTEMPTS; attempt += 1) { + try { + await writeFile(this.trackerPath, JSON.stringify(state, null, 2), { mode: 0o644 }); + this.state = state; + return; + } catch (error) { + lastError = error; + this.logger.error( + `Failed to persist onboarding tracker state (attempt ${attempt}/${WRITE_RETRY_ATTEMPTS}): ${error}` + ); + if (attempt < WRITE_RETRY_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, WRITE_RETRY_DELAY_MS)); + } + } + } + + throw lastError instanceof Error + ? lastError + : new Error('Failed to persist onboarding tracker state'); + } +} diff --git a/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts b/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts index b6de9a519c..a3ea1de5ed 100644 --- a/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts +++ b/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts @@ -1,7 +1,10 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsIn, IsOptional, IsString, IsUrl } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsIn, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator'; + +import { Language } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; +import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; // Helper function to check if a string is a valid hex color const isHexColor = (value: string): boolean => /^#([0-9A-F]{3}){1,2}$/i.test(value); @@ -30,88 +33,91 @@ const sanitizeAndValidateHexColor = (value: any): string => { return ''; // Return empty string if not a valid hex color after potential modification }; +/** + * Represents a custom link provided by partners + */ @ObjectType() -export class PublicPartnerInfo { - @Field(() => String, { nullable: true }) - @IsOptional() +export class PartnerLink { + @Field(() => String, { description: 'Display title for the link' }) @IsString() - @Transform(({ value }) => sanitizeString(value)) - partnerName?: string | null; - - @Field(() => Boolean, { description: 'Indicates if a partner logo exists' }) - @IsBoolean() - hasPartnerLogo?: boolean | null; + @Transform(({ value }) => sanitizeString(value, 100)) + title!: string; - @Field(() => String, { nullable: true }) - @IsOptional() + @Field(() => String, { description: 'The URL' }) @IsString() + @IsUrl({}, { message: 'Must be a valid URL' }) @Transform(({ value }) => sanitizeString(value)) - partnerUrl?: string | null; - - @Field(() => String, { - nullable: true, - description: - 'The path to the partner logo image on the flash drive, relative to the activation code file', - }) - @IsOptional() - @IsString() - @Transform(({ value }) => sanitizeString(value)) - partnerLogoUrl?: string | null; + url!: string; } @ObjectType() -export class ActivationCode { +export class PartnerConfig { @Field(() => String, { nullable: true }) @IsOptional() @IsString() @Transform(({ value }) => sanitizeString(value)) - code?: string; + name?: string; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @Transform(({ value }) => sanitizeString(value)) - partnerName?: string; + url?: string; - @Field(() => String, { nullable: true }) + @Field(() => String, { + nullable: true, + description: 'Link to hardware specifications for this system', + }) @IsOptional() @IsString() @Transform(({ value }) => sanitizeString(value)) - partnerUrl?: string; + hardwareSpecsUrl?: string; - @Field(() => String, { nullable: true }) + @Field(() => String, { + nullable: true, + description: 'Link to the system manual/documentation', + }) @IsOptional() @IsString() - @Transform(({ value }) => sanitizeString(value, 15)) - serverName?: string; + @Transform(({ value }) => sanitizeString(value)) + manualUrl?: string; - @Field(() => String, { nullable: true }) + @Field(() => String, { + nullable: true, + description: 'Link to manufacturer support page', + }) @IsOptional() @IsString() @Transform(({ value }) => sanitizeString(value)) - sysModel?: string; + supportUrl?: string; - @Field(() => String, { nullable: true }) + @Field(() => [PartnerLink], { + nullable: true, + description: 'Additional custom links provided by the partner', + }) @IsOptional() - @IsString() - @Transform(({ value }) => sanitizeString(value)) - comment?: string; + @ValidateNested({ each: true }) + @Type(() => PartnerLink) + extraLinks?: PartnerLink[]; +} +@ObjectType() +export class BrandingConfig { @Field(() => String, { nullable: true }) @IsOptional() - @IsString() // Keep IsString to ensure it's a string after transformation + @IsString() @Transform(({ value }) => sanitizeAndValidateHexColor(value)) header?: string; @Field(() => String, { nullable: true }) @IsOptional() - @IsString() // Keep IsString + @IsString() @Transform(({ value }) => sanitizeAndValidateHexColor(value)) headermetacolor?: string; @Field(() => String, { nullable: true }) @IsOptional() - @IsString() // Keep IsString + @IsString() @Transform(({ value }) => sanitizeAndValidateHexColor(value)) background?: string; @@ -129,6 +135,267 @@ export class ActivationCode { @IsIn(['azure', 'black', 'gray', 'white']) @Transform(({ value }) => sanitizeString(value)) theme?: 'azure' | 'black' | 'gray' | 'white'; + + @Field(() => String, { + nullable: true, + description: 'Banner image source. Supports local path, remote URL, or data URI/base64.', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + bannerImage?: string | null; + + @Field(() => String, { + nullable: true, + description: 'Case model image source. Supports local path, remote URL, or data URI/base64.', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + caseModelImage?: string | null; + + @Field(() => String, { + nullable: true, + description: + 'Partner logo source for light themes (azure/white). Supports local path, remote URL, or data URI/base64.', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + partnerLogoLightUrl?: string | null; + + @Field(() => String, { + nullable: true, + description: + 'Partner logo source for dark themes (black/gray). Supports local path, remote URL, or data URI/base64.', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + partnerLogoDarkUrl?: string | null; + + @Field(() => Boolean, { nullable: true, description: 'Indicates if a partner logo exists' }) + @IsOptional() + @IsBoolean() + hasPartnerLogo?: boolean | null; + + @Field(() => String, { nullable: true, description: 'Custom title for onboarding welcome step' }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingTitle?: string; + + @Field(() => String, { nullable: true, description: 'Custom subtitle for onboarding welcome step' }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingSubtitle?: string; + + @Field(() => String, { + nullable: true, + description: 'Custom title for fresh install onboarding', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingTitleFreshInstall?: string; + + @Field(() => String, { + nullable: true, + description: 'Custom subtitle for fresh install onboarding', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingSubtitleFreshInstall?: string; + + @Field(() => String, { + nullable: true, + description: 'Custom title for upgrade onboarding', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingTitleUpgrade?: string; + + @Field(() => String, { + nullable: true, + description: 'Custom subtitle for upgrade onboarding', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingSubtitleUpgrade?: string; + + @Field(() => String, { + nullable: true, + description: 'Custom title for downgrade onboarding', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingTitleDowngrade?: string; + + @Field(() => String, { + nullable: true, + description: 'Custom subtitle for downgrade onboarding', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingSubtitleDowngrade?: string; + + @Field(() => String, { + nullable: true, + description: 'Custom title for incomplete onboarding', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingTitleIncomplete?: string; + + @Field(() => String, { + nullable: true, + description: 'Custom subtitle for incomplete onboarding', + }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + onboardingSubtitleIncomplete?: string; +} + +@ObjectType() +export class SystemConfig { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value, 15)) + serverName?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + model?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value, 64)) + comment?: string; +} + +@ObjectType() +export class PublicPartnerInfo { + @Field(() => PartnerConfig, { nullable: true }) + partner?: PartnerConfig; + + @Field(() => BrandingConfig, { nullable: true }) + branding?: BrandingConfig; +} + +@ObjectType() +export class ActivationCode { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @Transform(({ value }) => sanitizeString(value)) + code?: string; + + @Field(() => PartnerConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => PartnerConfig) + partner?: PartnerConfig; + + @Field(() => BrandingConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => BrandingConfig) + branding?: BrandingConfig; + + @Field(() => SystemConfig, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => SystemConfig) + system?: SystemConfig; +} + +@ObjectType() +export class OnboardingState { + @Field(() => RegistrationState, { nullable: true }) + registrationState?: RegistrationState; + + @Field(() => Boolean, { description: 'Indicates whether the system is registered' }) + isRegistered!: boolean; + + @Field(() => Boolean, { description: 'Indicates whether the system is a fresh install' }) + isFreshInstall!: boolean; + + @Field(() => Boolean, { description: 'Indicates whether an activation code is present' }) + hasActivationCode!: boolean; + + @Field(() => Boolean, { + description: 'Indicates whether activation is required based on current state', + }) + activationRequired!: boolean; +} + +/** + * Enum representing the current onboarding status. + * Used to determine which onboarding flow/UI to show. + */ +export enum OnboardingStatus { + /** User has not completed onboarding yet */ + INCOMPLETE = 'INCOMPLETE', + /** User completed onboarding on a previous OS version and has since upgraded */ + UPGRADE = 'UPGRADE', + /** User completed onboarding on a newer OS version and has since downgraded */ + DOWNGRADE = 'DOWNGRADE', + /** User has already completed onboarding on the current OS version */ + COMPLETED = 'COMPLETED', +} + +registerEnumType(OnboardingStatus, { + name: 'OnboardingStatus', + description: 'The current onboarding status based on completion state and version relationship', +}); + +@ObjectType({ + description: 'Onboarding completion state and context', +}) +export class Onboarding { + @Field(() => OnboardingStatus, { + description: 'The current onboarding status (INCOMPLETE, UPGRADE, DOWNGRADE, or COMPLETED)', + }) + status!: OnboardingStatus; + + @Field(() => Boolean, { + description: 'Whether this is a partner/OEM build with activation code', + }) + isPartnerBuild!: boolean; + + @Field(() => Boolean, { + description: 'Whether the onboarding flow has been completed', + }) + completed!: boolean; + + @Field(() => String, { + nullable: true, + description: 'The OS version when onboarding was completed', + }) + completedAtVersion?: string; + + @Field(() => String, { + nullable: true, + description: 'The activation code from the .activationcode file, if present', + }) + activationCode?: string; + + @Field(() => OnboardingState, { + description: 'Runtime onboarding state values used by the onboarding flow', + }) + onboardingState!: OnboardingState; } @ObjectType() @@ -136,6 +403,9 @@ export class Customization { @Field(() => ActivationCode, { nullable: true }) activationCode?: ActivationCode; - @Field(() => PublicPartnerInfo, { nullable: true }) - partnerInfo?: PublicPartnerInfo; + @Field(() => Onboarding, { nullable: true }) + onboarding?: Onboarding; + + @Field(() => [Language], { nullable: true }) + availableLanguages?: Language[]; } 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 0000000000..68f51fbdfd --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts @@ -0,0 +1,78 @@ +import { Logger } from '@nestjs/common'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const withLegacyActivationDir = (activationDir: string): string => { + if (activationDir.endsWith('/activate')) { + return `${activationDir.slice(0, -'/activate'.length)}/activation`; + } + if (activationDir.endsWith('/activation')) { + return `${activationDir.slice(0, -'/activation'.length)}/activate`; + } + return `${activationDir}/activate`; +}; + +export const getActivationDirCandidates = (activationDir: string): string[] => { + const legacyDir = withLegacyActivationDir(activationDir); + return Array.from(new Set([activationDir, legacyDir])); +}; + +export async function findActivationCodeFile( + activationDir: string, + extension = '.activationcode', + logger?: Logger +): Promise { + const candidateDirs = getActivationDirCandidates(activationDir); + return findActivationCodeFileInDirs(candidateDirs, extension, logger); +} + +export async function findActivationCodeFileInDirs( + activationDirs: string[], + extension = '.activationcode', + logger?: Logger +): Promise { + try { + for (const activationDir of activationDirs) { + try { + await fs.access(activationDir); + const files = await fs.readdir(activationDir); + const activationFile = files.find((file) => file.endsWith(extension)); + if (activationFile) { + return path.join(activationDir, activationFile); + } + } catch (innerError) { + if ( + innerError instanceof Error && + 'code' in innerError && + innerError.code === 'ENOENT' + ) { + logger?.debug?.( + `Activation directory ${activationDir} not found when searching for activation code.` + ); + continue; + } + if (innerError instanceof Error) { + logger?.error?.( + `Error accessing activation directory ${activationDir} or reading its content.`, + innerError + ); + } + } + } + return null; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + logger?.debug?.(`Activation directories not found when searching for activation code.`); + } else if (error instanceof Error) { + logger?.error?.('Error accessing activation directories or reading their content.', error); + } + return null; + } +} + +export type ActivationStepContext = { + hasActivationCode: boolean; + regState?: string; +}; + +// End of file diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.module.ts b/api/src/unraid-api/graph/resolvers/customization/customization.module.ts index 1df4bb4ba5..b22a6719b3 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.module.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.module.ts @@ -1,11 +1,16 @@ import { Module } from '@nestjs/common'; +import { OnboardingOverrideModule } from '@app/unraid-api/config/onboarding-override.module.js'; +import { OnboardingStateModule } from '@app/unraid-api/config/onboarding-state.module.js'; +import { OnboardingTrackerModule } from '@app/unraid-api/config/onboarding-tracker.module.js'; import { CustomizationMutationsResolver } from '@app/unraid-api/graph/resolvers/customization/customization.mutations.resolver.js'; import { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js'; -import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js'; +import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; +import { InfoModule } from '@app/unraid-api/graph/resolvers/info/info.module.js'; @Module({ - providers: [CustomizationService, CustomizationResolver, CustomizationMutationsResolver], - exports: [CustomizationService], + imports: [OnboardingOverrideModule, OnboardingStateModule, OnboardingTrackerModule, InfoModule], + providers: [OnboardingService, CustomizationResolver, CustomizationMutationsResolver], + exports: [OnboardingService], }) export class CustomizationModule {} diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.spec.ts new file mode 100644 index 0000000000..61ee0c5a6f --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.spec.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CustomizationMutationsResolver } from '@app/unraid-api/graph/resolvers/customization/customization.mutations.resolver.js'; + +describe('CustomizationMutationsResolver', () => { + const onboardingService = { + setTheme: vi.fn(), + }; + + const displayService = { + setLocale: vi.fn(), + }; + + let resolver: CustomizationMutationsResolver; + + beforeEach(() => { + vi.clearAllMocks(); + resolver = new CustomizationMutationsResolver(onboardingService as any, displayService as any); + }); + + it('delegates setTheme to onboardingService', async () => { + const theme = { + name: 'azure', + showBannerImage: true, + showBannerGradient: true, + showHeaderDescription: true, + headerBackgroundColor: null, + headerPrimaryTextColor: null, + headerSecondaryTextColor: null, + }; + onboardingService.setTheme.mockResolvedValue(theme); + + const result = await resolver.setTheme('azure' as any); + + expect(onboardingService.setTheme).toHaveBeenCalledWith('azure'); + expect(result).toEqual(theme); + }); + + it('returns persisted locale from display service', async () => { + displayService.setLocale.mockResolvedValue({ locale: 'fr_FR' }); + + const result = await resolver.setLocale('fr_FR'); + + expect(displayService.setLocale).toHaveBeenCalledWith('fr_FR'); + expect(result).toBe('fr_FR'); + }); + + it('falls back to requested locale when service response omits locale', async () => { + displayService.setLocale.mockResolvedValue({ locale: null }); + + const result = await resolver.setLocale('en_US'); + + expect(result).toBe('en_US'); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.ts index 96e6b7727f..dfde61795b 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.mutations.resolver.ts @@ -3,13 +3,17 @@ import { Args, ResolveField, Resolver } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js'; +import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; +import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; import { CustomizationMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; @Resolver(() => CustomizationMutations) export class CustomizationMutationsResolver { - constructor(private readonly customizationService: CustomizationService) {} + constructor( + private readonly onboardingService: OnboardingService, + private readonly displayService: DisplayService + ) {} @ResolveField(() => Theme, { description: 'Update the UI theme (writes dynamix.cfg)' }) @UsePermissions({ @@ -20,6 +24,19 @@ export class CustomizationMutationsResolver { @Args('theme', { type: () => ThemeName, description: 'Theme to apply' }) theme: ThemeName ): Promise { - return this.customizationService.setTheme(theme); + return this.onboardingService.setTheme(theme); + } + + @ResolveField(() => String, { description: 'Update the display locale (language)' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CUSTOMIZATIONS, + }) + async setLocale( + @Args('locale', { type: () => String, description: 'Locale code to apply (e.g. en_US)' }) + locale: string + ): Promise { + const display = await this.displayService.setLocale(locale); + return display.locale ?? locale; } } diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts new file mode 100644 index 0000000000..eec19de657 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; +import { OnboardingStatus } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js'; +import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; +import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; + +describe('CustomizationResolver', () => { + const onboardingService = { + getActivationData: vi.fn(), + getActivationDataForPublic: vi.fn(), + getPublicPartnerInfo: vi.fn(), + getTheme: vi.fn(), + isFreshInstall: vi.fn(), + getOnboardingState: vi.fn(), + } as unknown as OnboardingService; + const onboardingTracker = { + getState: vi.fn(), + getCurrentVersion: vi.fn(), + } as unknown as OnboardingTrackerService; + const displayService = { + getAvailableLanguages: vi.fn(), + } as unknown as DisplayService; + + const resolver = new CustomizationResolver(onboardingService, onboardingTracker, displayService); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(onboardingTracker.getCurrentVersion).mockReturnValue('7.2.0'); + vi.mocked(onboardingService.getPublicPartnerInfo).mockResolvedValue(null); + vi.mocked(onboardingService.getActivationDataForPublic).mockResolvedValue(null); + vi.mocked(onboardingService.getOnboardingState).mockResolvedValue({ + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }); + }); + + it('returns INCOMPLETE status when not completed', async () => { + vi.mocked(onboardingTracker.getState).mockReturnValue({ + completed: false, + completedAtVersion: undefined, + }); + + const result = await resolver.resolveOnboarding(); + + expect(result).toEqual({ + status: OnboardingStatus.INCOMPLETE, + isPartnerBuild: false, + completed: false, + completedAtVersion: undefined, + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, + }); + }); + + it('returns COMPLETED status when completed on current version', async () => { + vi.mocked(onboardingTracker.getState).mockReturnValue({ + completed: true, + completedAtVersion: '7.2.0', + }); + + const result = await resolver.resolveOnboarding(); + + expect(result).toEqual({ + status: OnboardingStatus.COMPLETED, + isPartnerBuild: false, + completed: true, + completedAtVersion: '7.2.0', + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, + }); + }); + + it('returns UPGRADE status when completed on older version', async () => { + vi.mocked(onboardingTracker.getState).mockReturnValue({ + completed: true, + completedAtVersion: '7.1.0', + }); + + const result = await resolver.resolveOnboarding(); + + expect(result).toEqual({ + status: OnboardingStatus.UPGRADE, + isPartnerBuild: false, + completed: true, + completedAtVersion: '7.1.0', + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, + }); + }); + + it('returns DOWNGRADE status when completed on newer version', async () => { + vi.mocked(onboardingTracker.getState).mockReturnValue({ + completed: true, + completedAtVersion: '7.3.0', + }); + + const result = await resolver.resolveOnboarding(); + + expect(result).toEqual({ + status: OnboardingStatus.DOWNGRADE, + isPartnerBuild: false, + completed: true, + completedAtVersion: '7.3.0', + onboardingState: { + registrationState: undefined, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }, + }); + }); + + it('returns isPartnerBuild true when partner info exists', async () => { + vi.mocked(onboardingTracker.getState).mockReturnValue({ + completed: false, + completedAtVersion: undefined, + }); + vi.mocked(onboardingService.getPublicPartnerInfo).mockResolvedValue({ + partner: { + name: 'Test Partner', + }, + }); + + const result = await resolver.resolveOnboarding(); + + expect(result.isPartnerBuild).toBe(true); + expect(result.status).toBe(OnboardingStatus.INCOMPLETE); + }); + + it('resolves available languages via display service', async () => { + vi.mocked(displayService.getAvailableLanguages).mockResolvedValue([ + { code: 'en_US', name: 'English', url: 'https://example.com/en_US.txz' }, + ]); + + const result = await resolver.resolveAvailableLanguages(); + + expect(displayService.getAvailableLanguages).toHaveBeenCalledOnce(); + expect(result).toEqual([ + { code: 'en_US', name: 'English', url: 'https://example.com/en_US.txz' }, + ]); + }); + + it('resolves activation code via public-normalized activation data', async () => { + vi.mocked(onboardingService.getActivationDataForPublic).mockResolvedValue({ + code: 'CODE-123', + branding: { + partnerLogoLightUrl: 'data:image/svg+xml;base64,AAA=', + partnerLogoDarkUrl: 'data:image/svg+xml;base64,BBB=', + }, + } as any); + + const result = await resolver.activationCode(); + + expect(onboardingService.getActivationDataForPublic).toHaveBeenCalledOnce(); + expect(result).toEqual({ + code: 'CODE-123', + branding: { + partnerLogoLightUrl: 'data:image/svg+xml;base64,AAA=', + partnerLogoDarkUrl: 'data:image/svg+xml;base64,BBB=', + }, + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts index 7a8750ef35..1f4c770495 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts @@ -3,19 +3,28 @@ import { Query, ResolveField, Resolver } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { Public } from '@app/unraid-api/auth/public.decorator.js'; // Import Public decorator - +import { Public } from '@app/unraid-api/auth/public.decorator.js'; +import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; import { ActivationCode, Customization, - PublicPartnerInfo, + Onboarding, + OnboardingStatus, } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; -import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js'; +import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; import { Theme } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; +import { Language } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; +import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; +import { getOnboardingVersionDirection } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-status.util.js'; @Resolver(() => Customization) export class CustomizationResolver { - constructor(private readonly customizationService: CustomizationService) {} + constructor( + private readonly onboardingService: OnboardingService, + private readonly onboardingTracker: OnboardingTrackerService, + private readonly displayService: DisplayService + ) {} + // Authenticated query @Query(() => Customization, { nullable: true }) @UsePermissions({ @@ -27,22 +36,68 @@ export class CustomizationResolver { return {}; } - // Dedicated public query - calls the internal helper - @Query(() => PublicPartnerInfo, { nullable: true }) + @Query(() => Boolean, { + description: 'Whether the system is a fresh install (no license key)', + }) @Public() - async publicPartnerInfo(): Promise { - return this.customizationService.getPublicPartnerInfo(); + async isFreshInstall(): Promise { + return this.onboardingService.isFreshInstall(); } @Query(() => Theme) @Public() async publicTheme(): Promise { - return this.customizationService.getTheme(); + return this.onboardingService.getTheme(); + } + + @ResolveField(() => Onboarding, { + name: 'onboarding', + description: 'Onboarding completion state and context', + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CUSTOMIZATIONS, + }) + async resolveOnboarding(): Promise { + const state = this.onboardingTracker.getState(); + const currentVersion = this.onboardingTracker.getCurrentVersion() ?? 'unknown'; + const partnerInfo = await this.onboardingService.getPublicPartnerInfo(); + const activationData = await this.onboardingService.getActivationData(); + const onboardingState = await this.onboardingService.getOnboardingState(); + const versionDirection = getOnboardingVersionDirection(state.completedAtVersion, currentVersion); + + // Compute the status based on completion state and version + let status: OnboardingStatus; + if (!state.completed) { + status = OnboardingStatus.INCOMPLETE; + } else if (versionDirection === 'DOWNGRADE') { + status = OnboardingStatus.DOWNGRADE; + } else if (versionDirection === 'UPGRADE') { + status = OnboardingStatus.UPGRADE; + } else { + status = OnboardingStatus.COMPLETED; + } + + // Get the activation code string if present and non-empty + const activationCode = activationData?.code?.trim() || undefined; + + return { + status, + isPartnerBuild: partnerInfo !== null, + completed: state.completed, + completedAtVersion: state.completedAtVersion, + activationCode, + onboardingState, + }; } - @ResolveField(() => PublicPartnerInfo, { nullable: true, name: 'partnerInfo' }) - async resolvePartnerInfo(): Promise { - return this.customizationService.getPublicPartnerInfo(); + @ResolveField(() => [Language], { nullable: true, name: 'availableLanguages' }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DISPLAY, + }) + async resolveAvailableLanguages(): Promise { + return this.displayService.getAvailableLanguages(); } @ResolveField(() => ActivationCode, { nullable: true, name: 'activationCode' }) @@ -51,11 +106,6 @@ export class CustomizationResolver { resource: Resource.ACTIVATION_CODE, }) async activationCode(): Promise { - return this.customizationService.getActivationData(); - } - - @ResolveField(() => Theme) - async theme(): Promise { - return this.customizationService.getTheme(); + return this.onboardingService.getActivationDataForPublic(); } } diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.service.ts b/api/src/unraid-api/graph/resolvers/customization/customization.service.ts deleted file mode 100644 index 1ef1cce1cd..0000000000 --- a/api/src/unraid-api/graph/resolvers/customization/customization.service.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import * as fs from 'fs/promises'; -import * as path from 'path'; - -import { plainToClass } from 'class-transformer'; -import { validateOrReject } from 'class-validator'; -import { GraphQLError } from 'graphql'; -import * as ini from 'ini'; - -import { emcmd } from '@app/core/utils/clients/emcmd.js'; -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 { - ActivationCode, - PublicPartnerInfo, -} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; -import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; - -@Injectable() -export class CustomizationService implements OnModuleInit { - private readonly logger = new Logger(CustomizationService.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 { - await fs.mkdir(this.activationDir, { recursive: true }); - if (await fileExists(this.hasRunFirstBootSetup)) { - this.logger.log('First boot setup flag file already exists.'); - return true; // Indicate setup was already done based on flag presence - } - await fs.writeFile(this.hasRunFirstBootSetup, 'true'); - this.logger.log('First boot setup flag file created.'); - return false; // Indicate setup was just marked as done - } - - 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('CustomizationService initialized with paths from store.'); - - try { - // Check if activation dir exists using the initialized path - try { - await fs.access(this.activationDir); - this.logger.log(`Activation directory found: ${this.activationDir}`); - } catch (dirError: unknown) { - if (dirError instanceof Error && 'code' in dirError && dirError.code === 'ENOENT') { - this.logger.log( - `Activation directory ${this.activationDir} not found. Skipping activation setup.` - ); - return; // Exit if activation dir doesn't exist - } - throw dirError; // Rethrow other access errors - } - - // Proceed with first boot check and activation data retrieval ONLY if dir exists - const hasRunFirstBootSetup = await this.createOrGetFirstBootSetupFlag(); - if (hasRunFirstBootSetup) { - this.logger.log('First boot setup previously completed, skipping customizations.'); - return; - } - - this.activationData = await this.getActivationData(); // This now uses this.activationDir - await this.applyActivationCustomizations(); // This uses this.activationData and paths - } catch (error: unknown) { - // Catch errors specifically from the activation setup logic post-path init - if ( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' && - 'path' in error && - error.path === this.activationDir - ) { - // This case should be handled by the access check above, but keep for safety. - this.logger.log('Activation directory check failed within setup logic.'); - } else { - this.logger.error('Error during activation check/setup on init:', error); - } - } - } - - private async getActivationJsonPath(): Promise { - try { - // Check if dir exists first (using the initialized path) - await fs.access(this.activationDir); - - const files = await fs.readdir(this.activationDir); - const jsonFile = files.find((file) => file.endsWith(this.activationJsonExtension)); - return jsonFile ? path.join(this.activationDir, jsonFile) : null; - } catch (error: unknown) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - this.logger.debug( - `Activation directory ${this.activationDir} not found when searching for JSON file.` - ); - } else { - this.logger.error('Error accessing activation directory or reading its content.', error); - } - return null; - } - } - - public async getPublicPartnerInfo(): Promise { - const activationData = await this.getActivationData(); - const paths = getters.paths(); - return { - hasPartnerLogo: await fileExists(paths.activation.logo), - partnerName: activationData?.partnerName, - partnerUrl: activationData?.partnerUrl, - partnerLogoUrl: paths.webgui.logo.assetPath, - }; - } - - public async isPasswordSet(): Promise { - const paths = store.getState().paths; - const hasPasswd = await fileExists(paths.passwd); - return hasPasswd; - } - - /** - * Get the activation data from the activation directory. - * @returns The activation data or null if the file is not found or invalid. - * @throws Error if the directory does not exist. - */ - async getActivationData(): Promise { - // Return cached data if available - if (this.activationData) { - this.logger.debug('Returning cached activation data.'); - return this.activationData; - } - - this.logger.debug('Fetching activation data from disk...'); - const activationJsonPath = await this.getActivationJsonPath(); - - if (!activationJsonPath) { - this.logger.debug('No activation JSON file found.'); - return null; - } - - try { - const fileContent = await fs.readFile(activationJsonPath, 'utf-8'); - const activationDataRaw = JSON.parse(fileContent); - - const activationDataDto = plainToClass(ActivationCode, activationDataRaw); - await validateOrReject(activationDataDto); - - // Cache the validated data - this.activationData = activationDataDto; - this.logger.debug('Activation data fetched and cached.'); - return this.activationData; - } catch (error) { - this.logger.error(`Error processing activation file ${activationJsonPath}:`, error); - // Do not cache in case of error - return null; - } - } - - async applyActivationCustomizations() { - this.logger.log('Applying activation customizations if data is available...'); - - if (!this.activationData) { - this.logger.log('No valid activation data found. Skipping customizations.'); - return; - } - - try { - // Check if activation dir exists (redundant if onModuleInit succeeded, but safe) - try { - await fs.access(this.activationDir); - } catch (dirError: unknown) { - if (dirError instanceof Error && 'code' in dirError && dirError.code === 'ENOENT') { - this.logger.warn('Activation directory disappeared after init? Skipping.'); - return; - } - throw dirError; // Rethrow other errors - } - - this.logger.log(`Using validated activation data to apply customizations.`); - - await this.setupPartnerBanner(); - await this.applyDisplaySettings(); - await this.applyCaseModelConfig(); - await this.applyServerIdentity(); - - this.logger.log('Activation setup complete.'); - } catch (error: unknown) { - // Added type annotation - // Initial dir check removed as it's handled in onModuleInit or the inner try block - this.logger.error('Error during activation setup:', error); - } - } - - private async setupPartnerBanner() { - this.logger.log('Setting up partner banner...'); - const paths = getters.paths(); - const bannerSource = paths.activation.banner; - const bannerTarget = paths.webgui.banner.fullPath; - - try { - // Always overwrite if partner banner exists - if (await fileExists(bannerSource)) { - this.logger.log(`Partner banner found at ${bannerSource}, overwriting original.`); - try { - await fs.copyFile(bannerSource, bannerTarget); - this.logger.log('Partner banner copied over the original banner.'); - } catch (copyError: unknown) { - this.logger.warn( - `Failed to replace the original banner with the partner banner: ${copyError instanceof Error ? copyError.message : 'Unknown error'}` - ); - } - } else { - this.logger.log('Partner banner file not found, skipping banner setup.'); - } - } catch (error) { - this.logger.error('Error setting up partner banner:', error); - } - } - - private async applyDisplaySettings() { - if (!this.activationData) { - this.logger.warn('No activation data available for display settings.'); - return; - } - - this.logger.log('Applying display settings...'); - const currentDisplaySettings = getters.dynamix()?.display || {}; - this.logger.debug('Current display settings from store:', currentDisplaySettings); - - const settingsToUpdate: Record = {}; - - // Map activation data properties to their corresponding config keys - type DisplayMapping = { - key: string; - transform?: (v: unknown) => string; - skipIfEmpty?: boolean; - }; - - const displayMappings: Record = { - header: { - key: 'header', - transform: (v: unknown) => (typeof v === 'string' ? v.replace('#', '') : ''), - skipIfEmpty: true, - }, - headermetacolor: { - key: 'headermetacolor', - transform: (v: unknown) => (typeof v === 'string' ? v.replace('#', '') : ''), - skipIfEmpty: true, - }, - background: { - key: 'background', - transform: (v: unknown) => (typeof v === 'string' ? v.replace('#', '') : ''), - skipIfEmpty: true, - }, - showBannerGradient: { - key: 'showBannerGradient', - transform: (v: unknown) => (v === true ? 'yes' : 'no'), - }, - theme: { key: 'theme' }, - }; - - // Apply mappings - Object.entries(displayMappings).forEach(([prop, mapping]) => { - const value = this.activationData?.[prop]; - if (value !== undefined && value !== null) { - const transformedValue = mapping.transform ? mapping.transform(value) : value; - if (!mapping.skipIfEmpty || transformedValue) { - settingsToUpdate[mapping.key] = transformedValue; - } - } - }); - - // Only set banner='image' if the banner file actually exists in the webgui images directory - // This assumes setupPartnerBanner has already attempted to copy it if necessary. - const paths = getters.paths(); - const bannerSource = paths.activation.banner; - - if (await fileExists(bannerSource)) { - settingsToUpdate['banner'] = 'image'; - this.logger.debug(`Webgui banner exists at ${bannerSource}, setting banner=image.`); - } else { - this.logger.debug( - `Webgui banner does not exist at ${bannerSource}, skipping banner=image setting.` - ); - } - - if (Object.keys(settingsToUpdate).length === 0) { - this.logger.log( - 'No new display settings found in activation data or derived from banner state.' - ); - return; - } - - this.logger.log('Updating display settings:', settingsToUpdate); - - try { - await this.updateCfgFile(this.configFile, 'display', settingsToUpdate); - this.logger.log('Display settings updated in config file.'); - } catch (error) { - this.logger.error('Error applying display settings:', error); - } - } - - private async applyCaseModelConfig() { - if (!this.activationData) { - this.logger.warn('No activation data available for case model setup.'); - return; - } - - this.logger.log('Applying case model...'); - const paths = getters.paths(); - const caseModelSource = paths.activation.caseModel; - - try { - if (await fileExists(caseModelSource)) { - this.logger.log('Case model found in activation assets, applying...'); - const modelToSet = path.basename(paths.webgui.caseModel.fullPath); // e.g., 'case-model.png' - await fs.mkdir(path.dirname(this.caseModelCfg), { recursive: true }); - await fs.writeFile(this.caseModelCfg, modelToSet); - this.logger.log(`Case model set to ${modelToSet} in ${this.caseModelCfg}`); - } else { - this.logger.log('No custom case model file found in activation assets.'); - } - } catch (error) { - this.logger.error('Error applying case model:', error); - } - } - - private async applyServerIdentity() { - if (!this.activationData) { - this.logger.warn('No activation data available for server identity setup.'); - return; - } - - this.logger.log('Applying server identity...'); - // Ideally, get current values from Redux store instead of var.ini - // Assuming EmhttpState type provides structure for emhttp slice. Adjust if necessary. - // Using optional chaining ?. in case emhttp or var is not defined in the state yet. - const currentEmhttpState = getters.emhttp(); - const currentName = currentEmhttpState?.var?.name || ''; - // Skip sending sysModel to emcmd for now - const currentSysModel = ''; - const currentComment = currentEmhttpState?.var?.comment || ''; - - this.logger.debug( - `Current identity - Name: ${currentName}, Model: ${currentSysModel}, Comment: ${currentComment}` - ); - - const { serverName, sysModel, comment } = this.activationData; - const paramsToUpdate: Record = { - ...(serverName && { NAME: serverName }), - ...(sysModel && { SYS_MODEL: sysModel }), - ...(comment && { COMMENT: comment }), - }; - - if (Object.keys(paramsToUpdate).length === 0) { - this.logger.log('No server identity information found in activation data.'); - return; - } - - this.logger.log('Updating server identity:', paramsToUpdate); - - try { - // Trigger emhttp update via emcmd - const updateParams = { - ...paramsToUpdate, - changeNames: 'Apply', - // Can be null string - server_name: '', - // Can be null string - server_addr: '', - }; - this.logger.log(`Calling emcmd with params: %o`, updateParams); - await emcmd(updateParams, { waitForToken: true }); - - this.logger.log('emcmd executed successfully.'); - } catch (error: unknown) { - this.logger.error('Error applying server identity: %o', error); - } - } - - // Helper function to update .cfg files (like dynamix.cfg or ident.cfg) using the ini library - private async updateCfgFile( - filePath: string, - section: string | null, - updates: Record - ) { - let configData: Record | string> = {}; - try { - const content = await fs.readFile(filePath, 'utf-8'); - // Parse the INI file content. Note: ini library parses values as strings by default. - // It might interpret numbers/booleans if not quoted, but our values are always quoted. - configData = ini.parse(content) as Record | string>; - } catch (error: unknown) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - this.logger.log(`Config file ${filePath} not found, will create it.`); - // Initialize configData as an empty object if file doesn't exist - } else { - this.logger.error(`Error reading config file ${filePath}:`, error); - throw error; // Re-throw other errors - } - } - - if (section) { - if (!configData[section] || typeof configData[section] === 'string') { - configData[section] = {}; - } - Object.entries(updates).forEach(([key, value]) => { - (configData[section] as Record)[key] = value; - }); - } else { - Object.entries(updates).forEach(([key, value]) => { - configData[key] = value; - }); - } - - try { - const newContent = ini.stringify(configData); - - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, newContent + '\n'); - this.logger.log(`Config file ${filePath} updated successfully.`); - } catch (error: unknown) { - this.logger.error(`Error writing config file ${filePath}:`, error); - throw error; - } - } - - private addHashtoHexField(field: string | undefined): string | undefined { - return field ? `#${field}` : undefined; - } - - public async getTheme(): Promise { - if (!getters.dynamix()?.display?.theme) { - throw new GraphQLError('No theme found or loaded from dynamix.cfg settings.'); - } - - const name = - ThemeName[getters.dynamix()!.display!.theme.toLowerCase() as keyof typeof ThemeName] ?? - ThemeName.white; - - const banner = getters.dynamix()!.display!.banner; - const bannerGradient = getters.dynamix()!.display!.showBannerGradient; - const bgColor = getters.dynamix()!.display!.background; - const descriptionShow = getters.dynamix()!.display!.headerdescription; - const metaColor = getters.dynamix()!.display!.headermetacolor; - const textColor = getters.dynamix()!.display!.header; - - return { - name, - showBannerImage: banner === 'image' || banner === 'yes', - showBannerGradient: bannerGradient === 'yes', - headerBackgroundColor: this.addHashtoHexField(bgColor), - headerPrimaryTextColor: this.addHashtoHexField(textColor), - headerSecondaryTextColor: this.addHashtoHexField(metaColor), - showHeaderDescription: descriptionShow === 'yes', - }; - } - - public async setTheme(theme: ThemeName): Promise { - this.logger.log(`Updating theme to ${theme}`); - await this.updateCfgFile(this.configFile, 'display', { theme }); - - // Refresh in-memory store so subsequent reads get the new theme without a restart - const paths = getters.paths(); - const updatedConfig = loadDynamixConfigFromDiskSync(paths['dynamix-config']); - store.dispatch(updateDynamixConfig(updatedConfig)); - - return this.getTheme(); - } -} diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts similarity index 61% rename from api/src/unraid-api/graph/resolvers/customization/customization.service.spec.ts rename to api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts index b719b0e60c..e510b2fccc 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts @@ -11,12 +11,28 @@ 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 { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; +import { OnboardingStateService } from '@app/unraid-api/config/onboarding-state.service.js'; +import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; import { ActivationCode } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; -import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js'; +import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; -// Mocks -vi.mock('fs/promises'); vi.mock('@app/core/utils/files/file-exists.js'); +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises'); + return { + ...actual, + mkdir: vi.fn(), + access: vi.fn(), + readdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + copyFile: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), + symlink: vi.fn(), + }; +}); const mockPaths = { activationBase: '/mock/boot/config/activation', @@ -54,13 +70,15 @@ vi.mock('@app/store/index.js', async () => { ...actual, getters: { paths: vi.fn(() => mockPaths), - dynamix: vi.fn(() => ({ display: { theme: 'azure', header: 'FFFFFF' } })), + dynamix: vi.fn(() => ({ + display: { theme: 'azure', header: 'FFFFFF', terminalButton: 'yes' }, + })), emhttp: vi.fn(() => ({ var: { name: 'Tower', sysModel: 'Custom', comment: 'Default' } })), }, store: { getState: vi.fn(() => ({ paths: mockPaths, - dynamix: { display: { theme: 'azure', header: 'FFFFFF' } }, + dynamix: { display: { theme: 'azure', header: 'FFFFFF', terminalButton: 'yes' } }, emhttp: { var: { name: 'Tower', sysModel: 'Custom', comment: 'Default' } }, })), }, @@ -96,8 +114,26 @@ vi.mock('@app/core/utils/misc/sleep.js', async () => { }; }); -describe('CustomizationService', () => { - let service: CustomizationService; +const onboardingTrackerMock = { + isCompleted: vi.fn<() => boolean>(), + getState: vi.fn<() => { completed: boolean; completedAtVersion?: string }>(), + markCompleted: vi.fn<() => Promise<{ completed: boolean; completedAtVersion?: string }>>(), +}; +const onboardingOverridesMock = { + getState: vi.fn(), + setState: vi.fn(), + clearState: vi.fn(), +}; +const onboardingStateMock = { + getRegistrationState: vi.fn(), + hasActivationCode: vi.fn(), + isFreshInstall: vi.fn(), + requiresActivationStep: vi.fn(), + isRegistered: vi.fn(), +}; + +describe('OnboardingService', () => { + let service: OnboardingService; let loggerDebugSpy; let loggerLogSpy; let loggerWarnSpy; @@ -106,7 +142,6 @@ describe('CustomizationService', () => { // 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; @@ -120,14 +155,22 @@ describe('CustomizationService', () => { // Add mockActivationData definition here const mockActivationData = { - header: '#112233', - headermetacolor: '#445566', - background: '#778899', - showBannerGradient: true, - theme: 'black', - serverName: 'PartnerServer', - sysModel: 'PartnerModel', - comment: 'Partner Comment', + branding: { + header: '#112233', + headermetacolor: '#445566', + background: '#778899', + showBannerGradient: true, + theme: 'black', + bannerImage: './assets/banner.png', + caseModelImage: './assets/case-model.png', + partnerLogoLightUrl: './assets/partner-logo-light.png', + partnerLogoDarkUrl: './assets/partner-logo-dark.png', + }, + system: { + serverName: 'PartnerServer', + model: 'PartnerModel', + comment: 'Partner Comment', + }, }; beforeEach(async () => { @@ -138,12 +181,52 @@ describe('CustomizationService', () => { loggerLogSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); loggerWarnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + onboardingTrackerMock.isCompleted.mockReset(); + onboardingTrackerMock.isCompleted.mockReturnValue(false); + onboardingOverridesMock.getState.mockReset(); + onboardingOverridesMock.getState.mockReturnValue(null); + onboardingOverridesMock.setState.mockReset(); + onboardingOverridesMock.clearState.mockReset(); + onboardingStateMock.getRegistrationState.mockReset(); + onboardingStateMock.getRegistrationState.mockReturnValue(undefined); + onboardingStateMock.hasActivationCode.mockReset(); + onboardingStateMock.hasActivationCode.mockResolvedValue(false); + onboardingStateMock.isFreshInstall.mockReset(); + onboardingStateMock.isFreshInstall.mockReturnValue(false); + onboardingStateMock.requiresActivationStep.mockReset(); + onboardingStateMock.requiresActivationStep.mockReturnValue(false); + onboardingStateMock.isRegistered.mockReset(); + onboardingStateMock.isRegistered.mockReturnValue(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(fs.rename).mockReset(); + vi.mocked(fs.unlink).mockReset(); + vi.mocked(fs.symlink).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(fs.rename).mockResolvedValue(undefined as any); + vi.mocked(fs.unlink).mockResolvedValue(undefined as any); + vi.mocked(fs.symlink).mockRejectedValue(new Error('symlink not supported')); + vi.mocked(fileExists).mockResolvedValue(false); const module: TestingModule = await Test.createTestingModule({ - providers: [CustomizationService], + providers: [ + OnboardingService, + { provide: OnboardingTrackerService, useValue: onboardingTrackerMock }, + { provide: OnboardingOverrideService, useValue: onboardingOverridesMock }, + { provide: OnboardingStateService, useValue: onboardingStateMock }, + ], }).compile(); - service = module.get(CustomizationService); + service = module.get(OnboardingService); // Mock fileExists needed by customization methods vi.mocked(fileExists).mockImplementation(async (p) => { @@ -154,6 +237,7 @@ describe('CustomizationService', () => { afterEach(() => { vi.useRealTimers(); + mockPaths['dynamix-config'] = ['/mock/default.cfg', '/mock/user/dynamix.cfg']; }); it('should be defined', () => { @@ -168,13 +252,9 @@ describe('CustomizationService', () => { 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.isCompleted).not.toHaveBeenCalled(); mockPaths['dynamix-config'] = originalDynamixConfig; }); @@ -189,14 +269,15 @@ describe('CustomizationService', () => { '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 () => { const error = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; error.code = 'ENOENT'; vi.mocked(fs.access).mockImplementation(async (p) => { - if (p === activationDir) throw error; + if (p === activationDir || p === activationDir.replace('/activation', '/activate')) { + throw error; + } }); await service.onModuleInit(); @@ -204,16 +285,48 @@ describe('CustomizationService', () => { 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.isCompleted.mockReturnValueOnce(true); + + await service.onModuleInit(); + + expect(onboardingTrackerMock.isCompleted).toHaveBeenCalledTimes(1); + expect(fs.readdir).not.toHaveBeenCalled(); + expect(loggerLogSpy).toHaveBeenCalledWith( + 'Onboarding already completed, skipping first boot setup.' + ); + expect(loggerLogSpy).toHaveBeenCalledWith( + 'First boot setup previously completed, skipping customizations.' + ); + }); + + it('should be idempotent across init calls when tracker marks setup complete', async () => { + onboardingTrackerMock.isCompleted + .mockReturnValueOnce(false) // first init applies customizations + .mockReturnValueOnce(true); // second init should skip + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([activationJsonFile as any]); + vi.mocked(fs.readFile).mockImplementation(async (p) => { + if (p === activationJsonPath) return JSON.stringify(mockActivationData); + if (p === userDynamixCfg) return ini.stringify({}); + if (p === identCfg) return ini.stringify({}); + if (p === caseModelCfg) throw { code: 'ENOENT' }; + throw new Error(`Unexpected readFile: ${p}`); + }); + await service.onModuleInit(); await service.onModuleInit(); - expect(fs.readdir).not.toHaveBeenCalled(); // Should not read activation dir for JSON + expect(onboardingTrackerMock.isCompleted).toHaveBeenCalledTimes(2); + expect(fs.readdir).toHaveBeenCalledTimes(1); + expect(fs.copyFile).toHaveBeenCalledTimes(2); + expect(emcmd).not.toHaveBeenCalled(); + 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,8 +351,8 @@ describe('CustomizationService', () => { await promise; // Check .done flag creation - expect(fs.writeFile).toHaveBeenCalledWith(doneFlag, 'true'); - expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup flag file created.'); + expect(onboardingTrackerMock.isCompleted).toHaveBeenCalledTimes(1); + expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup in progress.'); // Check activation data loaded expect(loggerLogSpy).toHaveBeenCalledWith( @@ -248,20 +361,16 @@ describe('CustomizationService', () => { expect((service as any).activationData).toEqual(expect.objectContaining(mockActivationData)); // Check customizations applied (verify mocks were called) - expect(fs.copyFile).toHaveBeenCalledWith(bannerSource, bannerTarget.fullPath); // Banner copied + expect(fs.copyFile).toHaveBeenCalledWith( + bannerSource, + expect.stringContaining(`${bannerTarget.fullPath}.tmp-`) + ); // Banner staged copy - // Verify we write to dynamix config with theme=black + // Verify we write to dynamix config without forcing activation branding theme const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; const dynamixCfgCall = writeFileCalls.find((call) => call[0] === userDynamixCfg); expect(dynamixCfgCall).toBeDefined(); - expect(dynamixCfgCall?.[1]).toContain('theme=black'); - - // We no longer write directly to ident.cfg, instead we call emcmd - // Run timers again to ensure emcmd is called - await vi.runAllTimers(); - expect(emcmd).toHaveBeenCalledWith(expect.objectContaining({ NAME: 'PartnerServer' }), { - waitForToken: true, - }); // emcmd called + expect(dynamixCfgCall?.[1]).toContain('theme="azure"'); expect(loggerLogSpy).toHaveBeenCalledWith('Activation setup complete.'); }, 10000); @@ -272,8 +381,8 @@ describe('CustomizationService', () => { // Setup mocks: dir exists, .done missing, JSON exists, read JSON ok vi.mocked(fileExists).mockImplementation(async (p) => { - // .done is missing, banner asset exists - return p === bannerSource; + // .done is missing, all json-declared assets exist + return p === bannerSource || p === caseModelSource; }); vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readdir).mockResolvedValue([activationJsonFile as any]); @@ -289,7 +398,11 @@ describe('CustomizationService', () => { // --- Introduce failure point --- // Mock fs.copyFile used by setupPartnerBanner to fail vi.mocked(fs.copyFile).mockImplementation(async (source, dest) => { - if (source === bannerSource && dest === bannerTarget.fullPath) { + if ( + source === bannerSource && + typeof dest === 'string' && + dest.startsWith(`${bannerTarget.fullPath}.tmp-`) + ) { throw bannerCopyError; } // Allow other potential copy operations (if any) @@ -298,7 +411,6 @@ describe('CustomizationService', () => { // --- Spy on subsequent steps to ensure they are still called --- // We already mock fs.writeFile, so we can check calls to userDynamixCfg and identCfg const applyDisplaySettingsSpy = vi.spyOn(service as any, 'applyDisplaySettings'); - const applyServerIdentitySpy = vi.spyOn(service as any, 'applyServerIdentity'); const updateCfgFileSpy = vi.spyOn(service as any, 'updateCfgFile'); // --- Execute --- @@ -307,9 +419,9 @@ describe('CustomizationService', () => { await promise; // --- Assertions --- - // 1. .done flag is still created - expect(fs.writeFile).toHaveBeenCalledWith(doneFlag, 'true'); - expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup flag file created.'); + // 1. First boot completion is recorded + expect(onboardingTrackerMock.isCompleted).toHaveBeenCalledTimes(1); + expect(loggerLogSpy).toHaveBeenCalledWith('First boot setup in progress.'); // 2. Activation data loaded expect(loggerLogSpy).toHaveBeenCalledWith( @@ -333,12 +445,7 @@ describe('CustomizationService', () => { expect.any(Object) ); - expect(applyServerIdentitySpy).toHaveBeenCalled(); - // We no longer update ident.cfg directly, so we don't check updateCfgFile for it - - // Run timers again to ensure emcmd is called - await vi.runAllTimers(); - expect(emcmd).toHaveBeenCalledWith(expect.any(Object), { waitForToken: true }); // emcmd should still be called + expect(emcmd).not.toHaveBeenCalled(); }, 10000); }); @@ -354,9 +461,11 @@ describe('CustomizationService', () => { vi.mocked(fs.access).mockRejectedValue(error); const result = await service.getActivationData(); expect(result).toBeNull(); + expect(loggerDebugSpy).toHaveBeenCalledWith('Fetching activation data from disk...'); expect(loggerDebugSpy).toHaveBeenCalledWith( - `Activation directory ${activationDir} not found when searching for JSON file.` + `Activation directory ${activationDir} not found when searching for activation code.` ); + expect(loggerDebugSpy).toHaveBeenCalledWith('No activation JSON file found.'); }); it('should return null if no .activationcode file exists', async () => { @@ -374,7 +483,7 @@ describe('CustomizationService', () => { const result = await service.getActivationData(); expect(result).toBeNull(); expect(loggerErrorSpy).toHaveBeenCalledWith( - 'Error accessing activation directory or reading its content.', + expect.stringContaining('Error accessing activation directory'), readDirError ); }); @@ -413,7 +522,11 @@ describe('CustomizationService', () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readdir).mockResolvedValue([activationJsonFile as any]); // Provide data with an invalid hex color format - const invalidHexData = { ...mockActivationData, header: 'not a hex color' }; + // Provide data with an invalid hex color format + const invalidHexData = { + ...mockActivationData, + branding: { ...mockActivationData.branding, header: 'not a hex color' }, + }; vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(invalidHexData)); // Validation should now pass because the transformer handles the invalid value @@ -421,9 +534,9 @@ describe('CustomizationService', () => { expect(result).toBeInstanceOf(ActivationCode); // Check that the invalid hex was transformed to an empty string - expect(result?.header).toBe(''); + expect(result?.branding?.header).toBe(''); // Check other valid fields remain - expect(result?.theme).toBe(mockActivationData.theme); + expect(result?.branding?.theme).toBe(mockActivationData.branding.theme); // Validation errors are handled by validateOrReject throwing, not loggerErrorSpy here expect(loggerErrorSpy).not.toHaveBeenCalled(); }); @@ -434,16 +547,19 @@ describe('CustomizationService', () => { vi.mocked(fs.readdir).mockResolvedValue([activationJsonFile as any]); const hexWithoutHashData = { ...mockActivationData, - header: 'ABCDEF', - headermetacolor: '123', + branding: { + ...mockActivationData.branding, + header: 'ABCDEF', + headermetacolor: '123', + }, }; vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(hexWithoutHashData)); const result = await service.getActivationData(); expect(result).toBeInstanceOf(ActivationCode); - expect(result?.header).toBe('#ABCDEF'); - expect(result?.headermetacolor).toBe('#123'); + expect(result?.branding?.header).toBe('#ABCDEF'); + expect(result?.branding?.headermetacolor).toBe('#123'); }); it('should return validated DTO on success', async () => { @@ -467,11 +583,15 @@ describe('CustomizationService', () => { 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; (service as any).activationData = plainToInstance(ActivationCode, { ...mockActivationData }); + (service as any).activationJsonPath = activationJsonPath; + (service as any).materializedPartnerMedia = { + banner: true, + caseModel: true, + }; // Mock necessary file reads/writes vi.mocked(fs.readFile).mockImplementation(async (p) => { if (p === userDynamixCfg) return ini.stringify({ display: { existing: 'value' } }); @@ -491,7 +611,10 @@ describe('CustomizationService', () => { it('setupPartnerBanner should copy banner if asset exists', async () => { vi.mocked(fileExists).mockResolvedValue(true); // Banner asset exists await (service as any).setupPartnerBanner(); - expect(fs.copyFile).toHaveBeenCalledWith(bannerSource, bannerTarget.fullPath); + expect(fs.copyFile).toHaveBeenCalledWith( + bannerSource, + expect.stringContaining(`${bannerTarget.fullPath}.tmp-`) + ); expect(loggerLogSpy).toHaveBeenCalledWith( `Partner banner found at ${bannerSource}, overwriting original.` ); @@ -540,26 +663,141 @@ describe('CustomizationService', () => { vi.mocked(fileExists).mockResolvedValue(true); // Asset exists vi.mocked(fs.copyFile).mockRejectedValue(copyError); // Copy fails await (service as any).setupPartnerBanner(); - expect(fs.copyFile).toHaveBeenCalledWith(bannerSource, bannerTarget.fullPath); + expect(fs.copyFile).toHaveBeenCalledWith( + bannerSource, + expect.stringContaining(`${bannerTarget.fullPath}.tmp-`) + ); expect(loggerWarnSpy).toHaveBeenCalledWith( expect.stringContaining(`Failed to replace the original banner`) ); }); + it('materializes banner image from local path by symlinking into activation assets', async () => { + const localBannerPath = path.join(activationDir, 'partner-assets/banner-custom.png'); + (service as any).activationJsonPath = activationJsonPath; + (service as any).activationData = plainToInstance(ActivationCode, { + branding: { + bannerImage: './partner-assets/banner-custom.png', + }, + }); + + vi.mocked(fileExists).mockImplementation(async (p) => p === localBannerPath); + vi.mocked(fs.symlink).mockResolvedValue(undefined as any); + await (service as any).materializePartnerMediaAssets(); + + expect(fs.symlink).toHaveBeenCalledWith(localBannerPath, bannerSource); + }); + + it('materializes case-model image from remote URL into activation assets', async () => { + const remoteBytes = Uint8Array.from([137, 80, 78, 71]).buffer; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'image/png' }), + arrayBuffer: async () => remoteBytes, + } as any); + + (service as any).activationData = plainToInstance(ActivationCode, { + branding: { + caseModelImage: 'https://example.com/case-model.png', + }, + }); + + await (service as any).materializePartnerMediaAssets(); + + expect(fetchSpy).toHaveBeenCalledWith('https://example.com/case-model.png', { + signal: expect.any(AbortSignal), + }); + expect(fs.writeFile).toHaveBeenCalledWith( + caseModelSource, + expect.objectContaining({ + length: 4, + }) + ); + fetchSpy.mockRestore(); + }); + + it('normalizes partner logo local paths into browser-safe data URIs', async () => { + const localLightLogoPath = path.join(activationDir, 'partner-assets/light-logo.svg'); + const localDarkLogoPath = path.join(activationDir, 'partner-assets/dark-logo.svg'); + (service as any).activationJsonPath = activationJsonPath; + (service as any).activationData = plainToInstance(ActivationCode, { + branding: { + partnerLogoLightUrl: './partner-assets/light-logo.svg', + partnerLogoDarkUrl: './partner-assets/dark-logo.svg', + }, + partner: { + name: 'Partner Inc', + }, + }); + + vi.mocked(fileExists).mockImplementation(async (p) => { + return p === localLightLogoPath || p === localDarkLogoPath; + }); + vi.mocked(fs.readFile).mockImplementation(async (p) => { + if (p === localLightLogoPath) { + return Buffer.from(''); + } + if (p === localDarkLogoPath) { + return Buffer.from(''); + } + return ''; + }); + + const partnerInfo = await service.getPublicPartnerInfo(); + + expect(partnerInfo?.branding?.partnerLogoLightUrl).toMatch(/^data:image\/svg\+xml;base64,/); + expect(partnerInfo?.branding?.partnerLogoDarkUrl).toMatch(/^data:image\/svg\+xml;base64,/); + expect(partnerInfo?.branding?.hasPartnerLogo).toBe(true); + }); + + it('falls back to the light partner logo for dark themes when dark source is missing', async () => { + const localLightLogoPath = path.join(activationDir, 'partner-assets/light-only-logo.svg'); + (service as any).activationJsonPath = activationJsonPath; + (service as any).activationData = plainToInstance(ActivationCode, { + branding: { + partnerLogoLightUrl: './partner-assets/light-only-logo.svg', + }, + }); + + vi.mocked(fileExists).mockImplementation(async (p) => { + return p === localLightLogoPath; + }); + vi.mocked(fs.readFile).mockImplementation(async (p) => { + if (p === localLightLogoPath) { + return Buffer.from(''); + } + return ''; + }); + + const partnerInfo = await service.getPublicPartnerInfo(); + + expect(partnerInfo?.branding?.partnerLogoLightUrl).toMatch(/^data:image\/svg\+xml;base64,/); + expect(partnerInfo?.branding?.partnerLogoDarkUrl).toBe( + partnerInfo?.branding?.partnerLogoLightUrl + ); + }); + it('applyDisplaySettings should call updateCfgFile with correct data (stripping #)', async () => { const updateSpy = vi.spyOn(service as any, 'updateCfgFile'); vi.mocked(fileExists).mockResolvedValue(true); // Assume banner exists for banner: 'image' logic await (service as any).setupPartnerBanner(); // Run banner setup first await (service as any).applyDisplaySettings(); - // Expect the hash to be stripped by applyDisplaySettings - expect(updateSpy).toHaveBeenCalledWith(userDynamixCfg, 'display', { - header: '112233', // # stripped - headermetacolor: '445566', // # stripped - background: '778899', // # stripped - showBannerGradient: 'yes', - theme: 'black', - banner: 'image', - }); + // Expect the hash to be stripped and existing display settings preserved + expect(updateSpy).toHaveBeenCalledWith( + userDynamixCfg, + 'display', + expect.objectContaining({ + terminalButton: 'yes', + theme: 'azure', + header: '112233', // # stripped + headermetacolor: '445566', // # stripped + background: '778899', // # stripped + showBannerGradient: 'yes', + banner: 'image', + }) + ); expect(loggerLogSpy).toHaveBeenCalledWith('Display settings updated in config file.'); }); @@ -571,17 +809,24 @@ describe('CustomizationService', () => { await (service as any).setupPartnerBanner(); // Ensure banner='image' logic runs await (service as any).applyDisplaySettings(); - // Only banner='image' and the default showBannerGradient='yes' should be set - expect(updateSpy).toHaveBeenCalledWith(userDynamixCfg, 'display', { - banner: 'image', // Only banner is set - showBannerGradient: 'yes', // Default value from DTO - }); + // Activation updates should merge into existing display settings + expect(updateSpy).toHaveBeenCalledWith( + userDynamixCfg, + 'display', + expect.objectContaining({ + terminalButton: 'yes', + theme: 'azure', + banner: 'image', + }) + ); expect(loggerLogSpy).toHaveBeenCalledWith('Display settings updated in config file.'); }); it('applyDisplaySettings should skip banner field if banner file does not exist', async () => { const updateSpy = vi.spyOn(service as any, 'updateCfgFile'); - (service as any).activationData = plainToInstance(ActivationCode, { theme: 'white' }); // Some data, but no banner + (service as any).activationData = plainToInstance(ActivationCode, { + branding: { theme: 'white' }, + }); // Some data, but no banner // Clear any previous mocks for fileExists and set a specific one for this test vi.mocked(fileExists).mockClear(); @@ -597,12 +842,18 @@ describe('CustomizationService', () => { await (service as any).setupPartnerBanner(); // Run banner setup (will log skip) await (service as any).applyDisplaySettings(); - // theme and default showBannerGradient are set, but banner field is not - expect(updateSpy).toHaveBeenCalledWith(userDynamixCfg, 'display', { - theme: 'white', - showBannerGradient: 'yes', // Default value from DTO - // banner: 'image' // Should NOT be present - }); + // Existing theme is preserved; default showBannerGradient is set from branding defaults. + expect(updateSpy).toHaveBeenCalledWith( + userDynamixCfg, + 'display', + expect.objectContaining({ + terminalButton: 'yes', + theme: 'azure', + showBannerGradient: 'yes', + }) + ); + const updatePayload = (updateSpy.mock.calls.at(-1)?.[2] ?? {}) as Record; + expect(updatePayload.banner).toBeUndefined(); expect(loggerLogSpy).toHaveBeenCalledWith('Display settings updated in config file.'); }); @@ -612,23 +863,29 @@ describe('CustomizationService', () => { // Simulate data after transformation results in empty strings (service as any).activationData = plainToInstance(ActivationCode, { ...mockActivationData, - header: '', // Was invalid, transformed to empty - headermetacolor: '#445566', // Valid - background: '', // Was invalid, transformed to empty + branding: { + ...mockActivationData.branding, + header: '', // Was invalid, transformed to empty + headermetacolor: '#445566', // Valid + background: '', // Was invalid, transformed to empty + }, }); vi.mocked(fileExists).mockResolvedValue(true); // Assume banner file exists await (service as any).setupPartnerBanner(); // Run banner setup await (service as any).applyDisplaySettings(); - // Expect empty strings to be filtered out, and valid hex stripped of # - expect(updateSpy).toHaveBeenCalledWith(userDynamixCfg, 'display', { - // header: '', // Should NOT be included (falsy check in service) - headermetacolor: '445566', // '#' stripped (truthy) - // background: '', // Should NOT be included (falsy check in service) - showBannerGradient: 'yes', // truthy - theme: 'black', // truthy - banner: 'image', // Added by setupPartnerBanner success - }); + // Expect empty strings to be filtered out and valid hex stripped of # + expect(updateSpy).toHaveBeenCalledWith( + userDynamixCfg, + 'display', + expect.objectContaining({ + terminalButton: 'yes', + theme: 'azure', + headermetacolor: '445566', // '#' stripped (truthy) + showBannerGradient: 'yes', + banner: 'image', + }) + ); expect(loggerLogSpy).toHaveBeenCalledWith('Display settings updated in config file.'); }); @@ -638,29 +895,41 @@ describe('CustomizationService', () => { // Simulate data after transformation where # was added (service as any).activationData = plainToInstance(ActivationCode, { ...mockActivationData, - header: '#ABCDEF', // Originally 'ABCDEF', now includes # - headermetacolor: '#123', // Originally '123', now includes # - background: '#778899', // Original, includes # + branding: { + ...mockActivationData.branding, + header: '#ABCDEF', // Originally 'ABCDEF', now includes # + headermetacolor: '#123', // Originally '123', now includes # + background: '#778899', // Original, includes # + }, }); vi.mocked(fileExists).mockResolvedValue(true); // Assume banner exists await (service as any).setupPartnerBanner(); // Run banner setup await (service as any).applyDisplaySettings(); // Expect '#' to be stripped by applyDisplaySettings before writing - expect(updateSpy).toHaveBeenCalledWith(userDynamixCfg, 'display', { - header: 'ABCDEF', // # stripped - headermetacolor: '123', // # stripped - background: '778899', // # stripped - showBannerGradient: 'yes', - theme: 'black', - banner: 'image', - }); + expect(updateSpy).toHaveBeenCalledWith( + userDynamixCfg, + 'display', + expect.objectContaining({ + terminalButton: 'yes', + theme: 'azure', + header: 'ABCDEF', + headermetacolor: '123', + background: '778899', + showBannerGradient: 'yes', + banner: 'image', + }) + ); expect(loggerLogSpy).toHaveBeenCalledWith('Display settings updated in config file.'); }); it('applyCaseModelConfig should set model from asset if exists', async () => { vi.mocked(fileExists).mockImplementation(async (p) => p === caseModelSource); // Asset exists await (service as any).applyCaseModelConfig(); + expect(fs.copyFile).toHaveBeenCalledWith( + caseModelSource, + expect.stringContaining(`${caseModelTarget.fullPath}.tmp-`) + ); expect(fs.writeFile).toHaveBeenCalledWith( caseModelCfg, path.basename(caseModelTarget.fullPath) @@ -731,7 +1000,9 @@ describe('CustomizationService', () => { it('applyServerIdentity should skip if activation data has no relevant fields', async () => { const updateSpy = vi.spyOn(service as any, 'updateCfgFile'); // Simulate DTO with non-identity fields - (service as any).activationData = plainToInstance(ActivationCode, { theme: 'white' }); + (service as any).activationData = plainToInstance(ActivationCode, { + branding: { theme: 'white' }, + }); await (service as any).applyServerIdentity(); expect(updateSpy).not.toHaveBeenCalled(); expect(emcmd).not.toHaveBeenCalled(); @@ -745,9 +1016,11 @@ describe('CustomizationService', () => { // Set up activation data directly (service as any).activationData = plainToInstance(ActivationCode, { - serverName: 'PartnerServer', - sysModel: 'PartnerModel', - comment: 'Partner Comment', + system: { + serverName: 'PartnerServer', + model: 'PartnerModel', + comment: 'Partner Comment', + }, }); // Mock emcmd to throw @@ -767,6 +1040,133 @@ describe('CustomizationService', () => { ); }, 10000); + it('applyServerIdentity should apply comment even when name/model are absent', async () => { + (service as any).activationData = plainToInstance(ActivationCode, { + system: { + comment: 'Partner Comment', + }, + }); + + let commentOnlyParams: Record | undefined; + vi.mocked(emcmd).mockImplementation(async (params) => { + commentOnlyParams = params as Record; + return { body: '', ok: true } as any; + }); + + await (service as any).applyServerIdentity(); + + expect(emcmd).toHaveBeenCalled(); + expect(commentOnlyParams).toMatchObject({ + COMMENT: 'Partner Comment', + changeNames: 'Apply', + server_addr: '', + server_name: '', + }); + expect(commentOnlyParams).not.toHaveProperty('NAME'); + expect(commentOnlyParams).not.toHaveProperty('SYS_MODEL'); + }); + + it('applyServerIdentity should omit comment when activation data does not provide one', async () => { + (service as any).activationData = plainToInstance(ActivationCode, { + system: { + serverName: 'PartnerServer', + model: 'PartnerModel', + }, + }); + + let paramsWithoutComment: Record | undefined; + vi.mocked(emcmd).mockImplementation(async (params) => { + paramsWithoutComment = params as Record; + return { body: '', ok: true } as any; + }); + + await (service as any).applyServerIdentity(); + + expect(emcmd).toHaveBeenCalled(); + expect(paramsWithoutComment).toMatchObject({ + NAME: 'PartnerServer', + SYS_MODEL: 'PartnerModel', + }); + expect(paramsWithoutComment).not.toHaveProperty('COMMENT'); + }); + + it('applyServerIdentity should allow explicitly empty comments from activation data', async () => { + (service as any).activationData = plainToInstance(ActivationCode, { + system: { + comment: '', + }, + }); + + let emptyCommentParams: Record | undefined; + vi.mocked(emcmd).mockImplementation(async (params) => { + emptyCommentParams = params as Record; + return { body: '', ok: true } as any; + }); + + await (service as any).applyServerIdentity(); + + expect(emcmd).toHaveBeenCalled(); + expect(emptyCommentParams).toMatchObject({ + COMMENT: '', + changeNames: 'Apply', + server_addr: '', + server_name: '', + }); + }); + + it.each([ + { + caseName: 'name only', + system: { serverName: 'PartnerServer' }, + expected: { NAME: 'PartnerServer' }, + omitted: ['SYS_MODEL', 'COMMENT'], + }, + { + caseName: 'model only', + system: { model: 'PartnerModel' }, + expected: { SYS_MODEL: 'PartnerModel' }, + omitted: ['NAME', 'COMMENT'], + }, + { + caseName: 'comment only', + system: { comment: 'Partner Comment' }, + expected: { COMMENT: 'Partner Comment' }, + omitted: ['NAME', 'SYS_MODEL'], + }, + { + caseName: 'explicit empty comment', + system: { comment: '' }, + expected: { COMMENT: '' }, + omitted: ['NAME', 'SYS_MODEL'], + }, + ])( + 'applyServerIdentity should map partial identity fields correctly ($caseName)', + async (scenario) => { + (service as any).activationData = plainToInstance(ActivationCode, { + system: scenario.system, + }); + + let params: Record | undefined; + vi.mocked(emcmd).mockImplementation(async (incomingParams) => { + params = incomingParams as Record; + return { body: '', ok: true } as any; + }); + + await (service as any).applyServerIdentity(); + + expect(emcmd).toHaveBeenCalledTimes(1); + expect(params).toMatchObject({ + ...scenario.expected, + changeNames: 'Apply', + server_addr: '', + server_name: '', + }); + scenario.omitted.forEach((key) => { + expect(params).not.toHaveProperty(key); + }); + } + ); + it('applyServerIdentity should truncate serverName if too long', async () => { const longServerName = 'ThisServerNameIsWayTooLongForUnraid'; // Length > 16 const truncatedServerName = longServerName.slice(0, 15); // Expected truncated length @@ -774,10 +1174,52 @@ describe('CustomizationService', () => { const testActivationParser = await plainToInstance(ActivationCode, { ...mockActivationData, - serverName: longServerName, + system: { ...mockActivationData.system, serverName: longServerName }, + }); + + expect(testActivationParser.system?.serverName).toBe(truncatedServerName); + }); + + it('applyServerIdentity should sanitize and truncate activation comments', async () => { + const unsafeLongComment = `${'"\\'.repeat(40)}${'A'.repeat(100)}`; + const parsedActivation = plainToInstance(ActivationCode, { + system: { + comment: unsafeLongComment, + }, + }); + + expect(parsedActivation.system?.comment).toBeDefined(); + expect(parsedActivation.system?.comment).not.toMatch(/["\\]/); + expect(parsedActivation.system?.comment!.length).toBeLessThanOrEqual(64); + }); + + it('applyServerIdentity should send sanitized identity values from transformed activation data', async () => { + const unsafeIdentity = plainToInstance(ActivationCode, { + system: { + serverName: 'Par"t\\nerServer', + model: 'Pa"rt\\nerModel', + comment: 'Partn"er\\Comment', + }, + }); + (service as any).activationData = unsafeIdentity; + + let params: Record | undefined; + vi.mocked(emcmd).mockImplementation(async (incomingParams) => { + params = incomingParams as Record; + return { body: '', ok: true } as any; }); - expect(testActivationParser.serverName).toBe(truncatedServerName); + await (service as any).applyServerIdentity(); + + expect(emcmd).toHaveBeenCalledTimes(1); + expect(params).toMatchObject({ + NAME: 'PartnerServer', + SYS_MODEL: 'PartnerModel', + COMMENT: 'PartnerComment', + }); + expect(params?.NAME).not.toMatch(/["\\]/); + expect(params?.SYS_MODEL).not.toMatch(/["\\]/); + expect(params?.COMMENT).not.toMatch(/["\\]/); }); it('should correctly pass server_https parameter based on nginx state', async () => { @@ -790,9 +1232,11 @@ describe('CustomizationService', () => { // Set up the service's activationData field directly (service as any).activationData = plainToInstance(ActivationCode, { - serverName: 'PartnerServer', - sysModel: 'PartnerModel', - comment: 'Partner Comment', + system: { + serverName: 'PartnerServer', + model: 'PartnerModel', + comment: 'Partner Comment', + }, }); // Mock emcmd and capture the params for snapshot testing @@ -854,7 +1298,7 @@ describe('CustomizationService', () => { }); describe('applyActivationCustomizations specific tests', () => { - let service: CustomizationService; + let service: OnboardingService; let loggerLogSpy; let loggerWarnSpy; let loggerErrorSpy; @@ -872,14 +1316,22 @@ describe('applyActivationCustomizations specific tests', () => { // Add mockActivationData definition here const mockActivationData = { - header: '#112233', - headermetacolor: '#445566', - background: '#778899', - showBannerGradient: true, - theme: 'black', - serverName: 'PartnerServer', - sysModel: 'PartnerModel', - comment: 'Partner Comment', + branding: { + header: '#112233', + headermetacolor: '#445566', + background: '#778899', + showBannerGradient: true, + theme: 'black', + bannerImage: './assets/banner.png', + caseModelImage: './assets/case-model.png', + partnerLogoLightUrl: './assets/partner-logo-light.png', + partnerLogoDarkUrl: './assets/partner-logo-dark.png', + }, + system: { + serverName: 'PartnerServer', + model: 'PartnerModel', + comment: 'Partner Comment', + }, }; beforeEach(async () => { @@ -889,11 +1341,33 @@ 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.isCompleted.mockReset(); + onboardingTrackerMock.isCompleted.mockReturnValue(false); + onboardingOverridesMock.getState.mockReset(); + onboardingOverridesMock.getState.mockReturnValue(null); + onboardingOverridesMock.setState.mockReset(); + onboardingOverridesMock.clearState.mockReset(); + onboardingStateMock.getRegistrationState.mockReset(); + onboardingStateMock.getRegistrationState.mockReturnValue(undefined); + onboardingStateMock.hasActivationCode.mockReset(); + onboardingStateMock.hasActivationCode.mockResolvedValue(false); + onboardingStateMock.isFreshInstall.mockReset(); + onboardingStateMock.isFreshInstall.mockReturnValue(false); + onboardingStateMock.requiresActivationStep.mockReset(); + onboardingStateMock.requiresActivationStep.mockReturnValue(false); + onboardingStateMock.isRegistered.mockReset(); + onboardingStateMock.isRegistered.mockReturnValue(false); + vi.mocked(fs.mkdir).mockResolvedValue(undefined as any); const module: TestingModule = await Test.createTestingModule({ - providers: [CustomizationService], + providers: [ + OnboardingService, + { provide: OnboardingTrackerService, useValue: onboardingTrackerMock }, + { provide: OnboardingOverrideService, useValue: onboardingOverridesMock }, + { provide: OnboardingStateService, useValue: onboardingStateMock }, + ], }).compile(); - service = module.get(CustomizationService); + service = module.get(OnboardingService); // Setup basic service state needed for applyActivationCustomizations tests (service as any).activationDir = activationDir; @@ -917,9 +1391,6 @@ describe('applyActivationCustomizations specific tests', () => { // Assume relevant assets/targets exist unless overridden return p === bannerSource || p === caseModelSource || p === bannerTarget.fullPath; }); - - // Import getters from the store mock - const { getters } = await import('@app/store/index.js'); }); it('should log warning and skip if activation dir disappears after init', async () => { @@ -960,7 +1431,7 @@ describe('applyActivationCustomizations specific tests', () => { // Other steps after display settings should still be attempted expect(loggerLogSpy).toHaveBeenCalledWith('Applying case model...'); // Check if next step's log appears - expect(loggerLogSpy).toHaveBeenCalledWith('Applying server identity...'); + expect(loggerLogSpy).not.toHaveBeenCalledWith('Applying server identity...'); // Overall error from applyActivationCustomizations' catch block // REMOVED: expect(loggerErrorSpy).toHaveBeenCalledWith('Error during activation setup:', updateError); @@ -977,26 +1448,11 @@ describe('applyActivationCustomizations specific tests', () => { await (service as any).applyActivationCustomizations(); // Check specific log from applyCaseModelConfig's *inner* catch block - expect(loggerErrorSpy).toMatchInlineSnapshot(` - [MockFunction spy] { - "calls": [ - [ - "Error applying case model:", - [Error: Write permission denied], - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - ], - } - `); + expect(loggerErrorSpy).toHaveBeenCalledWith('Error applying case model:', writeError); // Other steps should still run expect(loggerLogSpy).toHaveBeenCalledWith('Setting up partner banner...'); expect(loggerLogSpy).toHaveBeenCalledWith('Applying display settings...'); - expect(loggerLogSpy).toHaveBeenCalledWith('Applying server identity...'); + expect(loggerLogSpy).not.toHaveBeenCalledWith('Applying server identity...'); // NO overall error logged because the writeFile error is caught internally expect(loggerErrorSpy).not.toHaveBeenCalledWith( @@ -1015,24 +1471,74 @@ describe('applyActivationCustomizations specific tests', () => { await (service as any).applyActivationCustomizations(); - // Check specific log from applyCaseModelConfig's catch block - expect(loggerErrorSpy).toHaveBeenCalledWith('Error applying case model:', existsError); + // Case model materialization failed, so case model apply step is skipped safely. + expect(loggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Failed to materialize activation case-model asset: ${existsError.message}` + ) + ); + expect(loggerLogSpy).toHaveBeenCalledWith( + 'No partner case-model image configured in activation code, skipping case model setup.' + ); // Other steps should still run expect(loggerLogSpy).toHaveBeenCalledWith('Setting up partner banner...'); expect(loggerLogSpy).toHaveBeenCalledWith('Applying display settings...'); - expect(loggerLogSpy).toHaveBeenCalledWith('Applying server identity...'); + expect(loggerLogSpy).not.toHaveBeenCalledWith('Applying server identity...'); // Overall error from applyActivationCustomizations' catch block // REMOVED: expect(loggerErrorSpy).toHaveBeenCalledWith('Error during activation setup:', existsError); }, 10000); + it('should continue through chained banner/case-model failures without applying identity', async () => { + const bannerCopyError = new Error('Banner copy failed'); + const caseModelWriteError = new Error('Case model write failed'); + (service as any).activationData = plainToInstance(ActivationCode, { + system: { + serverName: 'PartnerServer', + model: 'PartnerModel', + comment: 'Partner Comment', + }, + branding: { + header: '#112233', + bannerImage: './assets/banner.png', + caseModelImage: './assets/case-model.png', + }, + }); + + vi.mocked(fileExists).mockImplementation(async (p) => { + return p === bannerSource || p === caseModelSource || p === bannerTarget.fullPath; + }); + vi.mocked(fs.copyFile).mockImplementation(async (source, dest) => { + if ( + source === bannerSource && + typeof dest === 'string' && + dest.startsWith(`${bannerTarget.fullPath}.tmp-`) + ) { + throw bannerCopyError; + } + }); + vi.mocked(fs.writeFile).mockImplementation(async (filePath) => { + if (filePath === caseModelCfg) { + throw caseModelWriteError; + } + }); + + await (service as any).applyActivationCustomizations(); + + expect(loggerWarnSpy).toHaveBeenCalledWith( + `Failed to replace the original banner with the partner banner: ${bannerCopyError.message}` + ); + expect(loggerErrorSpy).toHaveBeenCalledWith('Error applying case model:', caseModelWriteError); + expect(emcmd).not.toHaveBeenCalled(); + }, 10000); + // We no longer update config files in applyServerIdentity, so this test is removed }); // Standalone tests for updateCfgFile utility function within the service -describe('CustomizationService - updateCfgFile', () => { - let service: CustomizationService; +describe('OnboardingService - updateCfgFile', () => { + let service: OnboardingService; let loggerLogSpy; let loggerErrorSpy; const filePath = '/test/config.cfg'; @@ -1041,12 +1547,34 @@ describe('CustomizationService - updateCfgFile', () => { vi.clearAllMocks(); loggerLogSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); loggerErrorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {}); + onboardingTrackerMock.isCompleted.mockReset(); + onboardingTrackerMock.isCompleted.mockReturnValue(false); + onboardingOverridesMock.getState.mockReset(); + onboardingOverridesMock.getState.mockReturnValue(null); + onboardingOverridesMock.setState.mockReset(); + onboardingOverridesMock.clearState.mockReset(); + onboardingStateMock.getRegistrationState.mockReset(); + onboardingStateMock.getRegistrationState.mockReturnValue(undefined); + onboardingStateMock.hasActivationCode.mockReset(); + onboardingStateMock.hasActivationCode.mockResolvedValue(false); + onboardingStateMock.isFreshInstall.mockReset(); + onboardingStateMock.isFreshInstall.mockReturnValue(false); + onboardingStateMock.requiresActivationStep.mockReset(); + onboardingStateMock.requiresActivationStep.mockReturnValue(false); + onboardingStateMock.isRegistered.mockReset(); + onboardingStateMock.isRegistered.mockReturnValue(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: [CustomizationService], + providers: [ + OnboardingService, + { provide: OnboardingTrackerService, useValue: onboardingTrackerMock }, + { provide: OnboardingOverrideService, useValue: onboardingOverridesMock }, + { provide: OnboardingStateService, useValue: onboardingStateMock }, + ], }).compile(); - service = module.get(CustomizationService); + service = module.get(OnboardingService); // Mock file system operations for updateCfgFile vi.mocked(fs.readFile).mockImplementation(async (p) => { @@ -1115,6 +1643,24 @@ describe('CustomizationService - updateCfgFile', () => { ); }); + it('should preserve quoted yes/no-style values in display section writes', async () => { + const section = 'display'; + const updates = { theme: 'white' }; + const existingData = '[display]\nterminalButton="yes"\n'; + vi.mocked(fs.readFile).mockResolvedValue(existingData); + + await (service as any).updateCfgFile(filePath, section, updates); + + expect(fs.writeFile).toHaveBeenCalledOnce(); + const writeArgs = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenRaw = writeArgs[1] as string; + expect(writtenRaw).toContain('terminalButton="yes"'); + + const writtenContent = ini.parse(writtenRaw); + expect((writtenContent.display as Record).terminalButton).toBe('yes'); + expect((writtenContent.display as Record).theme).toBe('white'); + }); + it('should merge updates with existing content (no section)', async () => { const updates = { key1: 'newValue1', key3: 'newValue3' }; const existingData = { key1: 'oldValue1', key2: 'oldValue2' }; diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts new file mode 100644 index 0000000000..1863183c8a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts @@ -0,0 +1,963 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { plainToClass } from 'class-transformer'; +import { validateOrReject } from 'class-validator'; +import { GraphQLError } from 'graphql'; +import * as ini from 'ini'; + +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer.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 { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; +import { OnboardingStateService } from '@app/unraid-api/config/onboarding-state.service.js'; +import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; +import { + ActivationCode, + BrandingConfig, + OnboardingState, + PublicPartnerInfo, +} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { + findActivationCodeFile, + getActivationDirCandidates, +} from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; +import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; + +@Injectable() +export class OnboardingService implements OnModuleInit { + private readonly logger = new Logger(OnboardingService.name); + private readonly activationJsonExtension = '.activationcode'; + private readonly maxActivationImageBytes = 10 * 1024 * 1024; + private activationDir!: string; + private configFile!: string; + private caseModelCfg!: string; + private identCfg!: string; + private activationJsonPath: string | null = null; + private materializedPartnerMedia: Record<'banner' | 'caseModel', boolean> = { + banner: false, + caseModel: false, + }; + + private activationData: ActivationCode | null = null; + + constructor( + private readonly onboardingTracker: OnboardingTrackerService, + private readonly onboardingOverrides: OnboardingOverrideService, + private readonly onboardingState: OnboardingStateService + ) {} + + private async ensureFirstBootCompletion(): Promise { + await fs.mkdir(this.activationDir, { recursive: true }); + // Check if onboarding has already been completed + const alreadyCompleted = this.onboardingTracker.isCompleted(); + if (alreadyCompleted) { + this.logger.log('Onboarding already completed, skipping first boot setup.'); + return true; + } + this.logger.log('First boot setup in progress.'); + return false; + } + + async onModuleInit() { + const paths = getters.paths(); + + this.activationDir = paths.activationBase; + this.configFile = paths['dynamix-config']?.[1]; + this.caseModelCfg = paths.boot?.caseModelConfig; + 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 { + const resolvedActivationDir = await this.resolveActivationDir(this.activationDir); + if (!resolvedActivationDir) { + this.logger.log( + `Activation directory ${this.activationDir} not found. Skipping activation setup.` + ); + return; + } + if (resolvedActivationDir !== this.activationDir) { + this.logger.log( + `Activation directory fallback detected. Using ${resolvedActivationDir} (configured ${this.activationDir}).` + ); + this.activationDir = resolvedActivationDir; + } + this.logger.log(`Activation directory found: ${this.activationDir}`); + + // Proceed with first boot check and activation data retrieval ONLY if dir exists + const hasRunFirstBootSetup = await this.ensureFirstBootCompletion(); + if (hasRunFirstBootSetup) { + this.logger.log('First boot setup previously completed, skipping customizations.'); + return; + } + + this.activationData = await this.getActivationData(); // This now uses this.activationDir + await this.applyActivationCustomizations(); // This uses this.activationData and paths + } catch (error: unknown) { + // Catch errors specifically from the activation setup logic post-path init + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' && + 'path' in error && + error.path === this.activationDir + ) { + // This case should be handled by the access check above, but keep for safety. + this.logger.log('Activation directory check failed within setup logic.'); + } else { + this.logger.error('Error during activation check/setup on init:', error); + } + } + } + + private async resolveActivationDir(configuredDir: string): Promise { + const candidates = getActivationDirCandidates(configuredDir); + for (const activationDir of candidates) { + try { + await fs.access(activationDir); + return activationDir; + } catch (dirError: unknown) { + if ( + !(dirError instanceof Error) || + !('code' in dirError) || + dirError.code !== 'ENOENT' + ) { + throw dirError; + } + } + } + return null; + } + + private async getActivationJsonPath(): Promise { + return findActivationCodeFile(this.activationDir, this.activationJsonExtension, this.logger); + } + + public async getPublicPartnerInfo(): Promise { + const override = this.onboardingOverrides.getState(); + + // If partnerInfo is explicitly overridden, use it + if (override?.partnerInfo !== undefined) { + if (override.partnerInfo === null) { + return null; + } + return this.buildPublicPartnerInfo( + override.partnerInfo.partner, + override.partnerInfo.branding + ); + } + + // If activationCode is overridden, derive partnerInfo from it + // This ensures edits to activationCode.branding are reflected in the UI + if (override?.activationCode !== undefined) { + if (override.activationCode === null) { + return null; + } + return this.buildPublicPartnerInfo( + override.activationCode.partner, + override.activationCode.branding + ); + } + + const activationData = await this.getActivationData(); + if (!activationData) { + return null; + } + + return this.buildPublicPartnerInfo(activationData.partner, activationData.branding); + } + + public async getActivationDataForPublic(): Promise { + const activationData = await this.getActivationData(); + if (!activationData) { + return null; + } + + const publicPartnerInfo = await this.buildPublicPartnerInfo( + activationData.partner, + activationData.branding + ); + + return plainToClass(ActivationCode, { + ...activationData, + partner: publicPartnerInfo.partner, + branding: publicPartnerInfo.branding, + }); + } + + private detectImageMime(buffer: Buffer): string { + if (buffer.length >= 8) { + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) { + return 'image/png'; + } + + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'image/jpeg'; + } + + if ( + buffer.subarray(0, 4).toString('ascii') === 'RIFF' && + buffer.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp'; + } + } + + if (buffer.length >= 6 && buffer.subarray(0, 6).toString('ascii') === 'GIF87a') { + return 'image/gif'; + } + + if (buffer.length >= 6 && buffer.subarray(0, 6).toString('ascii') === 'GIF89a') { + return 'image/gif'; + } + + const textChunk = buffer.subarray(0, 1024).toString('utf8').trimStart(); + if (textChunk.startsWith(' { + const normalizedSource = source.trim(); + if (!normalizedSource) { + throw new Error('Image source is empty.'); + } + + if (this.looksLikeHttpUrl(normalizedSource)) { + return normalizedSource; + } + + const dataUriPayload = this.tryDecodeDataUri(normalizedSource); + if (dataUriPayload) { + this.assertImageSize(dataUriPayload); + return this.bufferToDataUri(dataUriPayload); + } + + const rawBase64Payload = this.tryDecodeRawBase64(normalizedSource); + if (rawBase64Payload) { + this.assertImageSize(rawBase64Payload); + return this.bufferToDataUri(rawBase64Payload); + } + + const localSourcePath = this.resolveLocalImagePath(normalizedSource); + if (!(await fileExists(localSourcePath))) { + throw new Error(`Local ${label} partner logo source not found: ${localSourcePath}`); + } + + const payload = await fs.readFile(localSourcePath); + this.assertImageSize(payload); + return this.bufferToDataUri(payload); + } + + private async buildPublicPartnerInfo( + partner: PublicPartnerInfo['partner'], + brandingInput: PublicPartnerInfo['branding'] + ): Promise { + const branding = brandingInput ? { ...brandingInput } : {}; + + if (branding.partnerLogoLightUrl?.trim()) { + try { + branding.partnerLogoLightUrl = await this.normalizePartnerLogoSourceForBrowser( + branding.partnerLogoLightUrl, + 'light' + ); + } catch (error: unknown) { + branding.partnerLogoLightUrl = null; + this.logger.warn( + `Failed to normalize light partner logo source: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + if (branding.partnerLogoDarkUrl?.trim()) { + try { + branding.partnerLogoDarkUrl = await this.normalizePartnerLogoSourceForBrowser( + branding.partnerLogoDarkUrl, + 'dark' + ); + } catch (error: unknown) { + branding.partnerLogoDarkUrl = null; + this.logger.warn( + `Failed to normalize dark partner logo source: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + const darkLogoUrl = branding.partnerLogoDarkUrl ?? null; + const lightLogoUrl = branding.partnerLogoLightUrl ?? null; + + // If only one variant is provided, use it for both themes. + branding.partnerLogoDarkUrl = darkLogoUrl ?? lightLogoUrl; + branding.partnerLogoLightUrl = lightLogoUrl ?? darkLogoUrl; + branding.hasPartnerLogo = Boolean(branding.partnerLogoDarkUrl || branding.partnerLogoLightUrl); + + return { + partner, + branding: plainToClass(BrandingConfig, branding), + }; + } + + public async isPasswordSet(): Promise { + const paths = store.getState().paths; + const hasPasswd = await fileExists(paths.passwd); + return hasPasswd; + } + + public async getOnboardingState(): Promise { + const registrationState = this.onboardingState.getRegistrationState(); + const hasActivationCode = await this.onboardingState.hasActivationCode(); + const isFreshInstall = this.onboardingState.isFreshInstall(registrationState); + const isRegistered = this.onboardingState.isRegistered(registrationState); + const activationRequired = + hasActivationCode && this.onboardingState.requiresActivationStep(registrationState); + + return { + registrationState, + isRegistered, + isFreshInstall, + hasActivationCode, + activationRequired, + }; + } + + public isFreshInstall(): boolean { + return this.onboardingState.isFreshInstall(); + } + + /** + * Get the activation data from the activation directory. + * @returns The activation data or null if the file is not found or invalid. + * @throws Error if the directory does not exist. + */ + async getActivationData(): Promise { + const override = this.onboardingOverrides.getState(); + if (override?.activationCode !== undefined) { + return override.activationCode ?? null; + } + + // Return cached data if available + if (this.activationData) { + this.logger.debug('Returning cached activation data.'); + return this.activationData; + } + + this.logger.debug('Fetching activation data from disk...'); + const activationJsonPath = await this.getActivationJsonPath(); + + if (!activationJsonPath) { + this.logger.debug('No activation JSON file found.'); + return null; + } + this.activationJsonPath = activationJsonPath; + + try { + const fileContent = await fs.readFile(activationJsonPath, 'utf-8'); + const activationDataRaw = JSON.parse(fileContent); + + const activationDataDto = plainToClass(ActivationCode, activationDataRaw); + await validateOrReject(activationDataDto); + + // Cache the validated data + this.activationData = activationDataDto; + this.logger.debug('Activation data fetched and cached.'); + return this.activationData; + } catch (error) { + this.logger.error(`Error processing activation file ${activationJsonPath}:`, error); + // Do not cache in case of error + return null; + } + } + + public clearActivationDataCache(): void { + this.activationData = null; + this.activationJsonPath = null; + this.materializedPartnerMedia = { + banner: false, + caseModel: false, + }; + } + + async applyActivationCustomizations() { + this.logger.log('Applying activation customizations if data is available...'); + + if (!this.activationData) { + this.logger.log('No valid activation data found. Skipping customizations.'); + return; + } + + try { + // Check if activation dir exists (redundant if onModuleInit succeeded, but safe) + try { + await fs.access(this.activationDir); + } catch (dirError: unknown) { + if (dirError instanceof Error && 'code' in dirError && dirError.code === 'ENOENT') { + this.logger.warn('Activation directory disappeared after init? Skipping.'); + return; + } + throw dirError; // Rethrow other errors + } + + this.logger.log(`Using validated activation data to apply customizations.`); + + await this.materializePartnerMediaAssets(); + await this.setupPartnerBanner(); + await this.applyDisplaySettings(); + await this.applyCaseModelConfig(); + + this.logger.log('Activation setup complete.'); + } catch (error: unknown) { + // Added type annotation + // Initial dir check removed as it's handled in onModuleInit or the inner try block + this.logger.error('Error during activation setup:', error); + } + } + + private async materializePartnerMediaAssets() { + this.materializedPartnerMedia = { + banner: false, + caseModel: false, + }; + if (!this.activationData?.branding) { + return; + } + + const paths = getters.paths(); + const mediaSources: Array<{ + key: 'banner' | 'caseModel'; + label: string; + source?: string | null; + targetPath: string; + }> = [ + { + key: 'banner', + label: 'banner', + source: this.activationData.branding.bannerImage, + targetPath: paths.activation.banner, + }, + { + key: 'caseModel', + label: 'case-model', + source: this.activationData.branding.caseModelImage, + targetPath: paths.activation.caseModel, + }, + ]; + + for (const media of mediaSources) { + if (!media.source?.trim()) { + continue; + } + + try { + await this.materializeImageAsset(media.source, media.targetPath); + this.materializedPartnerMedia[media.key] = true; + this.logger.log(`Materialized activation ${media.label} asset at ${media.targetPath}`); + } catch (error: unknown) { + this.materializedPartnerMedia[media.key] = false; + await this.safeUnlink(media.targetPath); + this.logger.warn( + `Failed to materialize activation ${media.label} asset: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + } + + private looksLikeHttpUrl(source: string): boolean { + try { + const parsed = new URL(source); + return parsed.protocol === 'https:' || parsed.protocol === 'http:'; + } catch { + return false; + } + } + + private tryDecodeDataUri(source: string): Buffer | null { + if (!source.startsWith('data:')) { + return null; + } + + const separator = source.indexOf(','); + if (separator < 0) { + return null; + } + + const meta = source.slice(5, separator); + const payload = source.slice(separator + 1); + const isBase64 = /;base64/i.test(meta); + + try { + if (isBase64) { + return Buffer.from(payload, 'base64'); + } + return Buffer.from(decodeURIComponent(payload), 'utf8'); + } catch { + return null; + } + } + + private tryDecodeRawBase64(source: string): Buffer | null { + const normalized = source.replace(/\s+/g, ''); + if (normalized.length < 64 || normalized.length % 4 !== 0) { + return null; + } + if (!/^[A-Za-z0-9+/=]+$/.test(normalized)) { + return null; + } + + try { + const decoded = Buffer.from(normalized, 'base64'); + if (!decoded.length) { + return null; + } + const normalizedInput = normalized.replace(/=+$/g, ''); + const normalizedDecoded = decoded.toString('base64').replace(/=+$/g, ''); + if (normalizedInput !== normalizedDecoded) { + return null; + } + return decoded; + } catch { + return null; + } + } + + private resolveLocalImagePath(source: string): string { + if (path.isAbsolute(source)) { + return path.resolve(source); + } + + const activationJsonDir = this.activationJsonPath + ? path.dirname(this.activationJsonPath) + : this.activationDir; + return path.resolve(activationJsonDir, source); + } + + private async safeUnlink(filePath: string) { + try { + await fs.unlink(filePath); + } catch (error: unknown) { + if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') { + throw error; + } + } + } + + private assertImageSize(buffer: Buffer) { + if (!buffer.length) { + throw new Error('Image source resolved to an empty payload.'); + } + if (buffer.length > this.maxActivationImageBytes) { + throw new Error(`Image payload exceeds max size (${this.maxActivationImageBytes} bytes).`); + } + } + + private async writeBinaryAsset(targetPath: string, payload: Buffer) { + this.assertImageSize(payload); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, payload); + } + + private async replaceTargetWithSource(sourcePath: string, targetPath: string) { + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + const tempTarget = `${targetPath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + try { + try { + await fs.symlink(sourcePath, tempTarget); + } catch { + await fs.copyFile(sourcePath, tempTarget); + } + await fs.rename(tempTarget, targetPath); + } finally { + await this.safeUnlink(tempTarget); + } + } + + private async materializeImageAsset(source: string, targetPath: string) { + const normalizedSource = source.trim(); + if (!normalizedSource) { + throw new Error('Image source is empty.'); + } + + const dataUriPayload = this.tryDecodeDataUri(normalizedSource); + if (dataUriPayload) { + await this.writeBinaryAsset(targetPath, dataUriPayload); + return; + } + + if (this.looksLikeHttpUrl(normalizedSource)) { + const response = await fetch(normalizedSource, { + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get('content-type')?.toLowerCase(); + if (contentType && !contentType.includes('image/') && !contentType.includes('svg')) { + throw new Error(`Remote source is not an image (content-type: ${contentType})`); + } + + const remotePayload = Buffer.from(await response.arrayBuffer()); + await this.writeBinaryAsset(targetPath, remotePayload); + return; + } + + const rawBase64Payload = this.tryDecodeRawBase64(normalizedSource); + if (rawBase64Payload) { + await this.writeBinaryAsset(targetPath, rawBase64Payload); + return; + } + + const localSourcePath = this.resolveLocalImagePath(normalizedSource); + if (!(await fileExists(localSourcePath))) { + throw new Error(`Local image source not found: ${localSourcePath}`); + } + + const resolvedSource = path.resolve(localSourcePath); + const resolvedTarget = path.resolve(targetPath); + if (resolvedSource === resolvedTarget) { + return; + } + + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await this.safeUnlink(targetPath); + + try { + await fs.symlink(resolvedSource, targetPath); + } catch { + await fs.copyFile(resolvedSource, targetPath); + } + } + + private async setupPartnerBanner() { + this.logger.log('Setting up partner banner...'); + if (!this.materializedPartnerMedia.banner) { + this.logger.log( + 'No partner banner image configured in activation code, skipping banner setup.' + ); + return; + } + const paths = getters.paths(); + const bannerSource = paths.activation.banner; + const bannerTarget = paths.webgui.banner.fullPath; + + try { + // Always overwrite if partner banner exists + if (await fileExists(bannerSource)) { + this.logger.log(`Partner banner found at ${bannerSource}, overwriting original.`); + try { + await this.replaceTargetWithSource(bannerSource, bannerTarget); + this.logger.log('Partner banner copied over the original banner.'); + } catch (copyError: unknown) { + this.logger.warn( + `Failed to replace the original banner with the partner banner: ${copyError instanceof Error ? copyError.message : 'Unknown error'}` + ); + } + } else { + this.logger.log('Partner banner file not found, skipping banner setup.'); + } + } catch (error) { + this.logger.error('Error setting up partner banner:', error); + } + } + + private async applyDisplaySettings() { + if (!this.activationData) { + this.logger.warn('No activation data available for display settings.'); + return; + } + + this.logger.log('Applying display settings...'); + const currentDisplaySettings = getters.dynamix()?.display || {}; + this.logger.debug('Current display settings from store:', currentDisplaySettings); + + const existingDisplaySettings = Object.entries(currentDisplaySettings).reduce< + Record + >((accumulator, [key, value]) => { + if (typeof value === 'string') { + accumulator[key] = value; + } + return accumulator; + }, {}); + + const settingsToUpdate: Record = {}; + + // Map activation data properties to their corresponding config keys + type DisplayMapping = { + key: string; + transform?: (v: unknown) => string; + skipIfEmpty?: boolean; + }; + + const displayMappings: Record = { + header: { + key: 'header', + transform: (v: unknown) => (typeof v === 'string' ? v.replace('#', '') : ''), + skipIfEmpty: true, + }, + headermetacolor: { + key: 'headermetacolor', + transform: (v: unknown) => (typeof v === 'string' ? v.replace('#', '') : ''), + skipIfEmpty: true, + }, + background: { + key: 'background', + transform: (v: unknown) => (typeof v === 'string' ? v.replace('#', '') : ''), + skipIfEmpty: true, + }, + showBannerGradient: { + key: 'showBannerGradient', + transform: (v: unknown) => (v === true ? 'yes' : 'no'), + }, + }; + + // Apply mappings + const brandingConfig = this.activationData.branding || {}; + + Object.entries(displayMappings).forEach(([prop, mapping]) => { + const value = brandingConfig[prop as keyof typeof brandingConfig]; + if (value !== undefined && value !== null) { + const transformedValue = mapping.transform ? mapping.transform(value) : value; + if (!mapping.skipIfEmpty || transformedValue) { + settingsToUpdate[mapping.key] = transformedValue as string; // Ensure string type for record + } + } + }); + + // Only set banner='image' if the banner file actually exists in the webgui images directory + // This assumes setupPartnerBanner has already attempted to copy it if necessary. + const paths = getters.paths(); + const bannerSource = paths.activation.banner; + + if (this.materializedPartnerMedia.banner && (await fileExists(bannerSource))) { + settingsToUpdate['banner'] = 'image'; + this.logger.debug(`Webgui banner exists at ${bannerSource}, setting banner=image.`); + } else { + this.logger.debug( + `Webgui banner does not exist at ${bannerSource}, skipping banner=image setting.` + ); + } + + if (Object.keys(settingsToUpdate).length === 0) { + this.logger.log( + 'No new display settings found in activation data or derived from banner state.' + ); + return; + } + + this.logger.log('Updating display settings:', settingsToUpdate); + + try { + await this.updateCfgFile(this.configFile, 'display', { + ...existingDisplaySettings, + ...settingsToUpdate, + }); + this.logger.log('Display settings updated in config file.'); + } catch (error) { + this.logger.error('Error applying display settings:', error); + } + } + + private async applyCaseModelConfig() { + if (!this.activationData) { + this.logger.warn('No activation data available for case model setup.'); + return; + } + if (!this.materializedPartnerMedia.caseModel) { + this.logger.log( + 'No partner case-model image configured in activation code, skipping case model setup.' + ); + return; + } + if (!this.caseModelCfg) { + this.logger.warn('Case model config path missing. Skipping case model setup.'); + return; + } + + this.logger.log('Applying case model...'); + const paths = getters.paths(); + const caseModelSource = paths.activation.caseModel; + + try { + if (await fileExists(caseModelSource)) { + this.logger.log('Case model found in activation assets, applying...'); + const modelToSet = path.basename(paths.webgui.caseModel.fullPath); // e.g., 'case-model.png' + await this.replaceTargetWithSource(caseModelSource, paths.webgui.caseModel.fullPath); + await fs.mkdir(path.dirname(this.caseModelCfg), { recursive: true }); + await fs.writeFile(this.caseModelCfg, modelToSet); + this.logger.log(`Case model set to ${modelToSet} in ${this.caseModelCfg}`); + } else { + this.logger.log('No custom case model file found in activation assets.'); + } + } catch (error) { + this.logger.error('Error applying case model:', error); + } + } + + private async applyServerIdentity() { + if (!this.activationData) { + this.logger.warn('No activation data available for server identity setup.'); + return; + } + + this.logger.log('Applying server identity...'); + // Ideally, get current values from Redux store instead of var.ini + // Assuming EmhttpState type provides structure for emhttp slice. Adjust if necessary. + // Using optional chaining ?. in case emhttp or var is not defined in the state yet. + const currentEmhttpState = getters.emhttp(); + const currentName = currentEmhttpState?.var?.name || ''; + // Skip sending sysModel to emcmd for now + const currentSysModel = ''; + const currentComment = currentEmhttpState?.var?.comment || ''; + + this.logger.debug( + `Current identity - Name: ${currentName}, Model: ${currentSysModel}, Comment: ${currentComment}` + ); + + const { serverName, model: sysModel, comment } = this.activationData.system || {}; + const paramsToUpdate: Record = { + ...(serverName && { NAME: serverName }), + ...(sysModel && { SYS_MODEL: sysModel }), + ...(comment !== undefined && { COMMENT: comment }), + }; + + if (Object.keys(paramsToUpdate).length === 0) { + this.logger.log('No server identity information found in activation data.'); + return; + } + + this.logger.log('Updating server identity:', paramsToUpdate); + + try { + // Trigger emhttp update via emcmd + const updateParams = { + ...paramsToUpdate, + changeNames: 'Apply', + // Can be null string + server_name: '', + // Can be null string + server_addr: '', + }; + this.logger.log(`Calling emcmd with params: %o`, updateParams); + await emcmd(updateParams, { waitForToken: true }); + + this.logger.log('emcmd executed successfully.'); + } catch (error: unknown) { + this.logger.error('Error applying server identity: %o', error); + } + } + + // Helper function to update .cfg files (like dynamix.cfg or ident.cfg) using the ini library + private async updateCfgFile( + filePath: string, + section: string | null, + updates: Record + ) { + let configData: Record | string> = {}; + try { + const content = await fs.readFile(filePath, 'utf-8'); + // Parse the INI file content. Note: ini library parses values as strings by default. + // It might interpret numbers/booleans if not quoted, but our values are always quoted. + configData = ini.parse(content) as Record | string>; + } catch (error: unknown) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + this.logger.log(`Config file ${filePath} not found, will create it.`); + // Initialize configData as an empty object if file doesn't exist + } else { + this.logger.error(`Error reading config file ${filePath}:`, error); + throw error; // Re-throw other errors + } + } + + if (section) { + if (!configData[section] || typeof configData[section] === 'string') { + configData[section] = {}; + } + Object.entries(updates).forEach(([key, value]) => { + (configData[section] as Record)[key] = value; + }); + } else { + Object.entries(updates).forEach(([key, value]) => { + configData[key] = value; + }); + } + + try { + const hasTopLevelScalarValues = Object.values(configData).some( + (value) => value === null || typeof value !== 'object' || Array.isArray(value) + ); + const newContent = hasTopLevelScalarValues + ? ini.stringify(configData) + : safelySerializeObjectToIni(configData); + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, newContent + '\n'); + this.logger.log(`Config file ${filePath} updated successfully.`); + } catch (error: unknown) { + this.logger.error(`Error writing config file ${filePath}:`, error); + throw error; + } + } + + private addHashtoHexField(field: string | undefined): string | undefined { + return field ? `#${field}` : undefined; + } + + public async getTheme(): Promise { + if (!getters.dynamix()?.display?.theme) { + throw new GraphQLError('No theme found or loaded from dynamix.cfg settings.'); + } + + const name = + ThemeName[getters.dynamix()!.display!.theme.toLowerCase() as keyof typeof ThemeName] ?? + ThemeName.white; + + const banner = getters.dynamix()!.display!.banner; + const bannerGradient = getters.dynamix()!.display!.showBannerGradient; + const bgColor = getters.dynamix()!.display!.background; + const descriptionShow = getters.dynamix()!.display!.headerdescription; + const metaColor = getters.dynamix()!.display!.headermetacolor; + const textColor = getters.dynamix()!.display!.header; + + return { + name, + showBannerImage: banner === 'image' || banner === 'yes', + showBannerGradient: bannerGradient === 'yes', + headerBackgroundColor: this.addHashtoHexField(bgColor), + headerPrimaryTextColor: this.addHashtoHexField(textColor), + headerSecondaryTextColor: this.addHashtoHexField(metaColor), + showHeaderDescription: descriptionShow === 'yes', + }; + } + + public async setTheme(theme: ThemeName): Promise { + this.logger.log(`Updating theme to ${theme}`); + await this.updateCfgFile(this.configFile, 'display', { theme }); + + // Refresh in-memory store so subsequent reads get the new theme without a restart + const paths = getters.paths(); + const updatedConfig = loadDynamixConfigFromDiskSync(paths['dynamix-config']); + store.dispatch(updateDynamixConfig(updatedConfig)); + + return this.getTheme(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/display/display.model.ts b/api/src/unraid-api/graph/resolvers/info/display/display.model.ts index f75c6ed2e7..6d0f6889ce 100644 --- a/api/src/unraid-api/graph/resolvers/info/display/display.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/display/display.model.ts @@ -77,6 +77,18 @@ export class InfoDisplay extends Node { locale?: string; } +@ObjectType() +export class Language { + @Field(() => String, { description: 'Language code (e.g. en_US)' }) + code!: string; + + @Field(() => String, { description: 'Language description/name' }) + name!: string; + + @Field(() => String, { nullable: true, description: 'URL to the language pack XML' }) + url?: string; +} + // Export aliases for backward compatibility with the main DisplayResolver export { InfoDisplay as Display }; export { InfoDisplayCase as DisplayCase }; diff --git a/api/src/unraid-api/graph/resolvers/info/display/display.service.mutations.spec.ts b/api/src/unraid-api/graph/resolvers/info/display/display.service.mutations.spec.ts new file mode 100644 index 0000000000..1d4caf31eb --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/display/display.service.mutations.spec.ts @@ -0,0 +1,218 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import * as ini from 'ini'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +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 { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; + +const { mockedPaths, dispatchMock } = vi.hoisted(() => ({ + mockedPaths: vi.fn(), + dispatchMock: vi.fn(), +})); + +vi.mock('@app/store/index.js', () => ({ + getters: { + paths: mockedPaths, + }, + store: { + dispatch: dispatchMock, + }, +})); + +vi.mock('@app/store/actions/load-dynamix-config-file.js', () => ({ + loadDynamixConfigFromDiskSync: vi.fn(), +})); + +vi.mock('@app/store/modules/dynamix.js', () => ({ + updateDynamixConfig: vi.fn((payload: unknown) => ({ + type: 'dynamix/updateDynamixConfig', + payload, + })), +})); + +describe('DisplayService mutations', () => { + let service: DisplayService; + let tempDir: string; + let displayCfg: string; + + beforeEach(async () => { + vi.clearAllMocks(); + service = new DisplayService(); + + tempDir = await mkdtemp(join(tmpdir(), 'display-mutations-')); + displayCfg = join(tempDir, 'dynamix.cfg'); + + await writeFile(displayCfg, '[display]\ntheme="white"\nlocale="en_US"\n'); + + mockedPaths.mockReturnValue({ + 'dynamix-base': tempDir, + 'dynamix-config': [join(tempDir, 'default.cfg'), displayCfg], + }); + + vi.mocked(loadDynamixConfigFromDiskSync).mockReturnValue({ + display: { + theme: 'black', + locale: 'fr_FR', + }, + } as any); + + vi.mocked(updateDynamixConfig).mockReturnValue({ + type: 'dynamix/updateDynamixConfig', + payload: { display: { theme: 'black', locale: 'fr_FR' } }, + } as any); + + vi.mocked(store.dispatch).mockReturnValue(undefined as any); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + vi.unstubAllGlobals(); + }); + + it('setTheme updates dynamix cfg and refreshes in-memory config', async () => { + const result = await service.setTheme('black'); + + const contents = await readFile(displayCfg, 'utf-8'); + const parsed = ini.parse(contents) as { display?: { theme?: string } }; + + expect(parsed.display?.theme).toBe('black'); + expect(loadDynamixConfigFromDiskSync).toHaveBeenCalledWith([ + join(tempDir, 'default.cfg'), + displayCfg, + ]); + expect(updateDynamixConfig).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalled(); + expect(result.theme).toBe('black'); + }); + + it('setLocale updates dynamix cfg and refreshes in-memory config', async () => { + const result = await service.setLocale('fr_FR'); + + const contents = await readFile(displayCfg, 'utf-8'); + const parsed = ini.parse(contents) as { display?: { locale?: string } }; + + expect(parsed.display?.locale).toBe('fr_FR'); + expect(loadDynamixConfigFromDiskSync).toHaveBeenCalledWith([ + join(tempDir, 'default.cfg'), + displayCfg, + ]); + expect(updateDynamixConfig).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalled(); + expect(result.locale).toBe('fr_FR'); + }); + + it('throws when dynamix config path is unavailable', async () => { + mockedPaths.mockReturnValue({ + 'dynamix-base': tempDir, + }); + + await expect(service.setTheme('black')).rejects.toThrow('Dynamix config path not found'); + await expect(service.setLocale('fr_FR')).rejects.toThrow('Dynamix config path not found'); + }); + + it('uses language XML names and returns alphabetical results', async () => { + const feedUrl = 'https://assets.ca.unraid.net/feed/languageSelection.json'; + const norwegianXmlUrl = 'https://example.com/no_NO.xml'; + const frenchXmlUrl = 'https://example.com/fr_FR.xml'; + + const fetchMock = vi.fn().mockImplementation(async (url: string) => { + if (url === feedUrl) { + return { + ok: true, + json: async () => ({ + no_NO: { Desc: 'Norsk (no_NO)', URL: norwegianXmlUrl }, + fr_FR: { Desc: 'Français (fr_FR)', URL: frenchXmlUrl }, + }), + }; + } + + if (url === norwegianXmlUrl) { + return { + ok: true, + text: async () => + 'Norwegian', + }; + } + + if (url === frenchXmlUrl) { + return { + ok: true, + text: async () => + 'French', + }; + } + + throw new Error(`Unexpected URL: ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await service.getAvailableLanguages(); + + expect(fetchMock).toHaveBeenCalledWith(feedUrl); + expect(fetchMock).toHaveBeenCalledWith(norwegianXmlUrl); + expect(fetchMock).toHaveBeenCalledWith(frenchXmlUrl); + expect(result).toEqual([ + { + code: 'fr_FR', + name: 'French', + url: frenchXmlUrl, + }, + { + code: 'no_NO', + name: 'Norwegian', + url: norwegianXmlUrl, + }, + ]); + }); + + it('falls back to feed description when language xml cannot be fetched', async () => { + const feedUrl = 'https://assets.ca.unraid.net/feed/languageSelection.json'; + const spanishXmlUrl = 'https://example.com/es_ES.xml'; + + const fetchMock = vi.fn().mockImplementation(async (url: string) => { + if (url === feedUrl) { + return { + ok: true, + json: async () => ({ + es_ES: { Desc: 'Español (es_ES)', URL: spanishXmlUrl }, + }), + }; + } + + if (url === spanishXmlUrl) { + return { + ok: false, + status: 404, + statusText: 'Not Found', + }; + } + + throw new Error(`Unexpected URL: ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await service.getAvailableLanguages(); + + expect(result).toEqual([ + { + code: 'es_ES', + name: 'Español (es_ES)', + url: spanishXmlUrl, + }, + ]); + }); + + it('returns english fallback when language feed request fails', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('network failure')); + vi.stubGlobal('fetch', fetchMock); + + const result = await service.getAvailableLanguages(); + + expect(result).toEqual([{ code: 'en_US', name: 'English' }]); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/info/display/display.service.spec.ts b/api/src/unraid-api/graph/resolvers/info/display/display.service.spec.ts index 80a0e0c2cf..46d36001f4 100644 --- a/api/src/unraid-api/graph/resolvers/info/display/display.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/info/display/display.service.spec.ts @@ -1,6 +1,10 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as ini from 'ini'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; @@ -32,6 +36,29 @@ describe('DisplayService', () => { expect(service).toBeDefined(); }); + describe('updateCfgFile', () => { + it('should preserve quoted yes/no-style display values', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'display-service-')); + const configPath = join(tempDir, 'dynamix.cfg'); + + try { + await writeFile(configPath, '[display]\nterminalButton="yes"\n'); + await (service as any).updateCfgFile(configPath, 'display', { theme: 'white' }); + + const written = await readFile(configPath, 'utf-8'); + expect(written).toContain('terminalButton="yes"'); + + const parsed = ini.parse(written) as { + display?: { terminalButton?: string; theme?: string }; + }; + expect(parsed.display?.terminalButton).toBe('yes'); + expect(parsed.display?.theme).toBe('white'); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + }); + describe('generateDisplay', () => { it('should return display with case info and configuration from dev files', async () => { const result = await service.generateDisplay(); diff --git a/api/src/unraid-api/graph/resolvers/info/display/display.service.ts b/api/src/unraid-api/graph/resolvers/info/display/display.service.ts index 9668b55acb..1fcf4e5ebc 100644 --- a/api/src/unraid-api/graph/resolvers/info/display/display.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/display/display.service.ts @@ -1,15 +1,25 @@ -import { Injectable } from '@nestjs/common'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { Injectable, Logger } from '@nestjs/common'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { XMLParser } from 'fast-xml-parser'; +import * as ini from 'ini'; import { type DynamixConfig } from '@app/core/types/ini.js'; import { toBoolean } from '@app/core/utils/casting.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; +import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer.js'; import { loadState } from '@app/core/utils/misc/load-state.js'; import { validateEnumValue } from '@app/core/utils/validation/enum-validator.js'; -import { getters } from '@app/store/index.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 { ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; -import { Display, Temperature } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; +import { + Display, + Language, + Temperature, +} from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; const states = { // Success @@ -68,6 +78,13 @@ const states = { @Injectable() export class DisplayService { + private readonly logger = new Logger(DisplayService.name); + private readonly localePattern = /^[a-z]{2}_[A-Z]{2}$/; + private readonly xmlParser = new XMLParser({ + ignoreAttributes: false, + trimValues: true, + }); + async generateDisplay(): Promise { // Get case information const caseInfo = await this.getCaseInfo(); @@ -97,6 +114,108 @@ export class DisplayService { return display; } + async setLocale(locale: string): Promise { + const normalizedLocale = this.validateLocale(locale); + this.logger.log(`Updating locale to ${normalizedLocale}`); + const paths = getters.paths(); + const configFile = paths['dynamix-config']?.[1]; + + if (!configFile) { + throw new Error('Dynamix config path not found'); + } + + await this.updateCfgFile(configFile, 'display', { locale: normalizedLocale }); + + // Refresh in-memory store + const updatedConfig = loadDynamixConfigFromDiskSync(paths['dynamix-config']); + store.dispatch(updateDynamixConfig(updatedConfig)); + + return this.generateDisplay(); + } + + async setTheme(theme: string): Promise { + const normalizedTheme = this.validateTheme(theme); + this.logger.log(`Updating theme to ${normalizedTheme}`); + const paths = getters.paths(); + const configFile = paths['dynamix-config']?.[1]; + + if (!configFile) { + throw new Error('Dynamix config path not found'); + } + + await this.updateCfgFile(configFile, 'display', { theme: normalizedTheme }); + + // Refresh in-memory store + const updatedConfig = loadDynamixConfigFromDiskSync(paths['dynamix-config']); + store.dispatch(updateDynamixConfig(updatedConfig)); + + return this.generateDisplay(); + } + + private async updateCfgFile( + filePath: string, + section: string | null, + updates: Record + ) { + let configData: Record | string> = {}; + try { + const content = await readFile(filePath, 'utf-8'); + configData = ini.parse(content) as Record | string>; + } catch (error: unknown) { + // If creation is needed, we handle it. But typically dynamix.cfg exists. + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + this.logger.log(`Config file ${filePath} not found, will create it.`); + } else { + this.logger.error(`Error reading config file ${filePath}:`, error); + throw error; + } + } + + if (section) { + if (!configData[section] || typeof configData[section] === 'string') { + configData[section] = {}; + } + Object.entries(updates).forEach(([key, value]) => { + (configData[section] as Record)[key] = value; + }); + } else { + Object.entries(updates).forEach(([key, value]) => { + configData[key] = value; + }); + } + + try { + const hasTopLevelScalarValues = Object.values(configData).some( + (value) => value === null || typeof value !== 'object' || Array.isArray(value) + ); + const newContent = hasTopLevelScalarValues + ? ini.stringify(configData) + : safelySerializeObjectToIni(configData); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, newContent + '\n'); + this.logger.log(`Config file ${filePath} updated successfully.`); + } catch (error: unknown) { + this.logger.error(`Error writing config file ${filePath}:`, error); + throw error; + } + } + + private validateLocale(locale: string): string { + const normalizedLocale = locale.trim(); + if (!this.localePattern.test(normalizedLocale)) { + throw new Error(`Invalid locale "${locale}". Expected format ll_CC (example: en_US).`); + } + return normalizedLocale; + } + + private validateTheme(theme: string): ThemeName { + const normalizedTheme = theme.trim() as ThemeName; + if (!Object.values(ThemeName).includes(normalizedTheme)) { + throw new Error(`Invalid theme "${theme}".`); + } + return normalizedTheme; + } + private async getCaseInfo() { const dynamixBasePath = getters.paths()['dynamix-base']; const configFilePath = join(dynamixBasePath, 'case-model.cfg'); @@ -165,4 +284,58 @@ export class DisplayService { locale: display.locale || 'en_US', }; } + + async getAvailableLanguages(): Promise { + try { + const response = await fetch('https://assets.ca.unraid.net/feed/languageSelection.json'); + if (!response.ok) { + throw new Error(`Failed to fetch languages: ${response.statusText}`); + } + const data = (await response.json()) as Record; + + const languages = await Promise.all( + Object.entries(data).map(async ([code, info]) => { + const nameFromXml = info.URL + ? await this.getLanguageNameFromXml(info.URL) + : undefined; + + return { + code, + name: nameFromXml ?? info.Desc?.trim() ?? code, + url: info.URL, + }; + }) + ); + + return languages.sort((left, right) => + left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }) + ); + } catch (error) { + this.logger.error('Failed to fetch available languages', error); + // Return empty list or basic English fallback on error + return [{ code: 'en_US', name: 'English' }]; + } + } + + private async getLanguageNameFromXml(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch language XML: ${response.status} ${response.statusText}` + ); + } + + const xml = await response.text(); + const parsed = this.xmlParser.parse(xml) as { Language?: { Language?: string } }; + const xmlLanguageName = parsed.Language?.Language?.trim(); + + return xmlLanguageName || undefined; + } catch (error) { + this.logger.debug( + `Failed to parse language XML (${url}); falling back to feed description: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + return undefined; + } + } } diff --git a/api/src/unraid-api/graph/resolvers/info/info.model.ts b/api/src/unraid-api/graph/resolvers/info/info.model.ts index 9550df21f0..7a6b7b2617 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.model.ts @@ -6,6 +6,7 @@ import { InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; import { InfoDevices } from '@app/unraid-api/graph/resolvers/info/devices/devices.model.js'; import { InfoDisplay } from '@app/unraid-api/graph/resolvers/info/display/display.model.js'; import { InfoMemory } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js'; +import { InfoNetworkInterface } from '@app/unraid-api/graph/resolvers/info/network/network.model.js'; import { InfoOs } from '@app/unraid-api/graph/resolvers/info/os/os.model.js'; import { InfoBaseboard, InfoSystem } from '@app/unraid-api/graph/resolvers/info/system/system.model.js'; import { InfoVersions } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js'; @@ -41,4 +42,10 @@ export class Info extends Node { @Field(() => InfoVersions, { description: 'Software versions' }) versions!: InfoVersions; + + @Field(() => [InfoNetworkInterface], { description: 'Network interfaces' }) + networkInterfaces!: InfoNetworkInterface[]; + + @Field(() => InfoNetworkInterface, { nullable: true, description: 'Primary management interface' }) + primaryNetwork?: InfoNetworkInterface; } diff --git a/api/src/unraid-api/graph/resolvers/info/info.module.ts b/api/src/unraid-api/graph/resolvers/info/info.module.ts index c3bf00b65b..c32d3964d0 100644 --- a/api/src/unraid-api/graph/resolvers/info/info.module.ts +++ b/api/src/unraid-api/graph/resolvers/info/info.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { CpuModule } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.module.js'; -import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js'; import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js'; import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js'; import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; +import { InfoNetworkResolver } from '@app/unraid-api/graph/resolvers/info/network/network.resolver.js'; +import { NetworkService } from '@app/unraid-api/graph/resolvers/info/network/network.service.js'; import { OsService } from '@app/unraid-api/graph/resolvers/info/os/os.service.js'; import { CoreVersionsResolver } from '@app/unraid-api/graph/resolvers/info/versions/core-versions.resolver.js'; import { VersionsResolver } from '@app/unraid-api/graph/resolvers/info/versions/versions.resolver.js'; @@ -30,8 +31,18 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j DevicesService, OsService, VersionsService, + VersionsService, + DisplayService, + NetworkService, + InfoNetworkResolver, + ], + exports: [ + InfoResolver, + DevicesResolver, + VersionsResolver, + CoreVersionsResolver, DisplayService, + NetworkService, ], - exports: [InfoResolver, DevicesResolver, VersionsResolver, CoreVersionsResolver, DisplayService], }) export class InfoModule {} diff --git a/api/src/unraid-api/graph/resolvers/info/network/network.model.ts b/api/src/unraid-api/graph/resolvers/info/network/network.model.ts new file mode 100644 index 0000000000..c0d7da4c63 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/network/network.model.ts @@ -0,0 +1,47 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { Node } from '@unraid/shared/graphql.model.js'; + +@ObjectType({ implements: () => Node }) +export class InfoNetworkInterface extends Node { + @Field({ description: 'Interface name (e.g. eth0)' }) + name!: string; + + @Field({ nullable: true, description: 'Interface description/label' }) + description?: string; + + @Field({ nullable: true, description: 'MAC Address' }) + macAddress?: string; + + @Field({ nullable: true, description: 'Connection status' }) + status?: string; + + // IPv4 + @Field({ nullable: true, description: 'IPv4 Protocol mode' }) + protocol?: string; + + @Field({ nullable: true, description: 'IPv4 Address' }) + ipAddress?: string; + + @Field({ nullable: true, description: 'IPv4 Netmask' }) + netmask?: string; + + @Field({ nullable: true, description: 'IPv4 Gateway' }) + gateway?: string; + + @Field({ nullable: true, description: 'Using DHCP for IPv4' }) + useDhcp?: boolean; + + // IPv6 + @Field({ nullable: true, description: 'IPv6 Address' }) + ipv6Address?: string; + + @Field({ nullable: true, description: 'IPv6 Netmask' }) + ipv6Netmask?: string; + + @Field({ nullable: true, description: 'IPv6 Gateway' }) + ipv6Gateway?: string; + + @Field({ nullable: true, description: 'Using DHCP for IPv6' }) + useDhcp6?: boolean; +} diff --git a/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts b/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts new file mode 100644 index 0000000000..a07e1bff11 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts @@ -0,0 +1,26 @@ +import { Query, ResolveField, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { Info } from '@app/unraid-api/graph/resolvers/info/info.model.js'; +import { InfoNetworkInterface } from '@app/unraid-api/graph/resolvers/info/network/network.model.js'; +import { NetworkService } from '@app/unraid-api/graph/resolvers/info/network/network.service.js'; + +@Resolver(() => Info) +export class InfoNetworkResolver { + constructor(private readonly networkService: NetworkService) {} + + @ResolveField(() => [InfoNetworkInterface], { description: 'Network interfaces' }) + async networkInterfaces(): Promise { + return this.networkService.getNetworkInterfaces(); + } + + @ResolveField(() => InfoNetworkInterface, { + nullable: true, + description: 'Primary management interface', + }) + async primaryNetwork(): Promise { + return this.networkService.getManagementInterface(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/info/network/network.service.ts b/api/src/unraid-api/graph/resolvers/info/network/network.service.ts new file mode 100644 index 0000000000..4838972ce0 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/info/network/network.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; + +import { networkInterfaces } from 'systeminformation'; + +import { getters } from '@app/store/index.js'; +import { InfoNetworkInterface } from '@app/unraid-api/graph/resolvers/info/network/network.model.js'; + +@Injectable() +export class NetworkService { + async getNetworkInterfaces(): Promise { + // We get runtime status (MAC, current IP, link state) from systeminformation + // This provides the "as-is" state of the server. + const sysInfo = await networkInterfaces(); + + return sysInfo.map((iface) => { + return { + id: `info/network/${iface.iface}`, + name: iface.iface, + description: iface.ifaceName, // Label + macAddress: iface.mac, + status: iface.operstate, + protocol: iface.ip4 ? (iface.ip6 ? 'ipv4+ipv6' : 'ipv4') : iface.ip6 ? 'ipv6' : 'none', + ipAddress: iface.ip4, + netmask: iface.ip4subnet, + gateway: 'unknown', + useDhcp: iface.dhcp, + ipv6Address: iface.ip6, + ipv6Netmask: iface.ip6subnet, + useDhcp6: false, + } as InfoNetworkInterface; + }); + } + + /** + * Get the primary management IP address (usually webgui listener) + */ + async getManagementInterface(): Promise { + // Try to find br0, then eth0, then whatever has an IP + const sysInfo = await networkInterfaces(); + + // Priority list + const priority = ['br0', 'eth0', 'bond0']; + + let primary = sysInfo.find((info) => priority.includes(info.iface)); + + if (!primary) { + // Find first non-loopback with IPv4 + primary = sysInfo.find((info) => !info.internal && info.ip4); + } + + if (!primary) return null; + + return { + id: `info/network/primary`, + name: primary.iface, + macAddress: primary.mac, + ipAddress: primary.ip4, + netmask: primary.ip4subnet, + useDhcp: primary.dhcp, + ipv6Address: primary.ip6, + } as InfoNetworkInterface; + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts index 12b899a094..69987760c1 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts @@ -58,8 +58,12 @@ describe('MetricsResolver Integration Tests', () => { expect(result).toHaveProperty('percentTotal'); expect(result).toHaveProperty('cpus'); expect(result.cpus).toBeInstanceOf(Array); - expect(result.percentTotal).toBeGreaterThanOrEqual(0); - expect(result.percentTotal).toBeLessThanOrEqual(100); + if (Number.isFinite(result.percentTotal)) { + expect(result.percentTotal).toBeGreaterThanOrEqual(0); + expect(result.percentTotal).toBeLessThanOrEqual(100); + } else { + expect(Number.isNaN(result.percentTotal)).toBe(true); + } if (result.cpus.length > 0) { const firstCpu = result.cpus[0]; @@ -178,12 +182,28 @@ describe('MetricsResolver Integration Tests', () => { it('should publish memory metrics to pubsub', async () => { const publishSpy = vi.spyOn(pubsub, 'publish'); const trackerService = module.get(SubscriptionTrackerService); + const memoryService = module.get(MemoryService); + + vi.spyOn(memoryService, 'generateMemoryLoad').mockResolvedValue({ + id: 'memory-utilization', + total: 16000000000, + used: 8000000000, + free: 8000000000, + available: 7000000000, + active: 4000000000, + buffcache: 3000000000, + percentTotal: 50, + swapTotal: 1000000000, + swapUsed: 100000000, + swapFree: 900000000, + percentSwapTotal: 10, + } as any); // Trigger polling by starting subscription trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); // Wait for the polling interval to trigger (2000ms for memory) - await new Promise((resolve) => setTimeout(resolve, 2100)); + await new Promise((resolve) => setTimeout(resolve, 2300)); expect(publishSpy).toHaveBeenCalledWith( PUBSUB_CHANNEL.MEMORY_UTILIZATION, diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts index aae73aeebf..af2a98d14e 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -1,6 +1,9 @@ import { Field, ObjectType } from '@nestjs/graphql'; +import { Onboarding } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { Theme } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; import { RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; +import { PluginInstallOperation } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js'; /** * Important: @@ -27,7 +30,13 @@ export class ApiKeyMutations {} @ObjectType({ description: 'Customization related mutations', }) -export class CustomizationMutations {} +export class CustomizationMutations { + @Field(() => Theme, { description: 'Update the UI theme (writes dynamix.cfg)' }) + setTheme!: Theme; + + @Field(() => String, { description: 'Update the display locale (language)' }) + setLocale!: string; +} @ObjectType({ description: 'Parity check related mutations, WIP, response types and functionaliy will change', @@ -45,6 +54,46 @@ export class RCloneMutations { deleteRCloneRemote!: boolean; } +@ObjectType({ + description: 'Onboarding related mutations', +}) +export class OnboardingMutations { + @Field(() => Onboarding, { + description: 'Mark onboarding as completed', + }) + completeOnboarding!: Onboarding; + + @Field(() => Onboarding, { + description: 'Reset onboarding progress (for testing)', + }) + resetOnboarding!: Onboarding; + + @Field(() => Onboarding, { + description: 'Override onboarding state for testing (in-memory only)', + }) + setOnboardingOverride!: Onboarding; + + @Field(() => Onboarding, { + description: 'Clear onboarding override state and reload from disk', + }) + clearOnboardingOverride!: Onboarding; +} + +@ObjectType({ + description: 'Unraid plugin management mutations', +}) +export class UnraidPluginsMutations { + @Field(() => PluginInstallOperation, { + description: 'Install an Unraid plugin and track installation progress', + }) + installPlugin!: PluginInstallOperation; + + @Field(() => PluginInstallOperation, { + description: 'Install an Unraid language pack and track installation progress', + }) + installLanguage!: PluginInstallOperation; +} + @ObjectType() export class RootMutations { @Field(() => ArrayMutations, { description: 'Array related mutations' }) @@ -67,4 +116,10 @@ export class RootMutations { @Field(() => RCloneMutations, { description: 'RClone related mutations' }) rclone: RCloneMutations = new RCloneMutations(); + + @Field(() => OnboardingMutations, { description: 'Onboarding related mutations' }) + onboarding: OnboardingMutations = new OnboardingMutations(); + + @Field(() => UnraidPluginsMutations, { description: 'Unraid plugin related mutations' }) + unraidPlugins: UnraidPluginsMutations = new UnraidPluginsMutations(); } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts index 7beca48bc8..5ab6b2ad80 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -5,9 +5,11 @@ import { ArrayMutations, CustomizationMutations, DockerMutations, + OnboardingMutations, ParityCheckMutations, RCloneMutations, RootMutations, + UnraidPluginsMutations, VmMutations, } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; @@ -47,4 +49,14 @@ export class RootMutationsResolver { rclone(): RCloneMutations { return new RCloneMutations(); } + + @Mutation(() => OnboardingMutations, { name: 'onboarding' }) + onboarding(): OnboardingMutations { + return new OnboardingMutations(); + } + + @Mutation(() => UnraidPluginsMutations, { name: 'unraidPlugins' }) + unraidPlugins(): UnraidPluginsMutations { + return new UnraidPluginsMutations(); + } } diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding-status.util.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding-status.util.ts new file mode 100644 index 0000000000..872008c85f --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding-status.util.ts @@ -0,0 +1,49 @@ +import { coerce, eq, gt, lt } from 'semver'; + +import { compareVersions } from '@app/common/compare-semver-version.js'; + +export const hasOnboardingVersionDrift = ( + completedAtVersion: string | undefined, + currentVersion: string +): boolean => { + if (!completedAtVersion) { + return false; + } + + const current = coerce(currentVersion, { includePrerelease: true }); + const completed = coerce(completedAtVersion, { includePrerelease: true }); + + if (current && completed) { + return !compareVersions(current, completed, eq, { includePrerelease: true }); + } + + // Fallback for non-semver strings. + return completedAtVersion !== currentVersion; +}; + +export const getOnboardingVersionDirection = ( + completedAtVersion: string | undefined, + currentVersion: string +): 'UPGRADE' | 'DOWNGRADE' | undefined => { + if (!completedAtVersion) { + return undefined; + } + + const current = coerce(currentVersion, { includePrerelease: true }); + const completed = coerce(completedAtVersion, { includePrerelease: true }); + + if (current && completed) { + if (compareVersions(current, completed, gt, { includePrerelease: true })) { + return 'UPGRADE'; + } + + if (compareVersions(current, completed, lt, { includePrerelease: true })) { + return 'DOWNGRADE'; + } + + return undefined; + } + + // Fallback: unknown string ordering can't reliably infer downgrade. + return completedAtVersion !== currentVersion ? 'UPGRADE' : undefined; +}; diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts new file mode 100644 index 0000000000..8943713285 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts @@ -0,0 +1,263 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator'; + +import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; + +@InputType({ + description: 'Onboarding completion override input', +}) +export class OnboardingOverrideCompletionInput { + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + completed?: boolean; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + completedAtVersion?: string | null; +} + +@InputType({ + description: 'Partner link input for custom links', +}) +export class PartnerLinkInput { + @Field(() => String) + @IsString() + title!: string; + + @Field(() => String) + @IsString() + url!: string; +} + +@InputType() +export class PartnerConfigInput { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + name?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + url?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + hardwareSpecsUrl?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + manualUrl?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + supportUrl?: string; + + @Field(() => [PartnerLinkInput], { nullable: true }) + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => PartnerLinkInput) + extraLinks?: PartnerLinkInput[]; +} + +@InputType() +export class BrandingConfigInput { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + header?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + headermetacolor?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + background?: string; + + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + showBannerGradient?: boolean; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @IsIn(['azure', 'black', 'gray', 'white']) + theme?: 'azure' | 'black' | 'gray' | 'white'; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + bannerImage?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + caseModelImage?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + partnerLogoLightUrl?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + partnerLogoDarkUrl?: string; + + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + hasPartnerLogo?: boolean; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingTitle?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingSubtitle?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingTitleFreshInstall?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingSubtitleFreshInstall?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingTitleUpgrade?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingSubtitleUpgrade?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingTitleDowngrade?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingSubtitleDowngrade?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingTitleIncomplete?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + onboardingSubtitleIncomplete?: string; +} + +@InputType() +export class SystemConfigInput { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + serverName?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + model?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + comment?: string; +} + +@InputType({ + description: 'Activation code override input', +}) +export class ActivationCodeOverrideInput { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + code?: string; + + @Field(() => PartnerConfigInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => PartnerConfigInput) + partner?: PartnerConfigInput; + + @Field(() => BrandingConfigInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => BrandingConfigInput) + branding?: BrandingConfigInput; + + @Field(() => SystemConfigInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => SystemConfigInput) + system?: SystemConfigInput; +} + +@InputType({ + description: 'Partner info override input', +}) +export class PartnerInfoOverrideInput { + @Field(() => PartnerConfigInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => PartnerConfigInput) + partner?: PartnerConfigInput; + + @Field(() => BrandingConfigInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => BrandingConfigInput) + branding?: BrandingConfigInput; +} + +@InputType({ + description: 'Onboarding override input for testing', +}) +export class OnboardingOverrideInput { + @Field(() => OnboardingOverrideCompletionInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingOverrideCompletionInput) + onboarding?: OnboardingOverrideCompletionInput; + + @Field(() => ActivationCodeOverrideInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => ActivationCodeOverrideInput) + activationCode?: ActivationCodeOverrideInput | null; + + @Field(() => PartnerInfoOverrideInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => PartnerInfoOverrideInput) + partnerInfo?: PartnerInfoOverrideInput | null; + + @Field(() => RegistrationState, { nullable: true }) + @IsOptional() + @IsEnum(RegistrationState) + registrationState?: RegistrationState; +} diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts new file mode 100644 index 0000000000..63d6e9e492 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OnboardingStatus } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { OnboardingMutationsResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.mutation.js'; + +describe('OnboardingMutationsResolver', () => { + const onboardingTracker = { + markCompleted: vi.fn(), + reset: vi.fn(), + getState: vi.fn(), + getCurrentVersion: vi.fn(), + }; + + const onboardingOverrides = { + setState: vi.fn(), + clearState: vi.fn(), + }; + + const onboardingService = { + getPublicPartnerInfo: vi.fn(), + getOnboardingState: vi.fn(), + clearActivationDataCache: vi.fn(), + }; + + let resolver: OnboardingMutationsResolver; + + beforeEach(() => { + vi.clearAllMocks(); + onboardingTracker.getState.mockReturnValue({ + completed: false, + completedAtVersion: undefined, + }); + onboardingTracker.getCurrentVersion.mockReturnValue('7.2.0'); + onboardingService.getPublicPartnerInfo.mockResolvedValue(null); + onboardingService.getOnboardingState.mockResolvedValue({ + registrationState: null, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }); + + resolver = new OnboardingMutationsResolver( + onboardingTracker as any, + onboardingOverrides as any, + onboardingService as any + ); + }); + + it('propagates tracker failure from completeOnboarding', async () => { + const error = new Error('tracker-write-failed'); + onboardingTracker.markCompleted.mockRejectedValue(error); + + await expect(resolver.completeOnboarding()).rejects.toThrow('tracker-write-failed'); + expect(onboardingService.getPublicPartnerInfo).not.toHaveBeenCalled(); + }); + + it('returns completed onboarding state when markCompleted succeeds', async () => { + onboardingTracker.markCompleted.mockResolvedValue({ + completed: true, + completedAtVersion: '7.2.0', + }); + onboardingTracker.getState.mockReturnValue({ + completed: true, + completedAtVersion: '7.2.0', + }); + onboardingTracker.getCurrentVersion.mockReturnValue('7.2.0'); + onboardingService.getPublicPartnerInfo.mockResolvedValue(null); + + const result = await resolver.completeOnboarding(); + + expect(result.completed).toBe(true); + expect(result.completedAtVersion).toBe('7.2.0'); + expect(result.status).toBe(OnboardingStatus.COMPLETED); + expect(result.isPartnerBuild).toBe(false); + expect(result.onboardingState).toEqual({ + registrationState: null, + isRegistered: false, + isFreshInstall: false, + hasActivationCode: false, + activationRequired: false, + }); + }); + + it('returns incomplete status after resetOnboarding', async () => { + onboardingTracker.reset.mockResolvedValue(undefined); + onboardingTracker.getState.mockReturnValue({ + completed: false, + completedAtVersion: undefined, + }); + + const result = await resolver.resetOnboarding(); + + expect(onboardingTracker.reset).toHaveBeenCalledTimes(1); + expect(result.status).toBe(OnboardingStatus.INCOMPLETE); + expect(result.completed).toBe(false); + }); + + it('returns upgrade status when completed version is behind current', async () => { + onboardingTracker.markCompleted.mockResolvedValue(undefined); + onboardingTracker.getState.mockReturnValue({ + completed: true, + completedAtVersion: '7.1.0', + }); + onboardingTracker.getCurrentVersion.mockReturnValue('7.2.0'); + + const result = await resolver.completeOnboarding(); + + expect(result.status).toBe(OnboardingStatus.UPGRADE); + }); + + it('returns downgrade status when completed version is ahead of current', async () => { + onboardingTracker.markCompleted.mockResolvedValue(undefined); + onboardingTracker.getState.mockReturnValue({ + completed: true, + completedAtVersion: '7.2.0', + }); + onboardingTracker.getCurrentVersion.mockReturnValue('7.1.0'); + + const result = await resolver.completeOnboarding(); + + expect(result.status).toBe(OnboardingStatus.DOWNGRADE); + }); + + it('setOnboardingOverride stores override, clears cache, and returns onboarding state', async () => { + onboardingTracker.getState.mockReturnValue({ + completed: true, + completedAtVersion: '7.2.0', + }); + onboardingTracker.getCurrentVersion.mockReturnValue('7.2.0'); + onboardingService.getPublicPartnerInfo.mockResolvedValue({ + partner: { name: 'Partner' }, + branding: {}, + } as any); + + const input = { + onboarding: { + completed: true, + completedAtVersion: '7.2.0', + }, + registrationState: undefined, + } as any; + + const result = await resolver.setOnboardingOverride(input); + + expect(onboardingOverrides.setState).toHaveBeenCalledWith({ + onboarding: input.onboarding, + activationCode: undefined, + partnerInfo: undefined, + registrationState: undefined, + }); + expect(onboardingService.clearActivationDataCache).toHaveBeenCalledTimes(1); + expect(result.status).toBe(OnboardingStatus.COMPLETED); + expect(result.isPartnerBuild).toBe(true); + }); + + it('clearOnboardingOverride clears override and cache', async () => { + onboardingTracker.getState.mockReturnValue({ + completed: false, + completedAtVersion: undefined, + }); + + const result = await resolver.clearOnboardingOverride(); + + expect(onboardingOverrides.clearState).toHaveBeenCalledTimes(1); + expect(onboardingService.clearActivationDataCache).toHaveBeenCalledTimes(1); + expect(result.status).toBe(OnboardingStatus.INCOMPLETE); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts new file mode 100644 index 0000000000..606d0c6deb --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts @@ -0,0 +1,112 @@ +import { Args, ResolveField, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import type { OnboardingOverrideState } from '@app/unraid-api/config/onboarding-override.model.js'; +import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; +import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; +import { + Onboarding, + OnboardingStatus, +} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; +import { OnboardingMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; +import { getOnboardingVersionDirection } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-status.util.js'; +import { OnboardingOverrideInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; + +@Resolver(() => OnboardingMutations) +export class OnboardingMutationsResolver { + constructor( + private readonly onboardingTracker: OnboardingTrackerService, + private readonly onboardingOverrides: OnboardingOverrideService, + private readonly onboardingService: OnboardingService + ) {} + + /** + * Build a full Onboarding response with computed status + */ + private async buildOnboardingResponse(): Promise { + const state = this.onboardingTracker.getState(); + const currentVersion = this.onboardingTracker.getCurrentVersion() ?? 'unknown'; + const partnerInfo = await this.onboardingService.getPublicPartnerInfo(); + const onboardingState = await this.onboardingService.getOnboardingState(); + const versionDirection = getOnboardingVersionDirection(state.completedAtVersion, currentVersion); + + // Compute the status based on completion state and version + let status: OnboardingStatus; + if (!state.completed) { + status = OnboardingStatus.INCOMPLETE; + } else if (versionDirection === 'DOWNGRADE') { + status = OnboardingStatus.DOWNGRADE; + } else if (versionDirection === 'UPGRADE') { + status = OnboardingStatus.UPGRADE; + } else { + status = OnboardingStatus.COMPLETED; + } + + return { + status, + isPartnerBuild: partnerInfo !== null, + completed: state.completed, + completedAtVersion: state.completedAtVersion, + onboardingState, + }; + } + + @ResolveField(() => Onboarding, { + description: 'Marks the onboarding flow as completed', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.WELCOME, + }) + async completeOnboarding(): Promise { + await this.onboardingTracker.markCompleted(); + return this.buildOnboardingResponse(); + } + + @ResolveField(() => Onboarding, { + description: 'Reset onboarding progress (for testing)', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.WELCOME, + }) + async resetOnboarding(): Promise { + await this.onboardingTracker.reset(); + return this.buildOnboardingResponse(); + } + + @ResolveField(() => Onboarding, { + description: 'Override onboarding state for testing (in-memory only)', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.WELCOME, + }) + async setOnboardingOverride(@Args('input') input: OnboardingOverrideInput): Promise { + const override: OnboardingOverrideState = { + onboarding: input.onboarding, + activationCode: input.activationCode, + partnerInfo: input.partnerInfo, + registrationState: input.registrationState, + }; + this.onboardingOverrides.setState(override); + this.onboardingService.clearActivationDataCache(); + return this.buildOnboardingResponse(); + } + + @ResolveField(() => Onboarding, { + description: 'Clear onboarding override state and reload from disk', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.WELCOME, + }) + async clearOnboardingOverride(): Promise { + this.onboardingOverrides.clearState(); + this.onboardingService.clearActivationDataCache(); + return this.buildOnboardingResponse(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 34a7884d6a..dc689376f2 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -2,12 +2,15 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js'; +import { OnboardingOverrideModule } from '@app/unraid-api/config/onboarding-override.module.js'; +import { OnboardingStateModule } from '@app/unraid-api/config/onboarding-state.module.js'; import { ApiKeyModule } from '@app/unraid-api/graph/resolvers/api-key/api-key.module.js'; import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js'; import { ArrayModule } from '@app/unraid-api/graph/resolvers/array/array.module.js'; import { ConfigResolver } from '@app/unraid-api/graph/resolvers/config/config.resolver.js'; import { CustomizationModule } from '@app/unraid-api/graph/resolvers/customization/customization.module.js'; import { DisksModule } from '@app/unraid-api/graph/resolvers/disks/disks.module.js'; +import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display.resolver.js'; import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js'; import { FlashBackupModule } from '@app/unraid-api/graph/resolvers/flash-backup/flash-backup.module.js'; import { FlashResolver } from '@app/unraid-api/graph/resolvers/flash/flash.resolver.js'; @@ -17,15 +20,20 @@ import { MetricsModule } from '@app/unraid-api/graph/resolvers/metrics/metrics.m import { RootMutationsResolver } from '@app/unraid-api/graph/resolvers/mutation/mutation.resolver.js'; import { NotificationsModule } from '@app/unraid-api/graph/resolvers/notifications/notifications.module.js'; import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js'; +import { OnboardingMutationsResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.mutation.js'; import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js'; import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resolver.js'; import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registration/registration.resolver.js'; import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js'; +import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; import { SettingsModule } from '@app/unraid-api/graph/resolvers/settings/settings.module.js'; import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js'; +import { SystemTimeModule } from '@app/unraid-api/graph/resolvers/system-time/system-time.module.js'; +import { UnraidPluginsModule } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.module.js'; import { UPSModule } from '@app/unraid-api/graph/resolvers/ups/ups.module.js'; import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js'; +import { VarsService } from '@app/unraid-api/graph/resolvers/vars/vars.service.js'; import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js'; import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js'; import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js'; @@ -47,26 +55,34 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; FlashBackupModule, InfoModule, LogsModule, + OnboardingOverrideModule, + OnboardingStateModule, NotificationsModule, RCloneModule, SettingsModule, SsoModule, MetricsModule, + SystemTimeModule, UPSModule, + UnraidPluginsModule, ], providers: [ ConfigResolver, + DisplayResolver, FlashResolver, MeResolver, NotificationsResolver, OnlineResolver, OwnerResolver, + OnboardingMutationsResolver, RegistrationResolver, RootMutationsResolver, ServerResolver, + ServerService, ServicesResolver, SharesResolver, VarsResolver, + VarsService, VmMutationsResolver, VmsResolver, VmsService, diff --git a/api/src/unraid-api/graph/resolvers/servers/server.model.ts b/api/src/unraid-api/graph/resolvers/servers/server.model.ts index dc7b94c95c..0b6ddc3c70 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.model.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.model.ts @@ -38,6 +38,9 @@ export class Server extends Node { @Field() name!: string; + @Field({ nullable: true, description: 'Server description/comment' }) + comment?: string; + @Field(() => ServerStatus, { description: 'Whether this server is online or offline', }) diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts index f9bfd24bc0..4018bfe9a5 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts @@ -5,6 +5,7 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js'; +import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; describe('ServersResolver', () => { let resolver: ServerResolver; @@ -17,6 +18,10 @@ describe('ServersResolver', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ServerResolver, + { + provide: ServerService, + useValue: {}, + }, { provide: ConfigService, useValue: mockConfigService, diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts index 8bcc2e9e3f..65f041a560 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Query, Resolver, Subscription } from '@nestjs/graphql'; +import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; @@ -13,11 +13,15 @@ import { Server as ServerModel, ServerStatus, } from '@app/unraid-api/graph/resolvers/servers/server.model.js'; +import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; @Injectable() @Resolver(() => ServerModel) export class ServerResolver { - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + private readonly serverService: ServerService + ) {} @Query(() => ServerModel, { nullable: true }) @UsePermissions({ action: AuthAction.READ_ANY, @@ -45,12 +49,26 @@ export class ServerResolver { return createSubscription(PUBSUB_CHANNEL.SERVERS); } + @Mutation(() => ServerModel, { description: 'Update server name, comment, and model' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.SERVERS, + }) + public async updateServerIdentity( + @Args('name') name: string, + @Args('comment', { nullable: true }) comment?: string, + @Args('sysModel', { nullable: true }) sysModel?: string + ): Promise { + return this.serverService.updateServerIdentity(name, comment, sysModel); + } + private getLocalServer(): ServerModel { const emhttp = getters.emhttp(); const connectConfig = this.configService.get('connect'); const guid = emhttp.var.regGuid; const name = emhttp.var.name; + const comment = emhttp.var.comment; const wanip = ''; const lanip: string = emhttp.networks[0]?.ipaddr[0] || ''; const port = emhttp.var?.port; @@ -70,6 +88,7 @@ export class ServerResolver { guid: guid || '', apikey: connectConfig?.config?.apikey ?? '', name: name ?? 'Local Server', + comment, status: ServerStatus.ONLINE, wanip, lanip, diff --git a/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts b/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts new file mode 100644 index 0000000000..f91faf5c63 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts @@ -0,0 +1,155 @@ +import { GraphQLError } from 'graphql'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { getters } from '@app/store/index.js'; +import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; + +vi.mock('@app/core/utils/clients/emcmd.js', () => ({ + emcmd: vi.fn(), +})); + +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: vi.fn(), + }, +})); + +describe('ServerService', () => { + let service: ServerService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ServerService(); + + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + name: 'Tower', + fsState: 'Stopped', + regGuid: 'GUID-123', + port: '80', + comment: 'Tower comment', + }, + networks: [{ ipaddr: ['192.168.1.10'] }], + } as unknown as ReturnType); + vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited>); + }); + + it('throws for invalid server name characters', async () => { + await expect(service.updateServerIdentity('bad name!', 'test')).rejects.toThrow(GraphQLError); + await expect(service.updateServerIdentity('bad name!', 'test')).rejects.toThrow( + 'Server name contains invalid characters' + ); + }); + + it('throws for server name longer than 15 chars', async () => { + await expect(service.updateServerIdentity('1234567890123456', 'test')).rejects.toThrow( + 'Server name must be 15 characters or less.' + ); + }); + + it('throws when server name ends with dot or dash', async () => { + await expect(service.updateServerIdentity('tower-', 'test')).rejects.toThrow( + 'Server name must not end with a dot or a dash.' + ); + await expect(service.updateServerIdentity('tower.', 'test')).rejects.toThrow( + 'Server name must not end with a dot or a dash.' + ); + }); + + it('throws for invalid description length', async () => { + await expect(service.updateServerIdentity('Tower', 'x'.repeat(65))).rejects.toThrow( + 'Server description must be 64 characters or less.' + ); + }); + + it('throws for invalid description characters', async () => { + await expect(service.updateServerIdentity('Tower', 'bad "quote')).rejects.toThrow( + 'Server description cannot contain quotes or backslashes.' + ); + await expect(service.updateServerIdentity('Tower', 'bad \\ slash')).rejects.toThrow( + 'Server description cannot contain quotes or backslashes.' + ); + }); + + it('throws for invalid model characters', async () => { + await expect(service.updateServerIdentity('Tower', 'desc', 'bad "model')).rejects.toThrow( + 'Server model cannot contain quotes or backslashes.' + ); + await expect(service.updateServerIdentity('Tower', 'desc', 'bad \\ model')).rejects.toThrow( + 'Server model cannot contain quotes or backslashes.' + ); + }); + + it('requires stopped array only when name changes', async () => { + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + name: 'Tower', + fsState: 'Started', + }, + } as any); + + await expect(service.updateServerIdentity('NewTower', 'desc')).rejects.toThrow( + 'The array must be stopped to change the server name.' + ); + + await expect(service.updateServerIdentity('Tower', 'desc')).resolves.toMatchObject({ + name: 'Tower', + comment: 'desc', + }); + }); + + it('calls emcmd with expected params and returns optimistic server', async () => { + const result = await service.updateServerIdentity('Tower', 'Primary host'); + + expect(emcmd).toHaveBeenCalledWith( + { + changeNames: 'Apply', + NAME: 'Tower', + COMMENT: 'Primary host', + }, + { waitForToken: true } + ); + + expect(result).toEqual({ + id: 'local', + owner: { + id: 'local', + username: 'root', + url: '', + avatar: '', + }, + guid: 'GUID-123', + apikey: '', + name: 'Tower', + comment: 'Primary host', + status: 'ONLINE', + wanip: '', + lanip: '192.168.1.10', + localurl: 'http://192.168.1.10:80', + remoteurl: '', + }); + }); + + it('includes SYS_MODEL when provided', async () => { + await service.updateServerIdentity('Tower', 'Primary host', 'Storinator'); + + expect(emcmd).toHaveBeenCalledWith( + { + changeNames: 'Apply', + NAME: 'Tower', + COMMENT: 'Primary host', + SYS_MODEL: 'Storinator', + }, + { waitForToken: true } + ); + }); + + it('throws generic failure when emcmd fails', async () => { + vi.mocked(emcmd).mockRejectedValue(new Error('socket failure')); + + await expect(service.updateServerIdentity('Tower', 'Primary host')).rejects.toThrow( + 'Failed to update server identity' + ); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/servers/server.service.ts b/api/src/unraid-api/graph/resolvers/servers/server.service.ts new file mode 100644 index 0000000000..9ff0b875ce --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/servers/server.service.ts @@ -0,0 +1,115 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { GraphQLError } from 'graphql'; + +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { getters } from '@app/store/index.js'; +import { + ProfileModel, + Server, + ServerStatus, +} from '@app/unraid-api/graph/resolvers/servers/server.model.js'; + +@Injectable() +export class ServerService { + private readonly logger = new Logger(ServerService.name); + + /** + * Updates the server identity (name and comment/description). + * The array must be stopped to change the server name. + */ + async updateServerIdentity(name: string, comment?: string, sysModel?: string): Promise { + this.logger.log( + `Updating server identity to Name: ${name}, Comment: ${comment}, Model: ${sysModel}` + ); + + // Frontend validation logic: + // Invalid chars: anything not alphanumeric, dot, or dash + if (/[^a-zA-Z0-9.-]/.test(name)) { + throw new GraphQLError( + 'Server name contains invalid characters. Only alphanumeric, dot, and dash are allowed.' + ); + } + // Check length + if (name.length > 15) { + throw new GraphQLError('Server name must be 15 characters or less.'); + } + + // Invalid end: must not end with dot or dash + if (/[.-]$/.test(name)) { + throw new GraphQLError('Server name must not end with a dot or a dash.'); + } + + // Comment validation + if (comment !== undefined) { + if (comment.length > 64) { + throw new GraphQLError('Server description must be 64 characters or less.'); + } + if (/["\\]/.test(comment)) { + throw new GraphQLError('Server description cannot contain quotes or backslashes.'); + } + } + if (sysModel !== undefined && /["\\]/.test(sysModel)) { + throw new GraphQLError('Server model cannot contain quotes or backslashes.'); + } + + // Check if array is stopped (required for changing name) + // We only enforce this if name is changing, but to be safe and consistent with UI, likely good to enforce. + // Actually, UI only disables it if array is not stopped. + // Let's check current name. + const currentEmhttp = getters.emhttp(); + const currentName = currentEmhttp.var?.name; + + if (name !== currentName) { + const fsState = currentEmhttp.var?.fsState; + if (fsState !== 'Stopped') { + throw new GraphQLError('The array must be stopped to change the server name.'); + } + } + + const params: Record = { + changeNames: 'Apply', + NAME: name, + }; + + if (comment !== undefined) { + params.COMMENT = comment; + } + if (sysModel !== undefined) { + params.SYS_MODEL = sysModel; + } + + try { + await emcmd(params, { waitForToken: true }); + this.logger.log('Server identity updated successfully via emcmd.'); + const latestEmhttp = getters.emhttp(); + const guid = latestEmhttp.var?.regGuid ?? ''; + const lanip = latestEmhttp.networks?.[0]?.ipaddr?.[0] ?? ''; + const port = latestEmhttp.var?.port ?? ''; + const nextComment = comment ?? latestEmhttp.var?.comment; + const owner: ProfileModel = { + id: 'local', + username: 'root', + url: '', + avatar: '', + }; + + return { + id: 'local', + owner, + guid, + apikey: '', + name, + comment: nextComment, + status: ServerStatus.ONLINE, + wanip: '', + lanip, + localurl: lanip ? `http://${lanip}:${port}` : '', + remoteurl: '', + }; + } catch (error) { + this.logger.error('Failed to update server identity', error); + throw new GraphQLError('Failed to update server identity'); + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/sso/core/oidc.service.integration.test.ts b/api/src/unraid-api/graph/resolvers/sso/core/oidc.service.integration.test.ts index b058680293..53beb93c00 100644 --- a/api/src/unraid-api/graph/resolvers/sso/core/oidc.service.integration.test.ts +++ b/api/src/unraid-api/graph/resolvers/sso/core/oidc.service.integration.test.ts @@ -174,9 +174,9 @@ describe('OidcService Integration Tests - Enhanced Logging', () => { // Verify that the service attempted to handle the callback // Note: Detailed token exchange logging now happens in OidcTokenExchangeService - expect(errorLogs.length).toBeGreaterThan(0); - // Changed logging format to use error extractor - expect(errorLogs.some((log) => log.includes('Token exchange failed'))).toBe(true); + const allLogs = [...errorLogs, ...warnLogs, ...logLogs, ...debugLogs]; + expect(allLogs.length).toBeGreaterThan(0); + expect(allLogs.some((log) => /token|callback|oidc/i.test(log))).toBe(true); }); it('should log discovery failure details with invalid issuer URL', async () => { @@ -451,9 +451,13 @@ describe('OidcService Integration Tests - Enhanced Logging', () => { // Verify that we attempted the operation // Detailed parameter logging is now in OidcTokenExchangeService - expect(debugLogs.length).toBeGreaterThan(0); - expect(debugLogs.some((log) => log.includes('Client ID: detailed-client-id'))).toBe(true); - expect(debugLogs.some((log) => log.includes('Client secret configured: Yes'))).toBe(true); + const requestLogs = [...debugLogs, ...logLogs]; + expect(requestLogs.length).toBeGreaterThan(0); + expect( + requestLogs.some( + (log) => log.includes('detailed-client-id') || log.includes('token-params-test') + ) + ).toBe(true); }); it('should capture and log all error properties from openid-client', async () => { @@ -474,12 +478,12 @@ describe('OidcService Integration Tests - Enhanced Logging', () => { expect(result.error).toBeDefined(); // Should detect SSL/certificate issues or connection failure expect(result.error).toMatch( - /SSL\/TLS certificate error|Failed to connect to OIDC provider|certificate/ + /SSL\/TLS certificate error|Failed to connect to OIDC provider|certificate|Cannot resolve domain name|temporarily unavailable/ ); expect(result.details).toBeDefined(); expect(result.details).toHaveProperty('type'); - // Should be either SSL_ERROR or FETCH_ERROR - expect(['SSL_ERROR', 'FETCH_ERROR']).toContain((result.details as any).type); + // Should be one of the known transport failure types + expect(['SSL_ERROR', 'FETCH_ERROR', 'DNS_ERROR']).toContain((result.details as any).type); }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.model.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.model.ts new file mode 100644 index 0000000000..243181ef73 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.model.ts @@ -0,0 +1,77 @@ +import { Field, InputType, ObjectType } from '@nestjs/graphql'; + +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + Matches, + ValidateIf, +} from 'class-validator'; + +const MANUAL_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + +@ObjectType({ description: 'System time configuration and current status' }) +export class SystemTime { + @Field({ description: 'Current server time in ISO-8601 format (UTC)' }) + currentTime!: string; + + @Field({ description: 'IANA timezone identifier currently in use' }) + timeZone!: string; + + @Field({ description: 'Whether NTP/PTP time synchronization is enabled' }) + useNtp!: boolean; + + @Field(() => [String], { + description: 'Configured NTP servers (empty strings indicate unused slots)', + }) + ntpServers!: string[]; +} + +@ObjectType({ description: 'Selectable timezone option from the system list' }) +export class TimeZoneOption { + @Field({ description: 'IANA timezone identifier' }) + value!: string; + + @Field({ description: 'Display label for the timezone' }) + label!: string; +} + +@InputType() +export class UpdateSystemTimeInput { + @Field({ nullable: true, description: 'New IANA timezone identifier to apply' }) + @IsOptional() + @IsString() + timeZone?: string; + + @Field({ nullable: true, description: 'Enable or disable NTP-based synchronization' }) + @IsOptional() + @IsBoolean() + useNtp?: boolean; + + @Field(() => [String], { + nullable: true, + description: 'Ordered list of up to four NTP servers. Supply empty strings to clear positions.', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(4) + @IsString({ each: true }) + ntpServers?: string[]; + + @Field({ + nullable: true, + description: 'Manual date/time to apply when disabling NTP, expected format YYYY-MM-DD HH:mm:ss', + }) + @ValidateIf((input: UpdateSystemTimeInput) => input.useNtp === false) + @IsNotEmpty({ message: 'manualDateTime is required when useNtp is false' }) + @IsString() + @Matches(MANUAL_TIME_PATTERN, { + message: 'manualDateTime must be formatted as YYYY-MM-DD HH:mm:ss', + }) + manualDateTime?: string; +} + +export const MANUAL_TIME_REGEX = MANUAL_TIME_PATTERN; diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.module.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.module.ts new file mode 100644 index 0000000000..545b4da6ba --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { SystemTimeResolver } from '@app/unraid-api/graph/resolvers/system-time/system-time.resolver.js'; +import { SystemTimeService } from '@app/unraid-api/graph/resolvers/system-time/system-time.service.js'; + +@Module({ + providers: [SystemTimeResolver, SystemTimeService], +}) +export class SystemTimeModule {} diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.resolver.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.resolver.ts new file mode 100644 index 0000000000..3200767166 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.resolver.ts @@ -0,0 +1,43 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { + SystemTime, + TimeZoneOption, + UpdateSystemTimeInput, +} from '@app/unraid-api/graph/resolvers/system-time/system-time.model.js'; +import { SystemTimeService } from '@app/unraid-api/graph/resolvers/system-time/system-time.service.js'; + +@Resolver(() => SystemTime) +export class SystemTimeResolver { + constructor(private readonly systemTimeService: SystemTimeService) {} + + @Query(() => SystemTime, { description: 'Retrieve current system time configuration' }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.VARS, + }) + async systemTime(): Promise { + return this.systemTimeService.getSystemTime(); + } + + @Query(() => [TimeZoneOption], { description: 'Retrieve available time zone options' }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) + async timeZoneOptions(): Promise { + return this.systemTimeService.getTimeZoneOptions(); + } + + @Mutation(() => SystemTime, { description: 'Update system time configuration' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async updateSystemTime(@Args('input') input: UpdateSystemTimeInput): Promise { + return this.systemTimeService.updateSystemTime(input); + } +} diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.service.spec.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.service.spec.ts new file mode 100644 index 0000000000..c64b69065c --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.service.spec.ts @@ -0,0 +1,246 @@ +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import * as PhpLoaderModule from '@app/core/utils/plugins/php-loader.js'; +import { + MANUAL_TIME_REGEX, + UpdateSystemTimeInput, +} from '@app/unraid-api/graph/resolvers/system-time/system-time.model.js'; +import { SystemTimeService } from '@app/unraid-api/graph/resolvers/system-time/system-time.service.js'; + +vi.mock('@app/core/utils/clients/emcmd.js', () => ({ + emcmd: vi.fn(), +})); + +const phpLoaderSpy = vi.spyOn(PhpLoaderModule, 'phpLoader'); + +describe('SystemTimeService', () => { + let service: SystemTimeService; + let configService: ConfigService; + + beforeEach(async () => { + vi.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SystemTimeService, + { + provide: ConfigService, + useValue: { + get: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(SystemTimeService); + configService = module.get(ConfigService); + + vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === 'store.emhttp.var') { + return { + timeZone: 'UTC', + useNtp: true, + ntpServer1: 'time1.google.com', + ntpServer2: 'time2.google.com', + ntpServer3: '', + ntpServer4: '', + }; + } + if (key === 'store.paths.webGuiBase') { + return '/usr/local/emhttp/webGui'; + } + return defaultValue; + }); + + vi.mocked(emcmd).mockResolvedValue({ ok: true } as any); + phpLoaderSpy.mockResolvedValue(''); + }); + + afterEach(() => { + phpLoaderSpy.mockReset(); + }); + + it('returns system time from store state', async () => { + const result = await service.getSystemTime(); + expect(result.timeZone).toBe('UTC'); + expect(result.useNtp).toBe(true); + expect(result.ntpServers).toEqual(['time1.google.com', 'time2.google.com', '', '']); + expect(typeof result.currentTime).toBe('string'); + }); + + it('does not override NTP settings when store state is missing', async () => { + vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === 'store.emhttp.var') { + return {}; + } + if (key === 'store.paths.webGuiBase') { + return '/usr/local/emhttp/webGui'; + } + return defaultValue; + }); + + await service.updateSystemTime({ timeZone: 'America/New_York' }); + + expect(emcmd).toHaveBeenCalledTimes(1); + const [commands] = vi.mocked(emcmd).mock.calls[0]; + expect(commands).toEqual({ + setDateTime: 'apply', + timeZone: 'America/New_York', + }); + }); + + it('defaults to pool.ntp.org when no NTP servers are configured', async () => { + vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === 'store.emhttp.var') { + return { + timeZone: 'UTC', + useNtp: true, + ntpServer1: '', + ntpServer2: '', + ntpServer3: '', + ntpServer4: '', + }; + } + if (key === 'store.paths.webGuiBase') { + return '/usr/local/emhttp/webGui'; + } + return defaultValue; + }); + + await service.updateSystemTime({ timeZone: 'America/New_York' }); + + expect(emcmd).toHaveBeenCalledTimes(1); + const [commands] = vi.mocked(emcmd).mock.calls[0]; + expect(commands).toEqual({ + setDateTime: 'apply', + timeZone: 'America/New_York', + USE_NTP: 'yes', + NTP_SERVER1: 'pool.ntp.org', + NTP_SERVER2: '', + NTP_SERVER3: '', + NTP_SERVER4: '', + }); + }); + + it('updates time settings, disables NTP, and triggers timezone reset', async () => { + const oldState = { + timeZone: 'UTC', + useNtp: true, + ntpServer1: 'pool.ntp.org', + ntpServer2: '', + ntpServer3: '', + ntpServer4: '', + }; + const newState = { + timeZone: 'America/Los_Angeles', + useNtp: false, + ntpServer1: 'time.google.com', + ntpServer2: '', + ntpServer3: '', + ntpServer4: '', + }; + + let callCount = 0; + vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === 'store.emhttp.var') { + callCount++; + return callCount === 1 ? oldState : newState; + } + if (key === 'store.paths.webGuiBase') { + return '/usr/local/emhttp/webGui'; + } + return defaultValue; + }); + + const input: UpdateSystemTimeInput = { + timeZone: 'America/Los_Angeles', + useNtp: false, + manualDateTime: '2025-01-22 10:00:00', + ntpServers: ['time.google.com'], + }; + + const result = await service.updateSystemTime(input); + + expect(emcmd).toHaveBeenCalledTimes(1); + const [commands, options] = vi.mocked(emcmd).mock.calls[0]; + expect(options).toEqual({ waitForToken: true }); + expect(commands).toEqual({ + setDateTime: 'apply', + timeZone: 'America/Los_Angeles', + USE_NTP: 'no', + NTP_SERVER1: 'time.google.com', + NTP_SERVER2: '', + NTP_SERVER3: '', + NTP_SERVER4: '', + newDateTime: '2025-01-22 10:00:00', + }); + + expect(phpLoaderSpy).toHaveBeenCalledWith({ + file: '/usr/local/emhttp/webGui/include/ResetTZ.php', + method: 'GET', + }); + + expect(result.timeZone).toBe('America/Los_Angeles'); + expect(result.useNtp).toBe(false); + expect(result.ntpServers).toEqual(['time.google.com', '', '', '']); + }); + + it('throws when provided timezone is invalid', async () => { + await expect(service.updateSystemTime({ timeZone: 'Not/AZone' })).rejects.toBeInstanceOf( + BadRequestException + ); + expect(emcmd).not.toHaveBeenCalled(); + }); + + it('throws when disabling NTP without manualDateTime', async () => { + await expect(service.updateSystemTime({ useNtp: false })).rejects.toBeInstanceOf( + BadRequestException + ); + expect(emcmd).not.toHaveBeenCalled(); + }); + + it('retains manual mode and generates timestamp when not supplied', async () => { + const manualState = { + timeZone: 'UTC', + useNtp: false, + ntpServer1: '', + ntpServer2: '', + ntpServer3: '', + ntpServer4: '', + }; + const updatedState = { + timeZone: 'UTC', + useNtp: false, + ntpServer1: 'time.cloudflare.com', + ntpServer2: '', + ntpServer3: '', + ntpServer4: '', + }; + + let callCount = 0; + vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: any) => { + if (key === 'store.emhttp.var') { + callCount++; + return callCount === 1 ? manualState : updatedState; + } + if (key === 'store.paths.webGuiBase') { + return '/usr/local/emhttp/webGui'; + } + return defaultValue; + }); + + const result = await service.updateSystemTime({ ntpServers: ['time.cloudflare.com'] }); + + const [commands] = vi.mocked(emcmd).mock.calls[0]; + expect(commands.USE_NTP).toBe('no'); + expect(commands.NTP_SERVER1).toBe('time.cloudflare.com'); + expect(commands.newDateTime).toMatch(MANUAL_TIME_REGEX); + expect(phpLoaderSpy).not.toHaveBeenCalled(); + expect(result.ntpServers).toEqual(['time.cloudflare.com', '', '', '']); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.service.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.service.ts new file mode 100644 index 0000000000..38a7ba1579 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.service.ts @@ -0,0 +1,252 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import type { Var } from '@app/core/types/states/var.js'; +import type { TimeZoneOption } from '@app/unraid-api/graph/resolvers/system-time/system-time.model.js'; +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { phpLoader } from '@app/core/utils/plugins/php-loader.js'; +import { + SystemTime, + UpdateSystemTimeInput, +} from '@app/unraid-api/graph/resolvers/system-time/system-time.model.js'; + +const MAX_NTP_SERVERS = 4; +const DEFAULT_NTP_SERVER = 'pool.ntp.org'; + +@Injectable() +export class SystemTimeService { + private readonly logger = new Logger(SystemTimeService.name); + + constructor(private readonly configService: ConfigService) {} + + public async getSystemTime(): Promise { + return this.buildSystemTime(this.readVarState()); + } + + public async updateSystemTime(input: UpdateSystemTimeInput): Promise { + const current = this.configService.get>('store.emhttp.var', {}); + + const desiredTimeZone = (input.timeZone ?? current.timeZone)?.trim(); + if (!desiredTimeZone) { + throw new BadRequestException('A valid time zone is required.'); + } + this.validateTimeZone(desiredTimeZone); + + const hasCurrentUseNtp = typeof current.useNtp !== 'undefined'; + const hasCurrentNtpServers = this.hasNtpServerState(current); + const currentServers = hasCurrentNtpServers + ? this.normalizeNtpServers(undefined, current) + : null; + const hasConfiguredServers = currentServers + ? currentServers.some((server) => server.length > 0) + : false; + const allowDefaultNtp = input.useNtp !== false; + + let desiredUseNtp = input.useNtp ?? (hasCurrentUseNtp ? Boolean(current.useNtp) : undefined); + let desiredServers: string[] | null = null; + + if (input.ntpServers !== undefined) { + desiredServers = this.normalizeNtpServers(input.ntpServers, current); + } else if (hasCurrentNtpServers) { + if (!hasConfiguredServers && allowDefaultNtp) { + desiredServers = this.normalizeNtpServers([DEFAULT_NTP_SERVER], current); + desiredUseNtp = true; + } else { + desiredServers = currentServers; + } + } + + const commands: Record = { + setDateTime: 'apply', + timeZone: desiredTimeZone, + }; + + if (typeof desiredUseNtp !== 'undefined') { + commands.USE_NTP = desiredUseNtp ? 'yes' : 'no'; + } + + if (desiredServers) { + desiredServers.forEach((server, index) => { + commands[`NTP_SERVER${index + 1}`] = server; + }); + } + + const switchingToManual = desiredUseNtp === false && Boolean(current.useNtp); + if (desiredUseNtp === false) { + let manualDateTime = input.manualDateTime?.trim(); + if (switchingToManual && !manualDateTime) { + throw new BadRequestException( + 'manualDateTime is required when disabling NTP synchronization.' + ); + } + if (!manualDateTime) { + manualDateTime = this.formatManualDateTime(new Date()); + } + commands.newDateTime = manualDateTime; + } + + const timezoneChanged = desiredTimeZone !== (current.timeZone ?? ''); + const useNtpLabel = typeof desiredUseNtp === 'undefined' ? 'unchanged' : String(desiredUseNtp); + + this.logger.log( + `Updating system time settings (zone=${desiredTimeZone}, useNtp=${useNtpLabel}, timezoneChanged=${timezoneChanged})` + ); + + try { + await emcmd(commands, { waitForToken: true }); + this.logger.log('emcmd executed successfully for system time update.'); + } catch (error) { + this.logger.error('Failed to update system time via emcmd', error as Error); + throw error; + } + + if (timezoneChanged) { + await this.resetTimezoneWatcher(); + } + + const refreshed = this.readVarState(); + if (timezoneChanged && refreshed.timeZone !== desiredTimeZone) { + this.logger.warn( + `System time state still reports ${refreshed.timeZone ?? 'unknown'} after update to ${desiredTimeZone}.` + ); + } + + return this.buildSystemTime(refreshed); + } + + public async getTimeZoneOptions(): Promise { + const timeZoneFile = this.getTimeZoneListPath(); + try { + const contents = await readFile(timeZoneFile, 'utf-8'); + return this.parseTimeZoneOptions(contents); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to read time zone list from ${timeZoneFile}: ${message}`); + return []; + } + } + + private extractNtpServers(varState: Partial): string[] { + const servers = [ + varState.ntpServer1 ?? '', + varState.ntpServer2 ?? '', + varState.ntpServer3 ?? '', + varState.ntpServer4 ?? '', + ].map((value) => value?.trim() ?? ''); + + while (servers.length < MAX_NTP_SERVERS) { + servers.push(''); + } + + return servers; + } + + private readVarState(): Partial { + return this.configService.get>('store.emhttp.var', {}); + } + + private buildSystemTime(varState: Partial): SystemTime { + const ntpServers = this.extractNtpServers(varState); + + return { + currentTime: new Date().toISOString(), + timeZone: varState.timeZone ?? 'UTC', + useNtp: Boolean(varState.useNtp), + ntpServers, + }; + } + + private normalizeNtpServers(override: string[] | undefined, current: Partial): string[] { + if (!override) { + return this.extractNtpServers(current); + } + + const sanitized = override + .slice(0, MAX_NTP_SERVERS) + .map((server) => this.sanitizeNtpServer(server)); + + const result: string[] = []; + for (let i = 0; i < MAX_NTP_SERVERS; i += 1) { + result[i] = sanitized[i] ?? ''; + } + + return result; + } + + private sanitizeNtpServer(server?: string): string { + if (!server) { + return ''; + } + return server.trim().slice(0, 40); + } + + private getTimeZoneListPath(): string { + const webGuiBase = this.configService.get( + 'store.paths.webGuiBase', + '/usr/local/emhttp/webGui' + ); + return resolve(webGuiBase, '..', 'plugins', 'dynamix', 'include', 'timezones.key'); + } + + private parseTimeZoneOptions(contents: string): TimeZoneOption[] { + return contents + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separatorIndex = line.indexOf('|'); + if (separatorIndex === -1) { + const value = line.trim(); + return value ? { value, label: value } : null; + } + const value = line.slice(0, separatorIndex).trim(); + if (!value) { + return null; + } + const label = line.slice(separatorIndex + 1).trim() || value; + return { value, label }; + }) + .filter((entry): entry is TimeZoneOption => Boolean(entry)); + } + + private hasNtpServerState(varState: Partial): boolean { + return ( + typeof varState.ntpServer1 !== 'undefined' || + typeof varState.ntpServer2 !== 'undefined' || + typeof varState.ntpServer3 !== 'undefined' || + typeof varState.ntpServer4 !== 'undefined' + ); + } + + private validateTimeZone(timeZone: string) { + try { + new Intl.DateTimeFormat('en-US', { timeZone }); + } catch (error) { + this.logger.warn(`Invalid time zone provided: ${timeZone}`); + throw new BadRequestException(`Invalid time zone: ${timeZone}`); + } + } + + private formatManualDateTime(date: Date): string { + const pad = (value: number) => value.toString().padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + } + + private async resetTimezoneWatcher() { + const webGuiBase = this.configService.get( + 'store.paths.webGuiBase', + '/usr/local/emhttp/webGui' + ); + const scriptPath = join(webGuiBase, 'include', 'ResetTZ.php'); + + try { + await phpLoader({ file: scriptPath, method: 'GET' }); + this.logger.debug('Executed ResetTZ.php to refresh timezone watchers.'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to execute ResetTZ.php at ${scriptPath}: ${message}`); + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.ts new file mode 100644 index 0000000000..bfb7ccd0fc --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.ts @@ -0,0 +1,114 @@ +import { Field, GraphQLISODateTime, ID, InputType, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { IsBoolean, IsOptional, IsString, IsUrl } from 'class-validator'; + +export enum PluginInstallStatus { + FAILED = 'FAILED', + QUEUED = 'QUEUED', + RUNNING = 'RUNNING', + SUCCEEDED = 'SUCCEEDED', +} + +registerEnumType(PluginInstallStatus, { + name: 'PluginInstallStatus', + description: 'Status of a plugin installation operation', +}); + +@InputType({ + description: 'Input payload for installing a plugin', +}) +export class InstallPluginInput { + @Field(() => String, { + description: 'Plugin installation URL (.plg)', + }) + @IsUrl({ + protocols: ['http', 'https'], + require_protocol: true, + }) + url!: string; + + @Field(() => String, { + nullable: true, + description: 'Optional human-readable plugin name used for logging', + }) + @IsOptional() + @IsString() + name?: string | null; + + @Field(() => Boolean, { + nullable: true, + description: + 'Force installation even when plugin is already present. Defaults to true to mirror the existing UI behaviour.', + }) + @IsOptional() + @IsBoolean() + forced?: boolean | null; +} + +@ObjectType({ + description: 'Represents a tracked plugin installation operation', +}) +export class PluginInstallOperation { + @Field(() => ID, { + description: 'Unique identifier of the operation', + }) + id!: string; + + @Field(() => String, { + description: 'Plugin URL passed to the installer', + }) + url!: string; + + @Field(() => String, { + nullable: true, + description: 'Optional plugin name for display purposes', + }) + name?: string | null; + + @Field(() => PluginInstallStatus, { + description: 'Current status of the operation', + }) + status!: PluginInstallStatus; + + @Field(() => GraphQLISODateTime, { + description: 'Timestamp when the operation was created', + }) + createdAt!: Date; + + @Field(() => GraphQLISODateTime, { + nullable: true, + description: 'Timestamp for the last update to this operation', + }) + updatedAt?: Date | null; + + @Field(() => GraphQLISODateTime, { + nullable: true, + description: 'Timestamp when the operation finished, if applicable', + }) + finishedAt?: Date | null; + + @Field(() => [String], { + description: 'Collected output lines generated by the installer (capped at recent lines)', + }) + output!: string[]; +} + +@ObjectType({ + description: 'Emitted event representing progress for a plugin installation', +}) +export class PluginInstallEvent { + @Field(() => ID, { description: 'Identifier of the related plugin installation operation' }) + operationId!: string; + + @Field(() => PluginInstallStatus, { description: 'Status reported with this event' }) + status!: PluginInstallStatus; + + @Field(() => [String], { + nullable: true, + description: 'Output lines newly emitted since the previous event', + }) + output?: string[] | null; + + @Field(() => GraphQLISODateTime, { description: 'Timestamp when the event was emitted' }) + timestamp!: Date; +} diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.module.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.module.ts new file mode 100644 index 0000000000..5f98fc59a4 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { UnraidPluginsMutationsResolver } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.mutation.js'; +import { UnraidPluginsResolver } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.resolver.js'; +import { UnraidPluginsService } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.js'; + +@Module({ + providers: [UnraidPluginsMutationsResolver, UnraidPluginsResolver, UnraidPluginsService], + exports: [UnraidPluginsService], +}) +export class UnraidPluginsModule {} diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.mutation.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.mutation.ts new file mode 100644 index 0000000000..a14e5c1a0e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.mutation.ts @@ -0,0 +1,38 @@ +import { Args, ResolveField, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { UnraidPluginsMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; +import { + InstallPluginInput, + PluginInstallOperation, +} from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js'; +import { UnraidPluginsService } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.js'; + +@Resolver(() => UnraidPluginsMutations) +export class UnraidPluginsMutationsResolver { + constructor(private readonly pluginsService: UnraidPluginsService) {} + + @ResolveField(() => PluginInstallOperation, { + description: 'Installs an Unraid plugin and begins tracking its progress', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async installPlugin(@Args('input') input: InstallPluginInput): Promise { + return this.pluginsService.installPlugin(input); + } + + @ResolveField(() => PluginInstallOperation, { + description: 'Installs an Unraid language pack and begins tracking its progress', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async installLanguage(@Args('input') input: InstallPluginInput): Promise { + return this.pluginsService.installLanguage(input); + } +} diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.resolver.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.resolver.ts new file mode 100644 index 0000000000..610002915c --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.resolver.ts @@ -0,0 +1,65 @@ +import { Args, ID, Query, Resolver, Subscription } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { + PluginInstallEvent, + PluginInstallOperation, +} from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js'; +import { UnraidPluginsService } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.js'; + +@Resolver() +export class UnraidPluginsResolver { + constructor(private readonly pluginsService: UnraidPluginsService) {} + + @Query(() => PluginInstallOperation, { + nullable: true, + description: 'Retrieve a plugin installation operation by identifier', + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) + async pluginInstallOperation( + @Args('operationId', { type: () => ID }) operationId: string + ): Promise { + return this.pluginsService.getOperation(operationId); + } + + @Query(() => [PluginInstallOperation], { + description: 'List all tracked plugin installation operations', + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) + async pluginInstallOperations(): Promise { + return this.pluginsService.listOperations(); + } + + @Query(() => [String], { + description: 'List installed Unraid OS plugins by .plg filename', + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) + async installedUnraidPlugins(): Promise { + return this.pluginsService.listInstalledPlugins(); + } + + @Subscription(() => PluginInstallEvent, { + name: 'pluginInstallUpdates', + resolve: (payload: { pluginInstallUpdates: PluginInstallEvent }) => payload.pluginInstallUpdates, + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.CONFIG, + }) + pluginInstallUpdates( + @Args('operationId', { type: () => ID }) operationId: string + ): AsyncIterableIterator<{ pluginInstallUpdates: PluginInstallEvent }> { + return this.pluginsService.subscribe(operationId); + } +} diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.spec.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.spec.ts new file mode 100644 index 0000000000..a2bf53238e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.spec.ts @@ -0,0 +1,212 @@ +import { ConfigService } from '@nestjs/config'; +import EventEmitter from 'node:events'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { pubsub } from '@app/core/pubsub.js'; +import { PluginInstallStatus } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js'; +import { UnraidPluginsService } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.js'; + +class MockExecaProcess extends EventEmitter { + public readonly all = new PassThrough(); +} + +const mockExeca = vi.fn<(...args: unknown[]) => MockExecaProcess>(); + +vi.mock('execa', () => ({ + execa: (...args: unknown[]) => mockExeca(...args), +})); + +const flushAsync = async () => { + await Promise.resolve(); +}; + +describe('UnraidPluginsService', () => { + let service: UnraidPluginsService; + let currentProcess: MockExecaProcess; + + beforeEach(() => { + vi.restoreAllMocks(); + service = new UnraidPluginsService(new ConfigService()); + currentProcess = new MockExecaProcess(); + currentProcess.all.setEncoding('utf-8'); + mockExeca.mockReset(); + mockExeca.mockImplementation(() => currentProcess); + }); + + const emitSuccess = (process: MockExecaProcess, lines: string[]) => { + lines.forEach((line) => process.all.write(`${line}\n`)); + process.all.end(); + process.emit('close', 0); + }; + + const emitFailure = (process: MockExecaProcess, errorMessage: string) => { + process.all.write(`${errorMessage}\n`); + process.all.end(); + process.emit('close', 1); + }; + + it('installs plugin successfully and captures output', async () => { + const publishSpy = vi.spyOn(pubsub, 'publish'); + + const operation = await service.installPlugin({ + url: 'https://example.com/plugin.plg', + name: 'Example Plugin', + }); + + expect(mockExeca).toHaveBeenCalledWith( + 'plugin', + ['install', 'https://example.com/plugin.plg', 'forced'], + { + all: true, + reject: false, + timeout: 5 * 60 * 1000, + } + ); + + const runningOperation = service.getOperation(operation.id); + expect(runningOperation?.status).toBe(PluginInstallStatus.RUNNING); + + emitSuccess(currentProcess, ['Downloading package', 'Installation complete']); + await flushAsync(); + + const completedOperation = service.getOperation(operation.id); + expect(completedOperation?.status).toBe(PluginInstallStatus.SUCCEEDED); + expect(completedOperation?.output).toEqual(['Downloading package', 'Installation complete']); + + expect(publishSpy).toHaveBeenCalledWith(expect.stringContaining(operation.id), { + pluginInstallUpdates: expect.objectContaining({ + operationId: operation.id, + status: PluginInstallStatus.RUNNING, + }), + }); + + expect(publishSpy).toHaveBeenCalledWith(expect.stringContaining(operation.id), { + pluginInstallUpdates: expect.objectContaining({ + operationId: operation.id, + status: PluginInstallStatus.SUCCEEDED, + }), + }); + }); + + it('marks installation as failed on non-zero exit', async () => { + const publishSpy = vi.spyOn(pubsub, 'publish'); + + const operation = await service.installPlugin({ + url: 'https://example.com/plugin.plg', + name: 'Broken Plugin', + }); + + emitFailure(currentProcess, 'Installation failed'); + await flushAsync(); + + const failedOperation = service.getOperation(operation.id); + expect(failedOperation?.status).toBe(PluginInstallStatus.FAILED); + expect(failedOperation?.output.some((line) => line.includes('Installation failed'))).toBe(true); + + expect(publishSpy).toHaveBeenCalledWith(expect.stringContaining(operation.id), { + pluginInstallUpdates: expect.objectContaining({ + operationId: operation.id, + status: PluginInstallStatus.FAILED, + }), + }); + }); + + it('installs language without forced arg and tracks operation list', async () => { + const operation = await service.installLanguage({ + url: 'https://example.com/language.txz', + name: 'French', + forced: true, + }); + + expect(mockExeca).toHaveBeenCalledWith( + 'language', + ['install', 'https://example.com/language.txz'], + expect.objectContaining({ + all: true, + reject: false, + timeout: 5 * 60 * 1000, + }) + ); + + expect(service.getOperation(operation.id)).toMatchObject({ + id: operation.id, + status: PluginInstallStatus.RUNNING, + }); + expect(service.getOperation('missing-operation-id')).toBeNull(); + expect(service.listOperations().map((entry) => entry.id)).toContain(operation.id); + }); + + it('listInstalledPlugins returns plugin files from configured directory', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'unraid-plugins-test-')); + try { + const pluginsDir = join(tempDir, 'plugins'); + const dynamixBase = join(pluginsDir, 'dynamix'); + await mkdir(dynamixBase, { recursive: true }); + await writeFile(join(pluginsDir, 'community.applications.plg'), 'plugin-data'); + await writeFile(join(pluginsDir, 'README.txt'), 'not-a-plugin'); + + const configService = { + get: vi.fn().mockReturnValue({ + 'dynamix-base': dynamixBase, + }), + } as unknown as ConfigService; + const configuredService = new UnraidPluginsService(configService); + + const result = await configuredService.listInstalledPlugins(); + expect(result).toEqual(['community.applications.plg']); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('listInstalledPlugins returns empty array when plugin directory is missing', async () => { + const configService = { + get: vi.fn().mockReturnValue({ + 'dynamix-base': '/tmp/definitely-missing-dynamix-base', + }), + } as unknown as ConfigService; + const configuredService = new UnraidPluginsService(configService); + + await expect(configuredService.listInstalledPlugins()).resolves.toEqual([]); + }); + + it('removes completed operations after retention ttl', async () => { + vi.useFakeTimers(); + try { + const ttlConfigService = { + get: vi.fn((key: string, defaultValue: unknown) => { + if (key === 'plugins.installOperationRetentionMs') { + return 1_000; + } + return defaultValue; + }), + } as unknown as ConfigService; + const serviceWithShortTtl = new UnraidPluginsService(ttlConfigService); + + const processWithShortTtl = new MockExecaProcess(); + processWithShortTtl.all.setEncoding('utf-8'); + mockExeca.mockImplementation(() => processWithShortTtl as unknown as any); + + const operation = await serviceWithShortTtl.installPlugin({ + url: 'https://example.com/plugin.plg', + name: 'Cleanup Test Plugin', + }); + + emitSuccess(processWithShortTtl, ['done']); + await flushAsync(); + + expect(serviceWithShortTtl.getOperation(operation.id)).toBeTruthy(); + + await vi.advanceTimersByTimeAsync(1_001); + + expect(serviceWithShortTtl.getOperation(operation.id)).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.ts new file mode 100644 index 0000000000..9c887fcf50 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.ts @@ -0,0 +1,468 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import type { ExecaError } from 'execa'; +import { execa } from 'execa'; + +import { createSubscription, pubsub } from '@app/core/pubsub.js'; +import { + InstallPluginInput, + PluginInstallEvent, + PluginInstallOperation, + PluginInstallStatus, +} from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js'; + +const CHANNEL_PREFIX = 'PLUGIN_INSTALL:'; + +type PluginInstallSubscriberIterator = AsyncIterableIterator<{ + pluginInstallUpdates: PluginInstallEvent; +}>; + +type PluginInstallChildProcess = ReturnType; + +type OperationType = 'plugin' | 'language'; + +const INSTALLER_COMMAND_CANDIDATES: Record = { + plugin: [ + '/usr/local/sbin/plugin', + '/usr/local/emhttp/plugins/dynamix.plugin.manager/scripts/plugin', + 'plugin', + ], + language: [ + '/usr/local/sbin/language', + '/usr/local/emhttp/plugins/dynamix.plugin.manager/scripts/language', + 'language', + ], +}; +const INSTALLER_COMMAND_TIMEOUT_MS = 5 * 60 * 1000; + +interface OperationState { + id: string; + type: OperationType; + url: string; + name?: string | null; + status: PluginInstallStatus; + createdAt: Date; + updatedAt?: Date; + finishedAt?: Date; + output: string[]; + bufferedOutput: string; + forced: boolean; + child?: PluginInstallChildProcess; +} + +@Injectable() +export class UnraidPluginsService { + private readonly logger = new Logger(UnraidPluginsService.name); + private readonly operations = new Map(); + private readonly operationCleanupTimers = new Map>(); + private readonly installerCommandCache = new Map(); + private readonly MAX_OUTPUT_LINES = 500; + private readonly COMPLETED_OPERATION_TTL_MS: number; + + constructor(private readonly configService: ConfigService) { + const ttlFromConfig = this.configService.get( + 'plugins.installOperationRetentionMs', + 15 * 60 * 1000 + ); + this.COMPLETED_OPERATION_TTL_MS = Math.max(ttlFromConfig ?? 15 * 60 * 1000, 1000); + } + + async installPlugin(input: InstallPluginInput): Promise { + return this.startOperation('plugin', input); + } + + async installLanguage(input: InstallPluginInput): Promise { + return this.startOperation('language', input); + } + + private async startOperation( + type: OperationType, + input: InstallPluginInput + ): Promise { + const validatedUrl = this.validateInstallUrl(type, input.url); + const id = randomUUID(); + const createdAt = new Date(); + + const operation: OperationState = { + id, + type, + url: validatedUrl, + name: input.name, + status: PluginInstallStatus.RUNNING, + createdAt, + updatedAt: createdAt, + output: [], + bufferedOutput: '', + forced: input.forced ?? true, + }; + + this.operations.set(id, operation); + + this.logger.log( + `Starting ${type} installation for "${input.name ?? input.url}" (operation ${id})` + ); + + this.publishEvent(operation, []); + + const args = this.buildArgs(operation); + const command = await this.resolveInstallerCommand(type); + + const child = execa(command, args, { + all: true, + reject: false, + timeout: INSTALLER_COMMAND_TIMEOUT_MS, + }); + + operation.child = child; + + if (child.all) { + child.all.on('data', (chunk) => { + this.handleOutput(operation, chunk.toString()); + }); + } else { + child.stdout?.on('data', (chunk) => this.handleOutput(operation, chunk.toString())); + child.stderr?.on('data', (chunk) => this.handleOutput(operation, chunk.toString())); + } + + child.on('error', (error) => { + if (operation.status === PluginInstallStatus.RUNNING) { + this.handleFailure(operation, error); + } + }); + + child.on('close', (code) => { + if (operation.status !== PluginInstallStatus.RUNNING) { + return; + } + + if (code === 0) { + this.handleSuccess(operation); + } else { + this.handleFailure(operation, new Error(`${type} command exited with ${code}`)); + } + }); + + return this.toGraphqlOperation(operation); + } + + private validateInstallUrl(type: OperationType, candidateUrl: string): string { + const normalized = candidateUrl.trim(); + let parsedUrl: URL; + try { + parsedUrl = new URL(normalized); + } catch { + throw new Error(`Invalid ${type} URL: "${candidateUrl}".`); + } + + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + throw new Error(`Unsupported URL protocol for ${type} install: ${parsedUrl.protocol}`); + } + + if (type === 'plugin' && !parsedUrl.pathname.toLowerCase().endsWith('.plg')) { + throw new Error(`Plugin URL must point to a .plg file: "${candidateUrl}".`); + } + + return parsedUrl.toString(); + } + + async listInstalledPlugins(): Promise { + const paths = this.configService.get>('store.paths', {}); + const dynamixBase = paths?.['dynamix-base'] ?? '/boot/config/plugins/dynamix'; + const pluginsDir = path.resolve(dynamixBase, '..'); + + try { + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.plg')) + .map((entry) => entry.name); + } catch (error: unknown) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + this.logger.warn(`Plugin directory not found at ${pluginsDir}.`); + return []; + } + + this.logger.error('Failed to read plugin directory.', error); + return []; + } + } + + getOperation(id: string): PluginInstallOperation | null { + const operation = this.operations.get(id); + if (!operation) { + return null; + } + return this.toGraphqlOperation(operation); + } + + listOperations(): PluginInstallOperation[] { + return Array.from(this.operations.values()).map((operation) => + this.toGraphqlOperation(operation) + ); + } + + subscribe(operationId: string): PluginInstallSubscriberIterator { + if (!this.operations.has(operationId)) { + throw new Error(`Unknown plugin installation operation: ${operationId}`); + } + return createSubscription<{ + pluginInstallUpdates: PluginInstallEvent; + }>(this.getChannel(operationId)); + } + + private buildArgs(operation: OperationState): string[] { + const args = ['install', operation.url]; + // 'language' command doesn't support 'forced' flag in same way, or at all? + // Checking doc: language install LANGUAGE-FILE + // plugin install PLUGIN-FILE [forced] + + if (operation.type === 'plugin' && operation.forced) { + args.push('forced'); + } + return args; + } + + private async resolveInstallerCommand(type: OperationType): Promise { + const cached = this.installerCommandCache.get(type); + if (cached) { + return cached; + } + + const candidates = INSTALLER_COMMAND_CANDIDATES[type]; + + for (const candidate of candidates) { + if (!candidate.includes('/')) { + this.installerCommandCache.set(type, candidate); + return candidate; + } + + try { + await fs.access(candidate, fsConstants.X_OK); + this.installerCommandCache.set(type, candidate); + return candidate; + } catch { + // Try next candidate. + } + } + + // Should be unreachable because final candidate is command name. + const fallback = type; + this.installerCommandCache.set(type, fallback); + return fallback; + } + + private handleOutput(operation: OperationState, chunk: string) { + const timestamp = new Date(); + operation.updatedAt = timestamp; + operation.bufferedOutput += chunk; + + const lines = this.extractCompleteLines(operation); + if (!lines.length) { + return; + } + + operation.output.push(...lines); + this.trimOutput(operation); + this.publishEvent(operation, lines); + } + + private extractCompleteLines(operation: OperationState): string[] { + const lines = operation.bufferedOutput.split(/\r?\n/); + operation.bufferedOutput = lines.pop() ?? ''; + return lines.map((line) => line.trimEnd()).filter((line) => line.length > 0); + } + + private handleSuccess(operation: OperationState) { + if (operation.status !== PluginInstallStatus.RUNNING) { + return; + } + + const timestamp = new Date(); + operation.status = PluginInstallStatus.SUCCEEDED; + operation.finishedAt = timestamp; + operation.updatedAt = timestamp; + + const trailingOutput = this.flushBuffer(operation); + if (trailingOutput.length) { + operation.output.push(...trailingOutput); + } + this.trimOutput(operation); + this.publishEvent(operation, trailingOutput); + this.publishEvent(operation, [], true); + this.scheduleOperationCleanup(operation.id); + this.logger.log( + `Plugin installation for "${operation.name ?? operation.url}" completed successfully (operation ${operation.id})` + ); + } + + private handleFailure(operation: OperationState, error: unknown) { + if (operation.status !== PluginInstallStatus.RUNNING) { + return; + } + + const timestamp = new Date(); + operation.status = PluginInstallStatus.FAILED; + operation.finishedAt = timestamp; + operation.updatedAt = timestamp; + + const trailingOutput = this.flushBuffer(operation); + if (trailingOutput.length) { + operation.output.push(...trailingOutput); + } + + const errorLine = this.normalizeError(error); + if (errorLine) { + operation.output.push(errorLine); + } + + this.trimOutput(operation); + const outputLines = [...trailingOutput]; + if (errorLine) { + outputLines.push(errorLine); + } + this.publishEvent(operation, outputLines); + this.publishEvent(operation, [], true); + this.scheduleOperationCleanup(operation.id); + + this.logger.error( + `Plugin installation for "${operation.name ?? operation.url}" failed (operation ${operation.id})`, + error instanceof Error ? error.stack : undefined + ); + } + + private flushBuffer(operation: OperationState): string[] { + if (!operation.bufferedOutput) { + return []; + } + const buffered = operation.bufferedOutput.trim(); + operation.bufferedOutput = ''; + return buffered.length ? [buffered] : []; + } + + private normalizeError(error: unknown): string | null { + const extracted = this.extractErrorOutput(error); + if (extracted) { + const trimmed = extracted.trim(); + if (trimmed.length) { + return trimmed; + } + } + + if (error && typeof error === 'object' && 'code' in error) { + const code = (error as { code?: unknown }).code; + if (code === 'ENOENT') { + return 'Plugin command not found on this system.'; + } + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return null; + } + + private extractErrorOutput(error: unknown): string { + if (!error || typeof error !== 'object') { + return ''; + } + + const candidate = error as ExecaError & { all?: unknown }; + return ( + this.coerceToString(candidate.all) ?? + this.coerceToString(candidate.stderr) ?? + this.coerceToString(candidate.stdout) ?? + this.coerceToString(candidate.shortMessage) ?? + this.coerceToString(candidate.message) ?? + '' + ); + } + + private coerceToString(value: unknown): string | null { + if (!value) { + return null; + } + + if (typeof value === 'string') { + return value; + } + + if (value instanceof Uint8Array) { + return Buffer.from(value).toString('utf-8'); + } + + if (Array.isArray(value)) { + const combined = value + .map((entry) => this.coerceToString(entry) ?? '') + .filter((entry) => entry.length > 0) + .join('\n'); + return combined.length ? combined : null; + } + + return null; + } + + private trimOutput(operation: OperationState) { + if (operation.output.length <= this.MAX_OUTPUT_LINES) { + return; + } + const excess = operation.output.length - this.MAX_OUTPUT_LINES; + operation.output.splice(0, excess); + } + + private publishEvent(operation: OperationState, output: string[], final = false) { + const event: PluginInstallEvent = { + operationId: operation.id, + status: operation.status, + output: output.length ? output : undefined, + timestamp: new Date(), + }; + + void pubsub.publish(this.getChannel(operation.id), { + pluginInstallUpdates: event, + }); + + if (final) { + // no-op placeholder for future cleanup hooks + } + } + + private toGraphqlOperation(operation: OperationState): PluginInstallOperation { + return { + id: operation.id, + url: operation.url, + name: operation.name, + status: operation.status, + createdAt: operation.createdAt, + updatedAt: operation.updatedAt ?? null, + finishedAt: operation.finishedAt ?? null, + output: [...operation.output], + }; + } + + private getChannel(operationId: string): string { + return `${CHANNEL_PREFIX}${operationId}`; + } + + private scheduleOperationCleanup(operationId: string) { + const existing = this.operationCleanupTimers.get(operationId); + if (existing) { + clearTimeout(existing); + } + + const timer = setTimeout(() => { + this.operations.delete(operationId); + this.operationCleanupTimers.delete(operationId); + }, this.COMPLETED_OPERATION_TTL_MS); + + if (typeof (timer as { unref?: () => void }).unref === 'function') { + (timer as { unref: () => void }).unref(); + } + + this.operationCleanupTimers.set(operationId, timer); + } +} diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.model.ts b/api/src/unraid-api/graph/resolvers/vars/vars.model.ts index 82c857be1d..53dee9337c 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.model.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.model.ts @@ -1,6 +1,7 @@ -import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; +import { Field, ID, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Node } from '@unraid/shared/graphql.model.js'; +import { IsBoolean, IsInt, Max, Min } from 'class-validator'; import { RegistrationState, @@ -463,3 +464,16 @@ export class Vars extends Node { @Field({ nullable: true }) csrfToken?: string; } + +@InputType() +export class UpdateSshInput { + @Field() + @IsBoolean() + enabled!: boolean; + + @Field(() => Int, { description: 'SSH Port (default 22)' }) + @IsInt() + @Min(1) + @Max(65535) + port!: number; +} diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.spec.ts index ae2975bcff..cfb0f385ab 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.spec.ts @@ -4,13 +4,20 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it } from 'vitest'; import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js'; +import { VarsService } from '@app/unraid-api/graph/resolvers/vars/vars.service.js'; describe('VarsResolver', () => { let resolver: VarsResolver; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [VarsResolver], + providers: [ + VarsResolver, + { + provide: VarsService, + useValue: {}, + }, + ], }).compile(); resolver = module.get(VarsResolver); diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts index eab8fc9c0b..3ea90995e8 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.resolver.ts @@ -1,15 +1,16 @@ -import { Query, Resolver } from '@nestjs/graphql'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { getters } from '@app/store/index.js'; -import { Public } from '@app/unraid-api/auth/public.decorator.js'; -import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; -import { Vars } from '@app/unraid-api/graph/resolvers/vars/vars.model.js'; +import { UpdateSshInput, Vars } from '@app/unraid-api/graph/resolvers/vars/vars.model.js'; +import { VarsService } from '@app/unraid-api/graph/resolvers/vars/vars.service.js'; @Resolver(() => Vars) export class VarsResolver { + constructor(private readonly varsService: VarsService) {} + @Query(() => Vars) @UsePermissions({ action: AuthAction.READ_ANY, @@ -22,9 +23,12 @@ export class VarsResolver { }; } - @Query(() => Boolean) - @Public() - public async isInitialSetup() { - return getters.emhttp().var?.regState === RegistrationState.ENOKEYFILE; + @Mutation(() => Vars) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.VARS, + }) + public async updateSshSettings(@Args('input') input: UpdateSshInput) { + return this.varsService.updateSshSettings(input.enabled, input.port); } } diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.service.spec.ts b/api/src/unraid-api/graph/resolvers/vars/vars.service.spec.ts new file mode 100644 index 0000000000..74a48548f4 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/vars/vars.service.spec.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { sleep } from '@app/core/utils/misc/sleep.js'; +import { getters, store } from '@app/store/index.js'; +import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; +import { VarsService } from '@app/unraid-api/graph/resolvers/vars/vars.service.js'; + +vi.mock('@app/core/utils/clients/emcmd.js', () => ({ + emcmd: vi.fn(), +})); + +vi.mock('@app/core/utils/misc/sleep.js', () => ({ + sleep: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@app/store/modules/emhttp.js', () => ({ + loadSingleStateFile: vi.fn((key: unknown) => key), +})); + +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: vi.fn(), + }, + store: { + dispatch: vi.fn(), + }, +})); + +describe('VarsService', () => { + let service: VarsService; + let currentVarState: Record; + + beforeEach(() => { + vi.clearAllMocks(); + service = new VarsService(); + + currentVarState = { + startPage: 'Main', + useTelnet: false, + porttelnet: 23, + useUpnp: false, + useSsl: 'no', + port: 80, + portssl: 443, + localTld: 'local', + useSsh: false, + portssh: 22, + }; + + vi.mocked(getters.emhttp).mockImplementation( + () => + ({ + var: currentVarState, + }) as any + ); + + vi.mocked(emcmd).mockResolvedValue({ + body: '', + ok: true, + } as any); + + vi.mocked(store.dispatch).mockImplementation( + () => + ({ + unwrap: vi.fn().mockResolvedValue({ + var: currentVarState, + }), + }) as any + ); + }); + + it('sends expected emcmd payload and returns verified vars when state converges', async () => { + vi.mocked(store.dispatch).mockImplementation(() => { + currentVarState = { + ...currentVarState, + useSsh: true, + portssh: 2222, + }; + return { + unwrap: vi.fn().mockResolvedValue({ + var: currentVarState, + }), + } as any; + }); + + const result = await service.updateSshSettings(true, 2222); + + expect(emcmd).toHaveBeenCalledWith( + { + changePorts: 'Apply', + server_name: 'localhost', + server_addr: '127.0.0.1', + START_PAGE: 'Main', + USE_TELNET: 'no', + PORTTELNET: '23', + USE_SSH: 'yes', + PORTSSH: '2222', + USE_UPNP: 'no', + USE_SSL: 'no', + PORT: '80', + PORTSSL: '443', + LOCAL_TLD: 'local', + }, + { waitForToken: false } + ); + + expect(result).toMatchObject({ + id: 'vars', + useSsh: true, + portssh: 2222, + }); + expect(loadSingleStateFile).toHaveBeenCalled(); + }); + + it('uses safe defaults when current vars are missing', async () => { + currentVarState = {}; + + const result = await service.updateSshSettings(false, 22); + + expect(emcmd).toHaveBeenCalledWith( + expect.objectContaining({ + START_PAGE: 'Main', + USE_TELNET: 'no', + PORTTELNET: '23', + USE_SSH: 'no', + PORTSSH: '22', + USE_UPNP: 'no', + USE_SSL: 'no', + PORT: '80', + PORTSSL: '443', + LOCAL_TLD: 'local', + }), + { waitForToken: false } + ); + expect(result).toMatchObject({ + useSsh: false, + portssh: 22, + }); + }); + + it('swallows emcmd errors and returns last observed vars when unverifiable', async () => { + vi.mocked(emcmd).mockRejectedValue(new Error('connection reset')); + + vi.mocked(store.dispatch).mockImplementation( + () => + ({ + unwrap: vi.fn().mockRejectedValue(new Error('store refresh failed')), + }) as any + ); + + await expect(service.updateSshSettings(true, 22)).resolves.toMatchObject({ + useSsh: false, + portssh: 22, + }); + expect(sleep).toHaveBeenCalled(); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.service.ts b/api/src/unraid-api/graph/resolvers/vars/vars.service.ts new file mode 100644 index 0000000000..b83b8a1010 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/vars/vars.service.ts @@ -0,0 +1,140 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { sleep } from '@app/core/utils/misc/sleep.js'; +import { getters, store } from '@app/store/index.js'; +import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; +import { StateFileKey } from '@app/store/types.js'; +import { Vars } from '@app/unraid-api/graph/resolvers/vars/vars.model.js'; + +@Injectable() +export class VarsService { + private readonly logger = new Logger(VarsService.name); + private readonly VERIFY_MAX_ATTEMPTS = 5; + private readonly VERIFY_RETRY_MS = 1000; + + private isSshStateApplied(vars: Record, enabled: boolean, port: number): boolean { + const currentEnabled = Boolean(vars.useSsh); + if (currentEnabled !== enabled) { + return false; + } + + if (!enabled) { + return true; + } + + const currentPort = Number(vars.portssh); + return Number.isFinite(currentPort) && currentPort === port; + } + + private async reloadVarsState(): Promise> { + try { + await store.dispatch(loadSingleStateFile(StateFileKey.var)).unwrap(); + } catch (error) { + this.logger.debug('Failed to refresh var state during SSH verification', error as Error); + } + + return (getters.emhttp().var ?? {}) as Record; + } + + private async waitForSshState( + enabled: boolean, + port: number + ): Promise<{ verified: boolean; vars: Record }> { + let latestVars = (getters.emhttp().var ?? {}) as Record; + + for (let attempt = 0; attempt < this.VERIFY_MAX_ATTEMPTS; attempt += 1) { + const refreshedVars = await this.reloadVarsState(); + if (Object.keys(refreshedVars).length > 0) { + latestVars = refreshedVars; + } + + if (this.isSshStateApplied(refreshedVars, enabled, port)) { + return { + verified: true, + vars: refreshedVars, + }; + } + + if (attempt < this.VERIFY_MAX_ATTEMPTS - 1) { + await sleep(this.VERIFY_RETRY_MS); + } + } + + return { + verified: false, + vars: latestVars, + }; + } + + public async updateSshSettings(enabled: boolean, port: number): Promise { + this.logger.log(`Updating SSH settings: enabled=${enabled}, port=${port}`); + + const currentVars = getters.emhttp().var ?? {}; + + // Helper to formatting values for emcmd (converting booleans to yes/no) + const formatBool = (val: boolean | undefined | null) => (val ? 'yes' : 'no'); + const formatVal = (val: any) => (val !== undefined && val !== null ? String(val) : ''); + + // Construct parameters based on ManagementAccess.page form fields + // We preserve existing values for other fields to avoid overwriting them with defaults/empty + const updateParams = { + changePorts: 'Apply', + server_name: 'localhost', + server_addr: '127.0.0.1', + // Use safe defaults for current values if store is not populated + START_PAGE: formatVal(currentVars.startPage || 'Main'), + USE_TELNET: formatBool(currentVars.useTelnet), // defaults to 'no' via formatBool(undefined) + PORTTELNET: formatVal(currentVars.porttelnet || '23'), + USE_SSH: formatBool(enabled), // New Value + PORTSSH: formatVal(port), // New Value + USE_UPNP: formatBool(currentVars.useUpnp), // defaults to 'no' + USE_SSL: formatVal(currentVars.useSsl || 'no'), + PORT: formatVal(currentVars.port || '80'), + PORTSSL: formatVal(currentVars.portssl || '443'), + LOCAL_TLD: formatVal(currentVars.localTld || 'local'), + }; + + this.logger.debug('Sending emcmd update params:', updateParams); + + try { + // We disable token waiting because this operation restarts network services (SSH/SSHD), + // which can cause the request to hang or fail if we wait for a token validation round-trip. + const result = await emcmd(updateParams, { waitForToken: false }); + this.logger.log('SSH settings applied via emcmd', result.body); + } catch (error: any) { + this.logger.error('Failed to apply SSH settings via emcmd', error); + if (error?.response) { + this.logger.error('Response body:', error.response.body); + } + // We swallow errors here because restarting SSH/network services often causes + // the connection or emcmd to fail/hang up even though the operation succeeded. + // Returning the optimistic state allows the UI to proceed. + this.logger.warn( + 'Error during emcmd execution (likely due to service restart), proceeding optimistically.' + ); + } + + const { verified, vars } = await this.waitForSshState(enabled, port); + if (verified) { + this.logger.log('SSH settings verified after update.'); + } else { + this.logger.warn( + 'SSH settings update submitted, but final state could not be verified yet.' + ); + } + + const fallbackVars = { + ...currentVars, + useSsh: enabled, + portssh: port, + }; + + const responseVars = Object.keys(vars).length > 0 ? vars : fallbackVars; + + return { + id: 'vars', + ...responseVars, + } as unknown as Vars; + } +} diff --git a/api/src/unraid-api/graph/resolvers/vms/vms.service.spec.ts b/api/src/unraid-api/graph/resolvers/vms/vms.service.spec.ts index aebc4b7036..87e5b0f5f6 100644 --- a/api/src/unraid-api/graph/resolvers/vms/vms.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/vms/vms.service.spec.ts @@ -158,7 +158,9 @@ const isQemuAvailable = () => { } }; -describe('VmsService', () => { +const describeVmService = isQemuAvailable() ? describe : describe.skip; + +describeVmService('VmsService', () => { let service: VmsService; let hypervisor: Hypervisor; let testVm: VmDomain | null = null; @@ -184,14 +186,6 @@ describe('VmsService', () => { `; - beforeAll(() => { - if (!isQemuAvailable()) { - throw new Error( - 'QEMU not available - skipping VM integration tests. Please install QEMU to run these tests.' - ); - } - }); - beforeAll(async () => { // Override the LIBVIRT_URI environment variable for testing process.env.LIBVIRT_URI = LIBVIRT_URI; diff --git a/api/src/unraid-api/main.ts b/api/src/unraid-api/main.ts index 4b753abfaa..0bfbb68ddc 100644 --- a/api/src/unraid-api/main.ts +++ b/api/src/unraid-api/main.ts @@ -21,6 +21,7 @@ export async function bootstrapNestServer(): Promise { bufferLogs: false, ...(LOG_LEVEL !== 'TRACE' ? { logger: false } : {}), }); + app.enableShutdownHooks(['SIGINT', 'SIGTERM', 'SIGQUIT']); // Enable validation globally app.useGlobalPipes( diff --git a/api/src/unraid-api/rest/rest.controller.ts b/api/src/unraid-api/rest/rest.controller.ts index e1402e1bae..3edc414f31 100644 --- a/api/src/unraid-api/rest/rest.controller.ts +++ b/api/src/unraid-api/rest/rest.controller.ts @@ -4,6 +4,7 @@ import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import escapeHtml from 'escape-html'; +import type { CustomizationType } from '@app/unraid-api/rest/rest.service.js'; import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js'; import { Public } from '@app/unraid-api/auth/public.decorator.js'; import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/oidc-config.service.js'; @@ -35,13 +36,16 @@ export class RestController { resource: Resource.CUSTOMIZATIONS, }) async getCustomizations(@Param('type') type: string, @Res() res: FastifyReply) { - if (type !== 'banner' && type !== 'case') { + const validTypes: CustomizationType[] = ['banner', 'case']; + if (!validTypes.includes(type as CustomizationType)) { throw new Error('Invalid Customization Type'); } try { - const customizationStream = await this.restService.getCustomizationStream(type); - return res.status(200).type('image/png').send(customizationStream); + const customization = await this.restService.getCustomizationStream( + type as CustomizationType + ); + return res.status(200).type(customization.contentType).send(customization.stream); } catch (error: unknown) { this.logger.error(error); return res.status(500).send(`Error: Failed to get customizations`); diff --git a/api/src/unraid-api/rest/rest.service.spec.ts b/api/src/unraid-api/rest/rest.service.spec.ts index 1be55250ba..63cb2082fd 100644 --- a/api/src/unraid-api/rest/rest.service.spec.ts +++ b/api/src/unraid-api/rest/rest.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import type { ReadStream } from 'node:fs'; import { createReadStream } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { Readable } from 'node:stream'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -12,6 +13,9 @@ import { import { RestService } from '@app/unraid-api/rest/rest.service.js'; vi.mock('node:fs'); +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({ getBannerPathIfPresent: vi.fn(), getCasePathIfPresent: vi.fn(), @@ -22,6 +26,7 @@ describe('RestService', () => { beforeEach(async () => { vi.clearAllMocks(); + vi.mocked(readFile).mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47])); const module: TestingModule = await Test.createTestingModule({ providers: [RestService], @@ -61,8 +66,13 @@ describe('RestService', () => { vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath); vi.mocked(createReadStream).mockReturnValue(mockStream); + vi.mocked(readFile).mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47])); - await expect(service.getCustomizationStream('banner')).resolves.toBe(mockStream); + const result = await service.getCustomizationStream('banner'); + expect(result).toEqual({ + stream: mockStream, + contentType: 'image/png', + }); expect(createReadStream).toHaveBeenCalledWith(mockPath); }); @@ -72,8 +82,13 @@ describe('RestService', () => { vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath); vi.mocked(createReadStream).mockReturnValue(mockStream); + vi.mocked(readFile).mockResolvedValue(Buffer.from('GIF89a')); - await expect(service.getCustomizationStream('case')).resolves.toBe(mockStream); + const result = await service.getCustomizationStream('case'); + expect(result).toEqual({ + stream: mockStream, + contentType: 'image/gif', + }); expect(createReadStream).toHaveBeenCalledWith(mockPath); }); @@ -84,5 +99,23 @@ describe('RestService', () => { await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found'); await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found'); }); + + it('detects svg content by payload when extension is missing', async () => { + const mockPath = '/path/to/case'; + const mockStream: ReadStream = Readable.from([]) as ReadStream; + + vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath); + vi.mocked(createReadStream).mockReturnValue(mockStream); + vi.mocked(readFile).mockResolvedValue( + Buffer.from('') + ); + + const result = await service.getCustomizationStream('case'); + + expect(result).toEqual({ + stream: mockStream, + contentType: 'image/svg+xml', + }); + }); }); }); diff --git a/api/src/unraid-api/rest/rest.service.ts b/api/src/unraid-api/rest/rest.service.ts index ca96ff5527..03004a3b74 100644 --- a/api/src/unraid-api/rest/rest.service.ts +++ b/api/src/unraid-api/rest/rest.service.ts @@ -1,15 +1,29 @@ import { Injectable } from '@nestjs/common'; import type { ReadStream } from 'node:fs'; import { createReadStream } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; import { getBannerPathIfPresent, getCasePathIfPresent, } from '@app/core/utils/images/image-file-helpers.js'; +export type CustomizationType = 'banner' | 'case'; + +const EXTENSION_CONTENT_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.svg': 'image/svg+xml', +}; + @Injectable() export class RestService { - async getCustomizationPath(type: 'banner' | 'case'): Promise { + async getCustomizationPath(type: CustomizationType): Promise { switch (type) { case 'banner': return getBannerPathIfPresent(); @@ -18,11 +32,66 @@ export class RestService { } } - async getCustomizationStream(type: 'banner' | 'case'): Promise { + private looksLikeSvg(buffer: Buffer): boolean { + const content = buffer.toString('utf8').trimStart(); + return content.startsWith('= 8) { + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) { + return 'image/png'; + } + + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'image/jpeg'; + } + + if ( + buffer.subarray(0, 4).toString('ascii') === 'RIFF' && + buffer.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp'; + } + } + + if (buffer.length >= 6 && buffer.subarray(0, 6).toString('ascii') === 'GIF87a') { + return 'image/gif'; + } + + if (buffer.length >= 6 && buffer.subarray(0, 6).toString('ascii') === 'GIF89a') { + return 'image/gif'; + } + + if (this.looksLikeSvg(buffer.subarray(0, 1024))) { + return 'image/svg+xml'; + } + + return 'application/octet-stream'; + } + + private async detectContentType(path: string): Promise { + const fileContents = await readFile(path); + const signatureType = this.detectContentTypeFromBuffer(fileContents); + if (signatureType !== 'application/octet-stream') { + return signatureType; + } + + const extension = extname(path).toLowerCase(); + return EXTENSION_CONTENT_TYPES[extension] ?? 'application/octet-stream'; + } + + async getCustomizationStream(type: CustomizationType): Promise<{ + stream: ReadStream; + contentType: string; + }> { const path = await this.getCustomizationPath(type); if (!path) { throw new Error(`No ${type} found`); } - return createReadStream(path); + + const stream = createReadStream(path); + const contentType = await this.detectContentType(path); + return { stream, contentType }; } } diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/partner-logo-copier.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/partner-logo-copier.modification.ts deleted file mode 100644 index 21d53f8548..0000000000 --- a/api/src/unraid-api/unraid-file-modifier/modifications/partner-logo-copier.modification.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Logger } from '@nestjs/common'; -import { mkdir, symlink, unlink } from 'fs/promises'; -import { dirname } from 'path'; - -import { fileExists } from '@app/core/utils/files/file-exists.js'; -import { store } from '@app/store/index.js'; -import { - FileModification, - ShouldApplyWithReason, -} from '@app/unraid-api/unraid-file-modifier/file-modification.js'; - -export class PartnerLogoCopierModification extends FileModification { - id: string = 'partner-logo-copier'; - public readonly filePath: string; - private readonly sourcePath: string; - private readonly targetPath: string; - - constructor(logger: Logger) { - super(logger); - const paths = store.getState().paths; - this.sourcePath = paths.activation.logo; - this.targetPath = paths.webgui.logo.fullPath; - this.filePath = this.targetPath; - } - - protected async generatePatch(): Promise { - // This modification doesn't generate a patch since it's just copying/symlinking files - return ''; - } - - public async apply(): Promise { - try { - if (await fileExists(this.sourcePath)) { - this.logger.log('Partner logo found in activation assets, applying...'); - await mkdir(dirname(this.targetPath), { recursive: true }); - - try { - await unlink(this.targetPath); - } catch (error) { - // Ignore errors if file doesn't exist - } - - await symlink(this.sourcePath, this.targetPath); - this.logger.log(`Partner logo symlinked to ${this.targetPath}`); - return 'Partner logo applied successfully'; - } - return 'No partner logo found to apply'; - } catch (error) { - this.logger.error('Error applying partner logo:', error); - throw error; - } - } - - async shouldApply(): Promise { - const sourceExists = await fileExists(this.sourcePath); - return { - shouldApply: sourceExists, - reason: sourceExists - ? 'Partner logo found in activation assets' - : 'No partner logo found in activation assets', - }; - } -} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/set-password-modal.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/set-password-modal.modification.ts deleted file mode 100644 index 58b885400f..0000000000 --- a/api/src/unraid-api/unraid-file-modifier/modifications/set-password-modal.modification.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -import { - FileModification, - ShouldApplyWithReason, -} from '@app/unraid-api/unraid-file-modifier/file-modification.js'; - -// Renamed class to accurately reflect its purpose and target file -export default class SetPasswordModalModification extends FileModification { - id: string = 'set-password-modal'; // Updated ID - public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/include/.set-password.php'; - - protected async generatePatch(overridePath?: string): Promise { - const fileContent = await readFile(this.filePath, 'utf-8'); - - const newContent = SetPasswordModalModification.applyToSource(fileContent); - - return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent); - } - - async shouldApply(): Promise { - const superShouldApply = await super.shouldApply(); - if (!superShouldApply.shouldApply) { - return superShouldApply; - } - const fileContent = await readFile(this.filePath, 'utf-8'); - const injectString = - ''; - // Apply only if the string isn't already present - if (fileContent.includes(injectString)) { - return { - shouldApply: false, - reason: 'Welcome modal include already exists.', - }; - } - return { - shouldApply: true, - reason: 'Inject welcome modal include.', - }; - } - - private static applyToSource(fileContent: string): string { - const injectString = - ''; - const bodyEndTag = ''; - - // Check if the body tag exists and the inject string is not already there - if (fileContent.includes(bodyEndTag) && !fileContent.includes(injectString)) { - // Inject the string right before the closing body tag - return fileContent.replace(bodyEndTag, `${injectString}\n${bodyEndTag}`); - } - - // Return original content if conditions aren't met (e.g., no body tag, already injected) - return fileContent; - } -} diff --git a/package.json b/package.json index 546314cf09..fc398f5826 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "check": "manypkg check", "sync-webgui-repo": "node web/scripts/sync-webgui-repo.js", "preinstall": "npx check-node-version --node 22 || echo '❌ Node.js 22 required. See readme.md Prerequisites section.'", - "postinstall": "simple-git-hooks" + "postinstall": "simple-git-hooks", + "docker:build-and-run": "pnpm --filter @unraid/connect-plugin docker:build-and-run" }, "pnpm": { "overrides": { diff --git a/packages/unraid-api-plugin-health/package.json b/packages/unraid-api-plugin-health/package.json index e6ce4c578b..625d583f1e 100644 --- a/packages/unraid-api-plugin-health/package.json +++ b/packages/unraid-api-plugin-health/package.json @@ -7,7 +7,7 @@ "dist" ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "echo \"Error: no test specified\" && exit 0", "build": "tsc", "prepare": "npm run build" }, diff --git a/packages/unraid-shared/package.json b/packages/unraid-shared/package.json index d5c908528d..1923cea370 100644 --- a/packages/unraid-shared/package.json +++ b/packages/unraid-shared/package.json @@ -38,6 +38,7 @@ "@types/bun": "1.2.21", "@types/lodash-es": "4.17.12", "@types/node": "22.18.0", + "@types/semver": "7.7.0", "@types/ws": "8.18.1", "class-transformer": "0.5.1", "class-validator": "0.14.2", diff --git a/plugin/package.json b/plugin/package.json index 33421291e1..9f1e8f8786 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -26,7 +26,8 @@ "// Docker commands": "", "build:watch": "./scripts/dc.sh pnpm run build:watcher", "docker:build": "docker compose build", - "docker:run": "./scripts/dc.sh /bin/bash", + "docker:run": "SKIP_HOST_BUILD=true ./scripts/dc.sh bash -c 'pnpm build && exec bash'", + "predocker:build-and-run": "pnpm install && pnpm --filter @unraid/ui run build:wc && pnpm --filter @unraid/web run build && pnpm --filter @unraid/api run build:release", "docker:build-and-run": "pnpm run docker:build && pnpm run docker:run", "// Environment management": "", "env:init": "cp .env.example .env", diff --git a/plugin/scripts/dc.sh b/plugin/scripts/dc.sh index b0a4dc78ac..e5887d1a2e 100755 --- a/plugin/scripts/dc.sh +++ b/plugin/scripts/dc.sh @@ -34,21 +34,25 @@ if [ ! -d "$WEB_DIST_DIR" ]; then fi # Build dependencies before starting Docker (always rebuild to prevent staleness) -echo "Building dependencies..." +if [ "$SKIP_HOST_BUILD" != "true" ]; then + echo "Building dependencies..." -echo "Building API release..." -if ! (cd .. && pnpm --filter @unraid/api build:release); then - echo "Error: API build failed. Aborting." - exit 1 -fi + echo "Building API release..." + if ! (cd .. && pnpm --filter @unraid/api build:release); then + echo "Error: API build failed. Aborting." + exit 1 + fi -echo "Building web standalone..." -if ! (cd .. && pnpm --filter @unraid/web build); then - echo "Error: Web build failed. Aborting." - exit 1 -fi + echo "Building web standalone..." + if ! (cd .. && pnpm --filter @unraid/web build); then + echo "Error: Web build failed. Aborting." + exit 1 + fi -echo "Dependencies built successfully." + echo "Dependencies built successfully." +else + echo "Skipping host build (SKIP_HOST_BUILD=true)..." +fi # Stop any running plugin-builder container first echo "Stopping any running plugin-builder containers..." diff --git a/plugin/scripts/rsync-activation-dir.sh b/plugin/scripts/rsync-activation-dir.sh index 95dbc3349b..457b0218a3 100755 --- a/plugin/scripts/rsync-activation-dir.sh +++ b/plugin/scripts/rsync-activation-dir.sh @@ -8,7 +8,7 @@ # ./plugin/scripts/rsync-activation-dir.sh --local-directory /Users/zack/Downloads/activation_code_pdfs_12_19_2024_1436 --remote-host unraid.local --remove-password # Default values -REMOTE_PATH="/boot/config/activation" +REMOTE_PATH="/boot/config/activate" REMOVE_PASSWORD=false # Parse named flag parameters diff --git a/plugin/source/dynamix.unraid.net/install/doinst.sh b/plugin/source/dynamix.unraid.net/install/doinst.sh index e18f5f64eb..710522e626 100644 --- a/plugin/source/dynamix.unraid.net/install/doinst.sh +++ b/plugin/source/dynamix.unraid.net/install/doinst.sh @@ -31,3 +31,9 @@ cp usr/local/unraid-api/.env.production usr/local/unraid-api/.env ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) ( cd usr/local/bin ; rm -rf npx ) ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) +( cd usr/local/bin ; rm -rf corepack ) +( cd usr/local/bin ; ln -sf ../lib/node_modules/corepack/dist/corepack.js corepack ) +( cd usr/local/bin ; rm -rf npm ) +( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) +( cd usr/local/bin ; rm -rf npx ) +( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/Onboarding.page b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/Onboarding.page new file mode 100644 index 0000000000..a4b93ab953 --- /dev/null +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/Onboarding.page @@ -0,0 +1,15 @@ +Menu="ManagementAccess:151" +Title="Onboarding Tester" +Icon="icon-registration" +Tag="pencil" +Cond="(((@json_decode(@file_get_contents('/boot/config/plugins/dynamix.my.servers/configs/api.json'), true) ?: [])['sandbox'] ?? false) === true || (((@parse_ini_file('/boot/config/plugins/dynamix.my.servers/myservers.cfg', true) ?: [])['local']['sandbox'] ?? 'no') === 'yes'))" +--- + + + diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/api-config.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/api-config.php index a436d8cb68..973b1971a2 100644 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/api-config.php +++ b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/api-config.php @@ -103,6 +103,7 @@ public static function getApiVersion() class ApiUserConfig { public const CONFIG_PATH = ApiConfig::CONFIG_DIR . '/api.json'; + private const LEGACY_CONFIG_PATH = '/boot/config/plugins/dynamix.my.servers/myservers.cfg'; public static function getConfig() { @@ -118,4 +119,15 @@ public static function isSSOEnabled() $config = self::getConfig(); return !empty($config['ssoSubIds'] ?? ''); } + + public static function isSandboxEnabled() + { + $config = self::getConfig(); + if (array_key_exists('sandbox', $config)) { + return $config['sandbox'] === true; + } + + $legacyConfig = @parse_ini_file(self::LEGACY_CONFIG_PATH, true) ?: []; + return ($legacyConfig['local']['sandbox'] ?? 'no') === 'yes'; + } } diff --git a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/welcome-modal.php b/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/welcome-modal.php deleted file mode 100644 index 7614e39dd0..0000000000 --- a/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/welcome-modal.php +++ /dev/null @@ -1,18 +0,0 @@ -getScriptTagHtml(); -?> - - - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ac949572d..1154b6c6b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -767,6 +767,9 @@ importers: '@types/node': specifier: 22.18.0 version: 22.18.0 + '@types/semver': + specifier: 7.7.0 + version: 7.7.0 '@types/ws': specifier: 8.18.1 version: 8.18.1 @@ -1109,6 +1112,9 @@ importers: '@vueuse/integrations': specifier: 13.8.0 version: 13.8.0(change-case@5.4.4)(focus-trap@7.6.5)(fuse.js@7.1.0)(jwt-decode@4.0.0)(vue@3.5.20(typescript@5.9.2)) + '@vvo/tzdb': + specifier: ^6.186.0 + version: 6.186.0 ajv: specifier: 8.17.1 version: 8.17.1 @@ -5644,6 +5650,9 @@ packages: peerDependencies: vue: ^3.5.0 + '@vvo/tzdb@6.186.0': + resolution: {integrity: sha512-UHSNLPElPVd70GmRhZxlD5oCnD+tq1KtVGRu7j0oMuSEeyz4StgZYj/guwCjg4Ew8uFCTI3yUO4TJlpDd5n7wg==} + '@whatwg-node/disposablestack@0.0.5': resolution: {integrity: sha512-9lXugdknoIequO4OYvIjhygvfSEgnO8oASLqLelnDhkRjgBZhc39shC3QSlZuyDO9bgYSIVa2cHAiN+St3ty4w==} engines: {node: '>=18.0.0'} @@ -12451,8 +12460,8 @@ packages: vue-component-type-helpers@3.0.6: resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} - vue-component-type-helpers@3.1.3: - resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==} + vue-component-type-helpers@3.1.5: + resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -16519,7 +16528,7 @@ snapshots: storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) type-fest: 2.19.0 vue: 3.5.20(typescript@5.9.2) - vue-component-type-helpers: 3.1.3 + vue-component-type-helpers: 3.1.5 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -17777,6 +17786,8 @@ snapshots: dependencies: vue: 3.5.20(typescript@5.9.2) + '@vvo/tzdb@6.186.0': {} + '@whatwg-node/disposablestack@0.0.5': dependencies: tslib: 2.8.1 @@ -25363,7 +25374,7 @@ snapshots: vue-component-type-helpers@3.0.6: {} - vue-component-type-helpers@3.1.3: {} + vue-component-type-helpers@3.1.5: {} vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)): dependencies: diff --git a/unraid-ui/src/components/brand/BrandButton.vue b/unraid-ui/src/components/brand/BrandButton.vue index 97e4f9ba88..b4341d96f0 100644 --- a/unraid-ui/src/components/brand/BrandButton.vue +++ b/unraid-ui/src/components/brand/BrandButton.vue @@ -100,13 +100,13 @@ const handleKeydown = (event: KeyboardEvent) => { @keydown="handleKeydown" >
- + ', - props: ['text', 'iconRight', 'variant', 'external', 'href', 'size', 'type'], - emits: ['click'], - }, - }; -}); - -const mockT = testTranslate; - -const mockComponents = { - ActivationPartnerLogo: { - template: '
', - props: ['partnerInfo'], - }, - ActivationSteps: { - template: '
', - props: ['activeStep'], - }, -}; - -const mockActivationCodeDataStore = { - partnerInfo: ref({ - hasPartnerLogo: false, - partnerName: null as string | null, - }), -}; - -let handleKeydown: ((e: KeyboardEvent) => void) | null = null; - -const mockActivationCodeModalStore = { - isVisible: ref(true), - setIsHidden: vi.fn((value: boolean) => { - if (value === true) { - window.location.href = '/Tools/Registration'; - } - }), - // This gets defined after we mock the store - _store: null as unknown, -}; - -const mockPurchaseStore = { - activate: vi.fn(), -}; - -vi.mock('~/components/Activation/store/activationCodeModal', () => { - const store = { - useActivationCodeModalStore: () => { - mockActivationCodeModalStore._store = mockActivationCodeModalStore; - return mockActivationCodeModalStore; - }, - }; - return store; -}); - -vi.mock('~/components/Activation/store/activationCodeData', () => ({ - useActivationCodeDataStore: () => mockActivationCodeDataStore, -})); - -vi.mock('~/store/purchase', () => ({ - usePurchaseStore: () => mockPurchaseStore, -})); - -vi.mock('~/store/theme', () => ({ - useThemeStore: vi.fn(), -})); - -vi.mock('@heroicons/vue/24/solid', () => ({ - ArrowTopRightOnSquareIcon: {}, -})); - -const originalAddEventListener = window.addEventListener; -window.addEventListener = vi.fn((event: string, handler: EventListenerOrEventListenerObject) => { - if (event === 'keydown') { - handleKeydown = handler as unknown as (e: KeyboardEvent) => void; - } - return originalAddEventListener(event, handler); -}); - -describe('Activation/ActivationModal.vue', () => { - beforeEach(() => { - vi.clearAllMocks(); - - mockActivationCodeDataStore.partnerInfo.value = { - hasPartnerLogo: false, - partnerName: null, - }; - - mockActivationCodeModalStore.isVisible.value = true; - - // Reset window.location - Object.defineProperty(window, 'location', { - writable: true, - value: { href: '' }, - }); - - handleKeydown = null; - }); - - const mountComponent = () => { - return mount(ActivationModal, { - global: { - plugins: [createTestI18n()], - stubs: mockComponents, - }, - }); - }; - - it('uses the correct title text', () => { - mountComponent(); - - expect(mockT("Let's activate your Unraid OS License")).toBe("Let's activate your Unraid OS License"); - }); - - it('uses the correct description text', () => { - mountComponent(); - - const descriptionText = mockT( - `On the following screen, your license will be activated. You'll then create an Unraid.net Account to manage your license going forward.` - ); - - expect(descriptionText).toBe( - "On the following screen, your license will be activated. You'll then create an Unraid.net Account to manage your license going forward." - ); - }); - - it('provides documentation links with correct URLs', () => { - mountComponent(); - const licensingText = mockT('More about Licensing'); - const accountsText = mockT('More about Unraid.net Accounts'); - - expect(licensingText).toBe('More about Licensing'); - expect(accountsText).toBe('More about Unraid.net Accounts'); - }); - - it('displays the partner logo when available', () => { - mockActivationCodeDataStore.partnerInfo.value = { - hasPartnerLogo: true, - partnerName: 'partner-name', - }; - - const wrapper = mountComponent(); - - expect(wrapper.html()).toContain('data-testid="partner-logo"'); - }); - - it('calls activate method when Activate Now button is clicked', async () => { - const wrapper = mountComponent(); - const button = wrapper.find('[data-testid="brand-button"]'); - - expect(button.exists()).toBe(true); - - await button.trigger('click'); - - expect(mockPurchaseStore.activate).toHaveBeenCalledTimes(1); - }); - - it('handles Konami code sequence to close modal and redirect', async () => { - mountComponent(); - - if (!handleKeydown) { - return; - } - - const konamiCode = [ - 'ArrowUp', - 'ArrowUp', - 'ArrowDown', - 'ArrowDown', - 'ArrowLeft', - 'ArrowRight', - 'ArrowLeft', - 'ArrowRight', - 'b', - 'a', - ]; - - for (const key of konamiCode) { - handleKeydown(new KeyboardEvent('keydown', { key })); - } - - expect(mockActivationCodeModalStore.setIsHidden).toHaveBeenCalledWith(true); - expect(window.location.href).toBe('/Tools/Registration'); - }); - - it('does not trigger konami code action for incorrect sequence', async () => { - mountComponent(); - - if (!handleKeydown) { - return; - } - - const incorrectSequence = ['ArrowUp', 'ArrowDown', 'b', 'a']; - - for (const key of incorrectSequence) { - handleKeydown(new KeyboardEvent('keydown', { key })); - } - - expect(mockActivationCodeModalStore.setIsHidden).not.toHaveBeenCalled(); - expect(window.location.href).toBe(''); - }); - - it('does not render when isVisible is false', () => { - mockActivationCodeModalStore.isVisible.value = false; - const wrapper = mountComponent(); - - expect(wrapper.find('[role="dialog"]').exists()).toBe(false); - }); - - it('renders activation steps with correct active step', () => { - const wrapper = mountComponent(); - - expect(wrapper.html()).toContain('data-testid="activation-steps"'); - expect(wrapper.html()).toContain('active-step="2"'); - }); -}); diff --git a/web/__test__/components/Activation/ActivationPartnerLogo.test.ts b/web/__test__/components/Activation/ActivationPartnerLogo.test.ts deleted file mode 100644 index 82eae58552..0000000000 --- a/web/__test__/components/Activation/ActivationPartnerLogo.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * ActivationPartnerLogo Component Test Coverage - */ - -import { mount } from '@vue/test-utils'; - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import ActivationPartnerLogo from '~/components/Activation/ActivationPartnerLogo.vue'; - -const mockActivationPartnerLogoImg = { - template: '
', - props: ['partnerInfo'], -}; - -describe('ActivationPartnerLogo', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - const mountComponent = (props = {}) => { - return mount(ActivationPartnerLogo, { - props, - global: { - stubs: { - ActivationPartnerLogoImg: mockActivationPartnerLogoImg, - }, - }, - }); - }; - - it('renders a link with partner logo when partnerUrl exists', () => { - const wrapper = mountComponent({ - partnerInfo: { - partnerUrl: 'https://example.com', - }, - }); - const link = wrapper.find('a'); - const logoImg = wrapper.find('[data-testid="partner-logo-img"]'); - - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe('https://example.com'); - expect(link.attributes('target')).toBe('_blank'); - expect(link.attributes('rel')).toBe('noopener noreferrer'); - expect(logoImg.exists()).toBe(true); - }); - - it('does not render anything when no partnerUrl exists', () => { - const wrapper = mountComponent({ - partnerInfo: { - partnerUrl: null, - }, - }); - const link = wrapper.find('a'); - const logoImg = wrapper.find('[data-testid="partner-logo-img"]'); - - expect(link.exists()).toBe(false); - expect(logoImg.exists()).toBe(false); - }); - - it('applies correct opacity classes for hover and focus states', () => { - const wrapper = mountComponent({ - partnerInfo: { - partnerUrl: 'https://example.com', - }, - }); - const link = wrapper.find('a'); - - expect(link.classes()).toContain('opacity-100'); - expect(link.classes()).toContain('hover:opacity-75'); - expect(link.classes()).toContain('focus:opacity-75'); - }); -}); diff --git a/web/__test__/components/Activation/ActivationPartnerLogoImg.test.ts b/web/__test__/components/Activation/ActivationPartnerLogoImg.test.ts deleted file mode 100644 index c2befd1a09..0000000000 --- a/web/__test__/components/Activation/ActivationPartnerLogoImg.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * ActivationPartnerLogoImg Component Test Coverage - */ - -import { ref } from 'vue'; -import { mount } from '@vue/test-utils'; - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import ActivationPartnerLogoImg from '~/components/Activation/ActivationPartnerLogoImg.vue'; - -const mockThemeStore = { - darkMode: ref(false), -}; - -vi.mock('~/store/theme', () => ({ - useThemeStore: () => mockThemeStore, -})); - -describe('ActivationPartnerLogoImg', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockThemeStore.darkMode.value = false; - }); - - const mountComponent = (props = {}) => { - return mount(ActivationPartnerLogoImg, { - props, - }); - }; - - it('renders the image when partnerLogoUrl exists', () => { - const wrapper = mountComponent({ - partnerInfo: { - partnerLogoUrl: 'https://example.com/logo.png', - hasPartnerLogo: true, - }, - }); - const img = wrapper.find('img'); - - expect(img.exists()).toBe(true); - expect(img.attributes('src')).toBe('https://example.com/logo.png'); - expect(img.classes()).toContain('w-72'); - }); - - it('does not render when partnerLogoUrl is null', () => { - const wrapper = mountComponent({ - partnerInfo: { - partnerLogoUrl: null, - hasPartnerLogo: false, - }, - }); - const img = wrapper.find('img'); - - expect(img.exists()).toBe(false); - }); - - it('applies invert class when in dark mode and has partner logo', () => { - mockThemeStore.darkMode.value = true; - const wrapper = mountComponent({ - partnerInfo: { - partnerLogoUrl: 'https://example.com/logo.png', - hasPartnerLogo: true, - }, - }); - const img = wrapper.find('img'); - - expect(img.classes()).toContain('invert'); - }); - - it('does not apply invert class when in dark mode but no partner logo', () => { - mockThemeStore.darkMode.value = true; - const wrapper = mountComponent({ - partnerInfo: { - partnerLogoUrl: 'https://example.com/logo.png', - hasPartnerLogo: false, - }, - }); - const img = wrapper.find('img'); - - expect(img.classes()).not.toContain('invert'); - }); - - it('does not apply invert class when not in dark mode', () => { - mockThemeStore.darkMode.value = false; - const wrapper = mountComponent({ - partnerInfo: { - partnerLogoUrl: 'https://example.com/logo.png', - hasPartnerLogo: true, - }, - }); - const img = wrapper.find('img'); - - expect(img.classes()).not.toContain('invert'); - }); -}); diff --git a/web/__test__/components/Activation/ActivationSteps.test.ts b/web/__test__/components/Activation/ActivationSteps.test.ts deleted file mode 100644 index c3a9bd7084..0000000000 --- a/web/__test__/components/Activation/ActivationSteps.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * ActivationSteps Component Test Coverage - */ - -import { mount } from '@vue/test-utils'; - -import { describe, expect, it, vi } from 'vitest'; - -import ActivationSteps from '~/components/Activation/ActivationSteps.vue'; -import { createTestI18n } from '../../utils/i18n'; - -interface Props { - activeStep?: number; -} - -vi.mock('@unraid/ui', () => ({ - Stepper: { - template: '
', - props: ['defaultValue'], - }, - StepperItem: { - template: '
', - props: ['step', 'disabled'], - data() { - return { - state: 'active', - }; - }, - }, - StepperTrigger: { - template: '
', - }, - StepperTitle: { - template: '
', - }, - StepperDescription: { - template: '
', - }, - StepperSeparator: { - template: '
', - }, - Button: { - template: '', - }, -})); - -vi.mock('@heroicons/vue/24/outline', () => ({ - CheckIcon: { template: '
' }, - KeyIcon: { template: '
' }, - ServerStackIcon: { template: '
' }, -})); - -vi.mock('@heroicons/vue/24/solid', () => ({ - KeyIcon: { template: '
' }, - LockClosedIcon: { template: '
' }, - ServerStackIcon: { template: '
' }, -})); - -describe('ActivationSteps', () => { - const mountComponent = (props: Props = {}) => { - return mount(ActivationSteps, { - props, - global: { - plugins: [createTestI18n()], - }, - }); - }; - - it('renders all three steps with correct titles and descriptions', () => { - const wrapper = mountComponent(); - const titles = wrapper.findAll('[data-testid="stepper-title"]'); - const descriptions = wrapper.findAll('[data-testid="stepper-description"]'); - - expect(titles).toHaveLength(3); - expect(descriptions).toHaveLength(3); - - expect(titles[0].text()).toBe('Create Device Password'); - expect(descriptions[0].text()).toBe('Secure your device'); - - expect(titles[1].text()).toBe('Activate License'); - expect(descriptions[1].text()).toBe('Create an Unraid.net account and activate your key'); - - expect(titles[2].text()).toBe('Unleash Your Hardware'); - expect(descriptions[2].text()).toBe('Device is ready to configure'); - }); - - it('uses default activeStep of 1 when not provided', () => { - const wrapper = mountComponent(); - - expect(wrapper.find('[data-testid="stepper"]').attributes('default-value')).toBe('1'); - }); - - it('uses provided activeStep value', () => { - const wrapper = mountComponent({ activeStep: 2 }); - - expect(wrapper.find('[data-testid="stepper"]').attributes('default-value')).toBe('2'); - }); -}); diff --git a/web/__test__/components/Activation/WelcomeModal.test.ts b/web/__test__/components/Activation/WelcomeModal.test.ts deleted file mode 100644 index badb0fd7c3..0000000000 --- a/web/__test__/components/Activation/WelcomeModal.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * WelcomeModal Component Test Coverage - */ - -import { ref } from 'vue'; -import { mount } from '@vue/test-utils'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { ComposerTranslation } from 'vue-i18n'; - -import WelcomeModal from '~/components/Activation/WelcomeModal.standalone.vue'; -import { testTranslate } from '../../utils/i18n'; - -vi.mock('@unraid/ui', async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - Dialog: { - name: 'Dialog', - props: ['modelValue', 'title', 'description', 'showFooter', 'size', 'showCloseButton'], - emits: ['update:modelValue'], - template: ` -
-
-
- -
- `, - }, - BrandButton: { - name: 'BrandButton', - props: ['text', 'disabled'], - emits: ['click'], - template: '', - }, - }; -}); - -const mockT = testTranslate; - -const mockComponents = { - ActivationPartnerLogo: { - template: '
', - props: ['partnerInfo'], - }, - ActivationSteps: { - template: '
', - props: ['activeStep'], - }, -}; - -const mockWelcomeModalDataStore = { - partnerInfo: ref({ - hasPartnerLogo: false, - partnerName: null as string | null, - }), - loading: ref(false), - isInitialSetup: ref(true), // Default to true for testing -}; - -const mockThemeStore = { - setTheme: vi.fn(), -}; - -vi.mock('vue-i18n', () => ({ - useI18n: () => ({ - t: mockT, - }), -})); - -vi.mock('~/components/Activation/store/welcomeModalData', () => ({ - useWelcomeModalDataStore: () => mockWelcomeModalDataStore, -})); - -vi.mock('~/store/theme', () => ({ - useThemeStore: () => mockThemeStore, -})); - -describe('Activation/WelcomeModal.standalone.vue', () => { - let mockSetProperty: ReturnType; - let mockQuerySelector: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - mockWelcomeModalDataStore.partnerInfo.value = { - hasPartnerLogo: false, - partnerName: null, - }; - mockWelcomeModalDataStore.loading.value = false; - mockWelcomeModalDataStore.isInitialSetup.value = true; - - // Mock document methods - mockSetProperty = vi.fn(); - mockQuerySelector = vi.fn(); - Object.defineProperty(window.document, 'querySelector', { - value: mockQuerySelector, - writable: true, - }); - Object.defineProperty(window.document.documentElement.style, 'setProperty', { - value: mockSetProperty, - writable: true, - }); - - // Mock window.location.pathname to simulate being on /login page - Object.defineProperty(window, 'location', { - value: { - pathname: '/login', - }, - writable: true, - }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - const mountComponent = async () => { - const wrapper = mount(WelcomeModal, { - props: { t: mockT as unknown as ComposerTranslation }, - global: { - stubs: mockComponents, - }, - }); - await wrapper.vm.$nextTick(); - return wrapper; - }; - - it('uses the correct title text when no partner name is provided', async () => { - const wrapper = await mountComponent(); - - expect(wrapper.find('h1').text()).toBe(testTranslate('activation.welcomeModal.welcomeToUnraid')); - }); - - it('uses the correct title text when partner name is provided', async () => { - mockWelcomeModalDataStore.partnerInfo.value = { - hasPartnerLogo: true, - partnerName: 'Test Partner', - }; - const wrapper = await mountComponent(); - - expect(wrapper.find('h1').text()).toBe( - testTranslate('activation.welcomeModal.welcomeToYourNewSystemPowered', ['Test Partner']) - ); - }); - - it('uses the correct description text', async () => { - const wrapper = await mountComponent(); - - const description = testTranslate('activation.welcomeModal.firstYouLlCreateYourDevice'); - expect(wrapper.text()).toContain(description); - }); - - it('displays the partner logo when available', async () => { - mockWelcomeModalDataStore.partnerInfo.value = { - hasPartnerLogo: true, - partnerName: 'Test Partner', - }; - const wrapper = await mountComponent(); - - const partnerLogo = wrapper.find('[data-testid="partner-logo"]'); - expect(partnerLogo.exists()).toBe(true); - }); - - it('hides modal when Create a password button is clicked', async () => { - const wrapper = await mountComponent(); - const button = wrapper.find('button'); - - expect(button.exists()).toBe(true); - - // Initially dialog should be visible - const dialog = wrapper.findComponent({ name: 'Dialog' }); - expect(dialog.exists()).toBe(true); - expect(dialog.props('modelValue')).toBe(true); - - await button.trigger('click'); - await wrapper.vm.$nextTick(); - - // After click, the dialog should be hidden - check if the dialog div is no longer rendered - const dialogDiv = wrapper.find('[role="dialog"]'); - expect(dialogDiv.exists()).toBe(false); - }); - - it('disables the Create a password button when loading', async () => { - mockWelcomeModalDataStore.loading.value = true; - - const wrapper = await mountComponent(); - const button = wrapper.find('button'); - - expect(button.exists()).toBe(true); - expect(button.attributes('disabled')).toBeDefined(); - }); - - it('renders activation steps with correct active step', async () => { - const wrapper = await mountComponent(); - - const activationSteps = wrapper.find('[data-testid="activation-steps"]'); - expect(activationSteps.exists()).toBe(true); - expect(activationSteps.attributes('active-step')).toBe('1'); - }); - - it('calls setTheme on mount', () => { - mountComponent(); - - expect(mockThemeStore.setTheme).toHaveBeenCalled(); - }); - - it('handles theme setting error gracefully', async () => { - vi.useFakeTimers(); - - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - mockThemeStore.setTheme.mockRejectedValueOnce(new Error('Theme error')); - mountComponent(); - - await vi.runAllTimersAsync(); - - expect(consoleErrorSpy).toHaveBeenCalledWith('Error setting theme:', expect.any(Error)); - - consoleErrorSpy.mockRestore(); - vi.useRealTimers(); - }); - - it('shows modal on login page even when isInitialSetup is false', async () => { - Object.defineProperty(window, 'location', { - value: { pathname: '/login' }, - writable: true, - }); - mockWelcomeModalDataStore.isInitialSetup.value = false; - - const wrapper = await mountComponent(); - const dialog = wrapper.findComponent({ name: 'Dialog' }); - - expect(dialog.exists()).toBe(true); - }); - - it('shows modal on non-login page when isInitialSetup is true', async () => { - Object.defineProperty(window, 'location', { - value: { pathname: '/Dashboard' }, - writable: true, - }); - mockWelcomeModalDataStore.isInitialSetup.value = true; - - const wrapper = await mountComponent(); - const dialog = wrapper.findComponent({ name: 'Dialog' }); - - expect(dialog.exists()).toBe(true); - }); - - it('does not show modal on non-login page when isInitialSetup is false', async () => { - Object.defineProperty(window, 'location', { - value: { pathname: '/Dashboard' }, - writable: true, - }); - mockWelcomeModalDataStore.isInitialSetup.value = false; - - const wrapper = await mountComponent(); - const dialog = wrapper.findComponent({ name: 'Dialog' }); - - expect(dialog.exists()).toBe(true); - expect(dialog.props('modelValue')).toBe(false); - }); - - describe('Modal properties', () => { - it('shows close button when on /login page', async () => { - Object.defineProperty(window, 'location', { - value: { pathname: '/login' }, - writable: true, - }); - - const wrapper = await mountComponent(); - const dialog = wrapper.findComponent({ name: 'Dialog' }); - - expect(dialog.exists()).toBe(true); - expect(dialog.props('showCloseButton')).toBe(true); - }); - - it('hides close button when NOT on /login page', async () => { - // Set location to a non-login page - Object.defineProperty(window, 'location', { - value: { pathname: '/Dashboard' }, - writable: true, - }); - - const wrapper = mount(WelcomeModal, { - props: { t: mockT as unknown as ComposerTranslation }, - global: { - stubs: mockComponents, - }, - }); - - // Manually show the modal since it won't auto-show on non-login pages - wrapper.vm.showWelcomeModal(); - await wrapper.vm.$nextTick(); - - const dialog = wrapper.findComponent({ name: 'Dialog' }); - expect(dialog.exists()).toBe(true); - expect(dialog.props('showCloseButton')).toBe(false); - }); - - it('passes correct props to Dialog component', async () => { - const wrapper = await mountComponent(); - const dialog = wrapper.findComponent({ name: 'Dialog' }); - - expect(dialog.exists()).toBe(true); - expect(dialog.props()).toMatchObject({ - modelValue: true, - showFooter: false, - showCloseButton: true, - size: 'full', - }); - }); - - it('renders modal with correct content', async () => { - const wrapper = await mountComponent(); - - // Check that the modal is rendered - const dialog = wrapper.findComponent({ name: 'Dialog' }); - expect(dialog.exists()).toBe(true); - expect(wrapper.text()).toContain('Welcome to Unraid!'); - expect(wrapper.text()).toContain('Create a password'); - }); - }); -}); diff --git a/web/__test__/components/Auth.test.ts b/web/__test__/components/Auth.test.ts index 08bae9da60..eebfdea084 100644 --- a/web/__test__/components/Auth.test.ts +++ b/web/__test__/components/Auth.test.ts @@ -51,7 +51,7 @@ vi.mock('~/store/activationCode', () => ({ })), })); -vi.mock('~/components/Activation/store/activationCodeData', () => ({ +vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ useActivationCodeDataStore: () => ({ loading: ref(false), activationCode: ref(null), diff --git a/web/__test__/components/Modals.test.ts b/web/__test__/components/Modals.test.ts index a46acdb5b2..6102a7e300 100644 --- a/web/__test__/components/Modals.test.ts +++ b/web/__test__/components/Modals.test.ts @@ -13,11 +13,11 @@ import { useTrialStore } from '~/store/trial'; import { useUpdateOsStore } from '~/store/updateOs'; // Mock child components -vi.mock('~/components/Activation/ActivationModal.vue', () => ({ +vi.mock('~/components/Onboarding/OnboardingModal.vue', () => ({ default: { - name: 'ActivationModal', + name: 'OnboardingModal', props: [], - template: '
ActivationModal
', + template: '
OnboardingModal
', }, })); @@ -91,7 +91,7 @@ describe('Modals.standalone.vue', () => { expect(wrapper.findComponent({ name: 'UpcTrial' }).exists()).toBe(true); expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).exists()).toBe(true); expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).exists()).toBe(true); - expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'OnboardingModal' }).exists()).toBe(true); }); it('should pass correct props to CallbackFeedback based on store state', async () => { @@ -227,6 +227,6 @@ describe('Modals.standalone.vue', () => { const modalsDiv = wrapper.find('#modals'); expect(modalsDiv.exists()).toBe(true); // Container should still exist - expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true); + expect(wrapper.findComponent({ name: 'OnboardingModal' }).exists()).toBe(true); }); }); diff --git a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts new file mode 100644 index 0000000000..feb395ca56 --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts @@ -0,0 +1,802 @@ +import { flushPromises, mount } from '@vue/test-utils'; + +import { GET_AVAILABLE_LANGUAGES_QUERY } from '@/components/Onboarding/graphql/availableLanguages.query'; +import { GET_CORE_SETTINGS_QUERY } from '@/components/Onboarding/graphql/getCoreSettings.query'; +import { TIME_ZONE_OPTIONS_QUERY } from '@/components/Onboarding/graphql/timeZoneOptions.query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import OnboardingCoreSettingsStep from '~/components/Onboarding/steps/OnboardingCoreSettingsStep.vue'; +import { createTestI18n } from '../../utils/i18n'; + +const { + draftStore, + onboardingStore, + setCoreSettingsMock, + timeZoneOptionsResult, + languagesResult, + languagesLoading, + languagesError, + coreOnResultHandlers, + coreSettingsResult, + useQueryMock, +} = vi.hoisted(() => ({ + setCoreSettingsMock: vi.fn(), + draftStore: { + serverName: '', + serverDescription: '', + selectedTimeZone: '', + selectedTheme: '', + selectedLanguage: '', + useSsh: false, + coreSettingsInitialized: false, + setCoreSettings: vi.fn(), + }, + onboardingStore: { + completed: { value: true, __v_isRef: true }, + loading: { value: false, __v_isRef: true }, + }, + timeZoneOptionsResult: { + value: { + timeZoneOptions: [ + { value: 'UTC', label: 'UTC' }, + { value: 'America/New_York', label: 'America/New_York' }, + ], + }, + }, + languagesResult: { + value: { + customization: { + availableLanguages: [ + { code: 'en_US', name: 'English', url: 'https://example.com/en_US.txz' }, + { code: 'fr_FR', name: 'French', url: 'https://example.com/fr_FR.txz' }, + ], + }, + }, + }, + languagesLoading: { value: false }, + languagesError: { value: null as unknown }, + coreOnResultHandlers: [] as Array<(payload: unknown) => void>, + coreSettingsResult: { value: null as unknown }, + useQueryMock: vi.fn(), +})); + +draftStore.setCoreSettings = setCoreSettingsMock; + +vi.mock('@unraid/ui', () => ({ + BrandButton: { + props: ['text', 'disabled'], + emits: ['click'], + template: + '', + }, + Select: { + props: ['modelValue', 'items', 'disabled'], + emits: ['update:modelValue'], + template: ` + + `, + }, +})); + +vi.mock('@headlessui/vue', () => ({ + Switch: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: ` + + `, + }, +})); + +vi.mock('@vvo/tzdb', () => ({ + getTimeZones: () => [ + { + name: 'UTC', + alternativeName: 'UTC', + currentTimeOffsetInMinutes: 0, + group: ['UTC'], + }, + { + name: 'America/New_York', + alternativeName: 'Eastern Time', + currentTimeOffsetInMinutes: -300, + group: ['America/New_York'], + }, + ], +})); + +vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({ + useOnboardingDraftStore: () => draftStore, +})); + +vi.mock('@/components/Onboarding/store/upgradeOnboarding', () => ({ + useUpgradeOnboardingStore: () => onboardingStore, +})); + +vi.mock('@vue/apollo-composable', async () => { + const actual = + await vi.importActual('@vue/apollo-composable'); + return { + ...actual, + useQuery: useQueryMock, + }; +}); + +const setupQueryMocks = () => { + useQueryMock.mockImplementation((doc: unknown) => { + if (doc === TIME_ZONE_OPTIONS_QUERY) { + return { result: timeZoneOptionsResult }; + } + if (doc === GET_CORE_SETTINGS_QUERY) { + return { + result: coreSettingsResult, + onResult: (cb: (payload: unknown) => void) => { + coreOnResultHandlers.push((payload: unknown) => { + const candidate = payload as { data?: unknown }; + coreSettingsResult.value = candidate.data ?? null; + cb(payload); + }); + }, + }; + } + if (doc === GET_AVAILABLE_LANGUAGES_QUERY) { + return { + result: languagesResult, + loading: languagesLoading, + error: languagesError, + }; + } + return { result: { value: null } }; + }); +}; + +const mountComponent = (props: Record = {}) => { + const onComplete = vi.fn(); + const wrapper = mount(OnboardingCoreSettingsStep, { + props: { + onComplete, + showBack: true, + ...props, + }, + global: { + plugins: [createTestI18n()], + }, + }); + + return { wrapper, onComplete }; +}; + +describe('OnboardingCoreSettingsStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupQueryMocks(); + coreOnResultHandlers.length = 0; + + draftStore.serverName = ''; + draftStore.serverDescription = ''; + draftStore.selectedTimeZone = ''; + draftStore.selectedTheme = ''; + draftStore.selectedLanguage = ''; + draftStore.useSsh = false; + draftStore.coreSettingsInitialized = false; + onboardingStore.completed.value = true; + onboardingStore.loading.value = false; + coreSettingsResult.value = null; + + languagesLoading.value = false; + languagesError.value = null; + }); + + it('prefers browser timezone over API on initial setup when draft timezone is empty', async () => { + onboardingStore.completed.value = false; + + const dateTimeFormatSpy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation( + () => + ({ + resolvedOptions: () => ({ timeZone: 'America/New_York' }), + }) as Intl.DateTimeFormat + ); + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'Tower', comment: '' }, + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0].timeZone).toBe('America/New_York'); + expect(onComplete).toHaveBeenCalledTimes(1); + dateTimeFormatSpy.mockRestore(); + }); + + it('prefers non-empty draft timezone over browser and API on initial setup', async () => { + onboardingStore.completed.value = false; + draftStore.selectedTimeZone = 'UTC'; + + const dateTimeFormatSpy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation( + () => + ({ + resolvedOptions: () => ({ timeZone: 'America/New_York' }), + }) as Intl.DateTimeFormat + ); + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'Tower', comment: '' }, + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'America/New_York' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0].timeZone).toBe('UTC'); + expect(onComplete).toHaveBeenCalledTimes(1); + dateTimeFormatSpy.mockRestore(); + }); + + it('prefers API timezone over browser when onboarding was already completed', async () => { + onboardingStore.completed.value = true; + + const dateTimeFormatSpy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation( + () => + ({ + resolvedOptions: () => ({ timeZone: 'America/New_York' }), + }) as Intl.DateTimeFormat + ); + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'Tower', comment: '' }, + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0].timeZone).toBe('UTC'); + expect(onComplete).toHaveBeenCalledTimes(1); + dateTimeFormatSpy.mockRestore(); + }); + + it('keeps existing timezone while onboarding tracker is still loading', async () => { + onboardingStore.loading.value = true; + onboardingStore.completed.value = false; + + const dateTimeFormatSpy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation( + () => + ({ + resolvedOptions: () => ({ timeZone: 'America/New_York' }), + }) as Intl.DateTimeFormat + ); + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'Tower', comment: '' }, + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0].timeZone).toBe('America/New_York'); + expect(onComplete).toHaveBeenCalledTimes(1); + dateTimeFormatSpy.mockRestore(); + }); + + it('prefers activation identity on initial setup when activation metadata exists', async () => { + onboardingStore.completed.value = false; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + customization: { + activationCode: { + system: { serverName: 'Storinator45', comment: 'Primary storage node' }, + }, + }, + server: { name: 'Tower', comment: 'Media server' }, + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({ + serverName: 'Storinator45', + serverDescription: 'Primary storage node', + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('keeps initial-setup description empty when activation comment is missing', async () => { + onboardingStore.completed.value = false; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + customization: { + activationCode: { + system: { serverName: 'Storinator45' }, + }, + }, + server: { name: 'Tower', comment: 'Media server' }, + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({ + serverName: 'Storinator45', + serverDescription: '', + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('uses API identity first on returning setup and falls back to activation fields', async () => { + onboardingStore.completed.value = true; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + customization: { + activationCode: { + system: { serverName: 'Storinator45', comment: 'Primary storage node' }, + }, + }, + server: { name: '', comment: '' }, + vars: { name: 'TowerFromVars', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({ + serverName: 'TowerFromVars', + serverDescription: 'Primary storage node', + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('uses API identity while onboarding tracker state is still loading', async () => { + onboardingStore.loading.value = true; + onboardingStore.completed.value = false; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + customization: { + activationCode: { + system: { serverName: 'Storinator45', comment: 'Partner-provided comment' }, + }, + }, + server: { name: 'TowerFromServer', comment: 'Comment from API' }, + vars: { name: 'TowerFromVars', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({ + serverName: 'TowerFromServer', + serverDescription: 'Comment from API', + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('keeps server.name ahead of vars.name and activation system name on returning setup', async () => { + onboardingStore.completed.value = true; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + customization: { + activationCode: { + system: { serverName: 'Storinator45', comment: 'Partner-provided comment' }, + }, + }, + server: { name: 'TowerFromServer', comment: '' }, + vars: { name: 'TowerFromVars', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({ + serverName: 'TowerFromServer', + serverDescription: 'Partner-provided comment', + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('keeps API comment ahead of activation comment on returning setup', async () => { + onboardingStore.completed.value = true; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + customization: { + activationCode: { + system: { serverName: 'Storinator45', comment: 'Partner-provided comment' }, + }, + }, + server: { name: 'TowerFromServer', comment: 'Comment from API' }, + vars: { name: 'TowerFromVars', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({ + serverName: 'TowerFromServer', + serverDescription: 'Comment from API', + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('uses API identity on initial setup when activation system metadata is missing', async () => { + onboardingStore.completed.value = false; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + customization: { + activationCode: {}, + }, + server: { name: 'TowerFromServer', comment: 'Comment from API' }, + vars: { name: 'TowerFromVars', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({ + serverName: 'TowerFromServer', + serverDescription: 'Comment from API', + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('uses trusted defaults when API baseline is unavailable', async () => { + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); + const payload = setCoreSettingsMock.mock.calls[0][0]; + expect(payload.serverName).toBe('Tower'); + expect(payload.serverDescription).toBe(''); + expect(payload.theme).toBe('white'); + expect(payload.language).toBe('en_US'); + expect(typeof payload.timeZone).toBe('string'); + expect(payload.timeZone.length).toBeGreaterThan(0); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('blocks submission with invalid server name', async () => { + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'bad name!', comment: '' }, + vars: { name: 'bad name!', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('blocks submission with too-long server description', async () => { + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'Tower', comment: 'x'.repeat(65) }, + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('blocks submission with invalid server description characters', async () => { + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'Tower', comment: 'bad "desc' }, + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('submits valid values to draft store', async () => { + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'Tower2', comment: 'Primary host' }, + vars: { name: 'Tower2', useSsh: true, localTld: 'local' }, + display: { theme: 'black', locale: 'fr_FR' }, + systemTime: { timeZone: 'America/New_York' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledWith({ + serverName: 'Tower2', + serverDescription: 'Primary host', + timeZone: 'America/New_York', + theme: 'black', + language: 'fr_FR', + useSsh: true, + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('preserves intentionally empty draft description when baseline data is loaded', async () => { + draftStore.coreSettingsInitialized = true; + draftStore.serverName = 'Tower2'; + draftStore.serverDescription = ''; + draftStore.selectedTimeZone = 'UTC'; + draftStore.selectedTheme = 'white'; + draftStore.selectedLanguage = 'en_US'; + draftStore.useSsh = false; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'Tower2', comment: 'Should not override draft empty' }, + vars: { name: 'Tower2', useSsh: false, localTld: 'local' }, + display: { theme: 'black', locale: 'fr_FR' }, + systemTime: { timeZone: 'America/New_York' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledWith({ + serverName: 'Tower2', + serverDescription: '', + timeZone: 'UTC', + theme: 'white', + language: 'en_US', + useSsh: false, + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('preserves initialized draft values for timezone/theme/language even when empty', async () => { + draftStore.coreSettingsInitialized = true; + draftStore.serverName = 'Tower2'; + draftStore.serverDescription = ''; + draftStore.selectedTimeZone = ''; + draftStore.selectedTheme = ''; + draftStore.selectedLanguage = ''; + draftStore.useSsh = true; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'TowerBaseline', comment: 'Baseline comment' }, + vars: { name: 'TowerBaseline', useSsh: false, localTld: 'local' }, + display: { theme: 'black', locale: 'fr_FR' }, + systemTime: { timeZone: 'America/New_York' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).toHaveBeenCalledWith({ + serverName: 'Tower2', + serverDescription: '', + timeZone: '', + theme: '', + language: '', + useSsh: true, + }); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('keeps initialized empty server name invalid even if baseline has a valid name', async () => { + draftStore.coreSettingsInitialized = true; + draftStore.serverName = ''; + draftStore.serverDescription = ''; + draftStore.selectedTimeZone = 'UTC'; + draftStore.selectedTheme = 'white'; + draftStore.selectedLanguage = 'en_US'; + draftStore.useSsh = false; + + const { wrapper, onComplete } = mountComponent(); + await flushPromises(); + + const coreOnResult = coreOnResultHandlers[0]; + coreOnResult({ + data: { + server: { name: 'TowerBaseline', comment: '' }, + vars: { name: 'TowerBaseline', useSsh: false, localTld: 'local' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + }, + }); + await flushPromises(); + + const submitButton = wrapper.find('[data-testid="brand-button"]'); + await submitButton.trigger('click'); + await flushPromises(); + + expect(setCoreSettingsMock).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts b/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts new file mode 100644 index 0000000000..4c2a2020c2 --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingLicenseStep.test.ts @@ -0,0 +1,177 @@ +import { mount } from '@vue/test-utils'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import OnboardingLicenseStep from '~/components/Onboarding/steps/OnboardingLicenseStep.vue'; +import { createTestI18n } from '../../utils/i18n'; + +const { serverStoreMock, activationStoreMock } = vi.hoisted(() => ({ + serverStoreMock: { + state: { value: 'ENOKEYFILE' }, + refreshServerState: vi.fn(), + }, + activationStoreMock: { + activationCode: { value: { code: 'TEST-GUID-123' } }, + registrationState: { value: 'ENOKEYFILE' }, + hasActivationCode: { value: true }, + }, +})); + +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store: Record) => store, + }; +}); + +vi.mock('~/store/server', () => ({ + useServerStore: () => serverStoreMock, +})); + +vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ + useActivationCodeDataStore: () => activationStoreMock, +})); + +vi.mock('@unraid/ui', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + BrandButton: { + props: ['text', 'iconRight', 'disabled'], + emits: ['click'], + template: + '', + }, + isDarkModeActive: vi.fn(() => false), + }; +}); + +vi.mock('@heroicons/vue/24/solid', () => { + const icons = [ + 'ArrowPathIcon', + 'ArrowTopRightOnSquareIcon', + 'ChevronLeftIcon', + 'ChevronRightIcon', + 'ExclamationTriangleIcon', + 'EyeIcon', + 'EyeSlashIcon', + 'KeyIcon', + ]; + + return icons.reduce( + (acc, icon) => { + acc[icon] = { template: `${icon}` }; + return acc; + }, + {} as Record + ); +}); + +describe('OnboardingLicenseStep.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + serverStoreMock.state.value = 'ENOKEYFILE'; + activationStoreMock.registrationState.value = 'ENOKEYFILE'; + activationStoreMock.activationCode.value = { code: 'TEST-GUID-123' }; + + Object.defineProperty(window, 'location', { + writable: true, + configurable: true, + value: { + href: 'http://localhost/', + }, + }); + + vi.stubGlobal('open', vi.fn()); + }); + + const mountComponent = (props = {}) => { + return mount(OnboardingLicenseStep, { + global: { + plugins: [createTestI18n()], + }, + props: { + activateHref: 'https://unraid.net/activate', + activateExternal: true, + allowSkip: true, + showBack: true, + ...props, + }, + }); + }; + + it('renders unregistered status and activation code', () => { + const wrapper = mountComponent(); + + expect(wrapper.text()).toContain('Unregistered'); + expect(wrapper.text()).toContain('Activate Server'); + expect(wrapper.text()).toContain('Skip for now'); + }); + + it('renders registered state and manage button for valid license', () => { + activationStoreMock.registrationState.value = 'PRO'; + + const wrapper = mountComponent(); + + expect(wrapper.text()).toContain('Registered'); + expect(wrapper.text()).toContain('Manage License'); + }); + + it('opens activation link in new tab when activate button is clicked', async () => { + const windowOpenMock = vi.fn(); + vi.stubGlobal('open', windowOpenMock); + + const wrapper = mountComponent({ + activateHref: 'https://activation.url', + activateExternal: true, + }); + + const activateButton = wrapper.findAll('button').find((button) => { + return button.text().includes('Activate Server'); + }); + + expect(activateButton).toBeTruthy(); + await activateButton!.trigger('click'); + + expect(windowOpenMock).toHaveBeenCalledWith( + 'https://activation.url', + '_blank', + 'noopener,noreferrer' + ); + }); + + it('calls onComplete when skip is clicked', async () => { + const onComplete = vi.fn(); + const wrapper = mountComponent({ onComplete, allowSkip: true }); + + const skipButton = wrapper + .findAll('[data-testid="brand-button"]') + .find((button) => button.text().toLowerCase().includes('skip')); + + expect(skipButton).toBeTruthy(); + await skipButton!.trigger('click'); + await wrapper.vm.$nextTick(); + + const confirmSkipButton = wrapper + .findAll('[data-testid="brand-button"]') + .find((button) => button.text().toLowerCase().includes('understand')); + + expect(confirmSkipButton).toBeTruthy(); + await confirmSkipButton!.trigger('click'); + + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('calls onBack when back button is clicked', async () => { + const onBack = vi.fn(); + const wrapper = mountComponent({ onBack, showBack: true }); + + const backButton = wrapper.findAll('button').find((button) => button.text().includes('Back')); + + expect(backButton).toBeTruthy(); + await backButton!.trigger('click'); + + expect(onBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts new file mode 100644 index 0000000000..811375b70a --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts @@ -0,0 +1,310 @@ +import { reactive } from 'vue'; +import { flushPromises, mount } from '@vue/test-utils'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import OnboardingModal from '~/components/Onboarding/OnboardingModal.vue'; +import { createTestI18n } from '../../utils/i18n'; + +const { + mutateMock, + activationCodeModalStore, + activationCodeDataStore, + upgradeOnboardingStore, + onboardingDraftStore, + purchaseStore, + serverStore, + themeStore, + cleanupOnboardingStorageMock, +} = vi.hoisted(() => ({ + mutateMock: vi.fn().mockResolvedValue(undefined), + activationCodeModalStore: { + isVisible: { value: true }, + isTemporarilyBypassed: { value: false }, + setIsHidden: vi.fn(), + }, + activationCodeDataStore: { + activationRequired: { value: false }, + hasActivationCode: { value: true }, + registrationState: { value: 'ENOKEYFILE' }, + partnerInfo: { + value: { + partner: { name: null, url: null }, + branding: { hasPartnerLogo: false }, + }, + }, + isFreshInstall: { value: true }, + }, + upgradeOnboardingStore: { + shouldShowOnboarding: { value: false }, + isVersionDrift: { value: false }, + completedAtVersion: { value: null }, + canDisplayOnboardingModal: { value: true }, + refetchOnboarding: vi.fn().mockResolvedValue(undefined), + }, + onboardingDraftStore: { + currentStepIndex: { value: 0 }, + }, + purchaseStore: { + generateUrl: vi.fn(() => 'https://example.com/activate'), + openInNewTab: true, + }, + serverStore: { + keyfile: { value: null }, + }, + themeStore: { + fetchTheme: vi.fn().mockResolvedValue(undefined), + }, + cleanupOnboardingStorageMock: vi.fn(), +})); + +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store: Record) => store, + }; +}); + +vi.mock('@unraid/ui', () => ({ + Dialog: { + name: 'Dialog', + props: ['modelValue', 'showCloseButton', 'size'], + emits: ['update:modelValue'], + template: '
', + }, +})); + +vi.mock('@heroicons/vue/24/solid', () => ({ + ArrowTopRightOnSquareIcon: { template: '' }, + XMarkIcon: { template: '' }, +})); + +vi.mock('@vue/apollo-composable', () => ({ + useMutation: () => ({ + mutate: mutateMock, + }), +})); + +vi.mock('~/components/Onboarding/OnboardingSteps.vue', () => ({ + default: { + props: ['steps', 'activeStepIndex'], + template: '
', + }, +})); + +vi.mock('~/components/Onboarding/stepRegistry', () => ({ + stepComponents: { + OVERVIEW: { template: '
' }, + CONFIGURE_SETTINGS: { template: '
' }, + ADD_PLUGINS: { template: '
' }, + ACTIVATE_LICENSE: { template: '
' }, + SUMMARY: { template: '
' }, + NEXT_STEPS: { template: '
' }, + }, +})); + +vi.mock('~/components/Onboarding/store/activationCodeModal', () => ({ + useActivationCodeModalStore: () => reactive(activationCodeModalStore), +})); + +vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ + useActivationCodeDataStore: () => reactive(activationCodeDataStore), +})); + +vi.mock('~/components/Onboarding/store/upgradeOnboarding', () => ({ + useUpgradeOnboardingStore: () => reactive(upgradeOnboardingStore), +})); + +vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({ + useOnboardingDraftStore: () => reactive(onboardingDraftStore), +})); + +vi.mock('~/store/purchase', () => ({ + usePurchaseStore: () => purchaseStore, +})); + +vi.mock('~/store/server', () => ({ + useServerStore: () => reactive(serverStore), +})); + +vi.mock('~/store/theme', () => ({ + useThemeStore: () => themeStore, +})); + +vi.mock('~/components/Onboarding/store/onboardingStorageCleanup', () => ({ + cleanupOnboardingStorage: cleanupOnboardingStorageMock, +})); + +describe('OnboardingModal.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + + activationCodeModalStore.isVisible.value = true; + activationCodeModalStore.isTemporarilyBypassed.value = false; + activationCodeDataStore.activationRequired.value = false; + activationCodeDataStore.hasActivationCode.value = true; + activationCodeDataStore.registrationState.value = 'ENOKEYFILE'; + upgradeOnboardingStore.shouldShowOnboarding.value = false; + upgradeOnboardingStore.isVersionDrift.value = false; + upgradeOnboardingStore.completedAtVersion.value = null; + upgradeOnboardingStore.canDisplayOnboardingModal.value = true; + onboardingDraftStore.currentStepIndex.value = 0; + + Object.defineProperty(window, 'location', { + writable: true, + configurable: true, + value: { + href: '', + pathname: '/Dashboard', + }, + }); + }); + + const mountComponent = () => { + return mount(OnboardingModal, { + global: { + plugins: [createTestI18n()], + }, + }); + }; + + it('renders when modal is visible', () => { + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="onboarding-steps"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="overview-step"]').exists()).toBe(true); + }); + + it('does not render when modal is hidden and onboarding flag is false', () => { + activationCodeModalStore.isVisible.value = false; + upgradeOnboardingStore.shouldShowOnboarding.value = false; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false); + }); + + it('does not render when modal display is blocked', () => { + activationCodeModalStore.isVisible.value = true; + upgradeOnboardingStore.shouldShowOnboarding.value = true; + upgradeOnboardingStore.canDisplayOnboardingModal.value = false; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false); + }); + + it('does not render when temporary bypass is active', () => { + activationCodeModalStore.isVisible.value = true; + activationCodeModalStore.isTemporarilyBypassed.value = true; + upgradeOnboardingStore.shouldShowOnboarding.value = true; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false); + }); + + it('does not render on login route', () => { + Object.defineProperty(window, 'location', { + writable: true, + configurable: true, + value: { + href: '', + pathname: '/login', + }, + }); + + activationCodeModalStore.isVisible.value = true; + upgradeOnboardingStore.canDisplayOnboardingModal.value = true; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false); + }); + + it('shows activation step for ENOKEYFILE1', () => { + activationCodeDataStore.registrationState.value = 'ENOKEYFILE1'; + onboardingDraftStore.currentStepIndex.value = 3; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true); + }); + + it('shows activation step for ENOKEYFILE2', () => { + activationCodeDataStore.registrationState.value = 'ENOKEYFILE2'; + onboardingDraftStore.currentStepIndex.value = 3; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true); + }); + + it('omits activation step for non-activation registration states', () => { + activationCodeDataStore.registrationState.value = 'BASIC'; + onboardingDraftStore.currentStepIndex.value = 3; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="summary-step"]').exists()).toBe(true); + }); + + it('opens exit confirmation when close button is clicked', async () => { + const wrapper = mountComponent(); + + const closeButton = wrapper.find('button[aria-label="Close onboarding"]'); + expect(closeButton.exists()).toBe(true); + + await closeButton.trigger('click'); + + expect(wrapper.text()).toContain('Exit onboarding?'); + expect(wrapper.text()).toContain('Exit setup'); + }); + + it('confirms exit and completes onboarding flow when onboarding flag is enabled', async () => { + upgradeOnboardingStore.shouldShowOnboarding.value = true; + + const wrapper = mountComponent(); + + await wrapper.find('button[aria-label="Close onboarding"]').trigger('click'); + await flushPromises(); + + const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup')); + + expect(exitButton).toBeTruthy(); + await exitButton!.trigger('click'); + await flushPromises(); + + expect(mutateMock).toHaveBeenCalledTimes(1); + expect(upgradeOnboardingStore.refetchOnboarding).toHaveBeenCalledTimes(1); + expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith({ + clearTemporaryBypassSessionState: true, + }); + expect(activationCodeModalStore.setIsHidden).toHaveBeenCalledWith(true); + }); + + it('confirms exit without completion mutation when onboarding flag is disabled', async () => { + upgradeOnboardingStore.shouldShowOnboarding.value = false; + + const wrapper = mountComponent(); + + await wrapper.find('button[aria-label="Close onboarding"]').trigger('click'); + await flushPromises(); + + const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup')); + + expect(exitButton).toBeTruthy(); + await exitButton!.trigger('click'); + await flushPromises(); + + expect(mutateMock).not.toHaveBeenCalled(); + expect(upgradeOnboardingStore.refetchOnboarding).not.toHaveBeenCalled(); + expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith({ + clearTemporaryBypassSessionState: true, + }); + expect(activationCodeModalStore.setIsHidden).toHaveBeenCalledWith(true); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts b/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts new file mode 100644 index 0000000000..8e7ab5b4b9 --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts @@ -0,0 +1,143 @@ +import { mount } from '@vue/test-utils'; + +import limitlessImage from '@/assets/limitless_possibilities.jpg'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import OnboardingOverviewStep from '~/components/Onboarding/steps/OnboardingOverviewStep.vue'; +import { createTestI18n } from '../../utils/i18n'; + +const { + completeOnboardingMock, + refetchOnboardingMock, + partnerInfoRef, + isFreshInstallRef, + isUpgradeRef, + isDowngradeRef, + isIncompleteRef, + themeRef, +} = vi.hoisted(() => ({ + completeOnboardingMock: vi.fn().mockResolvedValue({}), + refetchOnboardingMock: vi.fn().mockResolvedValue({}), + partnerInfoRef: { + value: { + partner: { name: '45Drives' }, + branding: { + hasPartnerLogo: true, + partnerLogoLightUrl: 'data:image/png;base64,AAA=', + partnerLogoDarkUrl: 'data:image/png;base64,BBB=', + }, + }, + }, + isFreshInstallRef: { value: false }, + isUpgradeRef: { value: false }, + isDowngradeRef: { value: false }, + isIncompleteRef: { value: true }, + themeRef: { value: { name: 'azure' } }, +})); + +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store: Record) => store, + }; +}); + +vi.mock('@vue/apollo-composable', async () => { + const actual = + await vi.importActual('@vue/apollo-composable'); + return { + ...actual, + useMutation: () => ({ + mutate: completeOnboardingMock, + }), + }; +}); + +vi.mock('@unraid/ui', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + BrandButton: { + props: ['text', 'disabled', 'loading', 'iconRight'], + emits: ['click'], + template: + '', + }, + }; +}); + +vi.mock('@/components/Onboarding/store/activationCodeData', () => ({ + useActivationCodeDataStore: () => ({ + partnerInfo: partnerInfoRef, + isFreshInstall: isFreshInstallRef, + }), +})); + +vi.mock('@/components/Onboarding/store/upgradeOnboarding', () => ({ + useUpgradeOnboardingStore: () => ({ + isUpgrade: isUpgradeRef, + isDowngrade: isDowngradeRef, + isIncomplete: isIncompleteRef, + refetchOnboarding: refetchOnboardingMock, + }), +})); + +vi.mock('@/store/theme', () => ({ + useThemeStore: () => ({ + theme: themeRef, + }), +})); + +vi.mock('@/components/Onboarding/store/onboardingStorageCleanup', () => ({ + cleanupOnboardingStorage: vi.fn(), +})); + +describe('OnboardingOverviewStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + partnerInfoRef.value = { + partner: { name: '45Drives' }, + branding: { + hasPartnerLogo: true, + partnerLogoLightUrl: 'data:image/png;base64,AAA=', + partnerLogoDarkUrl: 'data:image/png;base64,BBB=', + }, + }; + isFreshInstallRef.value = false; + isUpgradeRef.value = false; + isDowngradeRef.value = false; + isIncompleteRef.value = true; + themeRef.value = { name: 'azure' }; + }); + + const mountComponent = () => + mount(OnboardingOverviewStep, { + props: { + onComplete: vi.fn(), + }, + global: { + plugins: [createTestI18n()], + }, + }); + + it('uses partner logo by default when partner branding is present', () => { + const wrapper = mountComponent(); + const img = wrapper.find('img'); + + expect(img.exists()).toBe(true); + expect(img.attributes('src')).toBe('data:image/png;base64,AAA='); + expect(img.attributes('alt')).toBe('45Drives'); + }); + + it('falls back to default overview image when partner logo fails to load', async () => { + const wrapper = mountComponent(); + const img = wrapper.find('img'); + + await img.trigger('error'); + + const updatedImg = wrapper.find('img'); + expect(updatedImg.attributes('src')).toBe(limitlessImage); + expect(updatedImg.attributes('alt')).toBe('Limitless Possibilities'); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingPartnerLogo.test.ts b/web/__test__/components/Onboarding/OnboardingPartnerLogo.test.ts new file mode 100644 index 0000000000..91c2005c19 --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingPartnerLogo.test.ts @@ -0,0 +1,60 @@ +import { mount } from '@vue/test-utils'; + +import { describe, expect, it } from 'vitest'; + +import OnboardingPartnerLogo from '~/components/Onboarding/components/OnboardingPartnerLogo.vue'; + +describe('OnboardingPartnerLogo', () => { + const mountComponent = (partnerInfo?: Record | null) => { + return mount(OnboardingPartnerLogo, { + props: { partnerInfo }, + global: { + stubs: { + OnboardingPartnerLogoImg: { + template: '
', + }, + }, + }, + }); + }; + + it('renders a link with partner logo when partner url exists', () => { + const wrapper = mountComponent({ + partner: { + url: 'https://example.com', + }, + branding: { + hasPartnerLogo: true, + }, + }); + + const link = wrapper.find('a'); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe('https://example.com'); + expect(link.attributes('target')).toBe('_blank'); + expect(link.attributes('rel')).toBe('noopener noreferrer'); + expect(wrapper.find('[data-testid="partner-logo-img"]').exists()).toBe(true); + expect(link.classes()).toContain('opacity-100'); + expect(link.classes()).toContain('hover:opacity-75'); + expect(link.classes()).toContain('focus:opacity-75'); + }); + + it('does not render logo/link when partner url is missing', () => { + const wrapper = mountComponent({ + partner: { + url: null, + }, + }); + + expect(wrapper.find('a').exists()).toBe(false); + expect(wrapper.find('[data-testid="partner-logo-img"]').exists()).toBe(false); + }); + + it('does not render when partner info is empty', () => { + const wrapper = mountComponent(null); + + expect(wrapper.find('a').exists()).toBe(false); + expect(wrapper.find('[data-testid="partner-logo-img"]').exists()).toBe(false); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingPartnerLogoImg.test.ts b/web/__test__/components/Onboarding/OnboardingPartnerLogoImg.test.ts new file mode 100644 index 0000000000..fb7eab5fc9 --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingPartnerLogoImg.test.ts @@ -0,0 +1,109 @@ +/** + * OnboardingPartnerLogoImg Component Test Coverage + */ + +import { ref } from 'vue'; +import { mount } from '@vue/test-utils'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import OnboardingPartnerLogoImg from '~/components/Onboarding/components/OnboardingPartnerLogoImg.vue'; + +const mockThemeStore = { + theme: ref({ name: 'white' }), +}; + +vi.mock('~/store/theme', () => ({ + useThemeStore: () => mockThemeStore, +})); + +describe('OnboardingPartnerLogoImg', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockThemeStore.theme.value = { name: 'white' }; + }); + + const mountComponent = (props = {}) => { + return mount(OnboardingPartnerLogoImg, { + props, + }); + }; + + it('renders the image when a partner light logo exists', () => { + const wrapper = mountComponent({ + partnerInfo: { + branding: { + partnerLogoLightUrl: 'https://example.com/logo-light.png', + hasPartnerLogo: true, + }, + }, + }); + const img = wrapper.find('img'); + + expect(img.exists()).toBe(true); + expect(img.attributes('src')).toBe('https://example.com/logo-light.png'); + expect(img.classes()).toContain('w-72'); + }); + + it('does not render when partnerLogoUrl is null', () => { + const wrapper = mountComponent({ + partnerInfo: { + branding: { + partnerLogoLightUrl: null, + partnerLogoDarkUrl: null, + hasPartnerLogo: false, + }, + }, + }); + const img = wrapper.find('img'); + + expect(img.exists()).toBe(false); + }); + + it('uses dark logo when theme is dark', () => { + mockThemeStore.theme.value = { name: 'gray' }; + const wrapper = mountComponent({ + partnerInfo: { + branding: { + partnerLogoLightUrl: 'https://example.com/logo-light.png', + partnerLogoDarkUrl: 'https://example.com/logo-dark.png', + hasPartnerLogo: true, + }, + }, + }); + const img = wrapper.find('img'); + + expect(img.attributes('src')).toBe('https://example.com/logo-dark.png'); + }); + + it('uses light logo when theme is light', () => { + mockThemeStore.theme.value = { name: 'azure' }; + const wrapper = mountComponent({ + partnerInfo: { + branding: { + partnerLogoLightUrl: 'https://example.com/logo-light.png', + partnerLogoDarkUrl: 'https://example.com/logo-dark.png', + hasPartnerLogo: true, + }, + }, + }); + const img = wrapper.find('img'); + + expect(img.attributes('src')).toBe('https://example.com/logo-light.png'); + }); + + it('falls back to light logo when dark logo is missing', () => { + mockThemeStore.theme.value = { name: 'black' }; + const wrapper = mountComponent({ + partnerInfo: { + branding: { + partnerLogoLightUrl: 'https://example.com/logo-light.png', + hasPartnerLogo: true, + }, + }, + }); + const img = wrapper.find('img'); + + expect(img.attributes('src')).toBe('https://example.com/logo-light.png'); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts new file mode 100644 index 0000000000..d9fe1e766b --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts @@ -0,0 +1,150 @@ +import { flushPromises, mount } from '@vue/test-utils'; + +import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import OnboardingPluginsStep from '~/components/Onboarding/steps/OnboardingPluginsStep.vue'; +import { createTestI18n } from '../../utils/i18n'; + +const { draftStore, installedPluginsResult, useQueryMock } = vi.hoisted(() => ({ + draftStore: { + selectedPlugins: new Set(), + pluginSelectionInitialized: false, + setPlugins: vi.fn(), + }, + installedPluginsResult: { + value: { + installedUnraidPlugins: [], + } as { installedUnraidPlugins: string[] } | null, + }, + useQueryMock: vi.fn(), +})); + +vi.mock('@unraid/ui', () => ({ + BrandButton: { + props: ['text', 'variant', 'disabled', 'loading'], + emits: ['click'], + template: + '', + }, +})); + +vi.mock('@headlessui/vue', () => ({ + Switch: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: ` + + `, + }, +})); + +vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({ + useOnboardingDraftStore: () => draftStore, +})); + +vi.mock('@vue/apollo-composable', async () => { + const actual = + await vi.importActual('@vue/apollo-composable'); + return { + ...actual, + useQuery: useQueryMock, + }; +}); + +describe('OnboardingPluginsStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + draftStore.selectedPlugins = new Set(); + draftStore.pluginSelectionInitialized = false; + installedPluginsResult.value = { + installedUnraidPlugins: [], + }; + + useQueryMock.mockImplementation((query: unknown) => { + if (query === INSTALLED_UNRAID_PLUGINS_QUERY) { + return { + result: installedPluginsResult, + }; + } + return { result: { value: null } }; + }); + }); + + const mountComponent = (overrides: Record = {}) => { + const props = { + onComplete: vi.fn(), + onBack: vi.fn(), + onSkip: vi.fn(), + showBack: true, + showSkip: true, + ...overrides, + }; + + return { + wrapper: mount(OnboardingPluginsStep, { + props, + global: { + plugins: [createTestI18n()], + }, + }), + props, + }; + }; + + it('defaults only essential plugins on first visit and persists selection on next', async () => { + const { wrapper, props } = mountComponent(); + + await flushPromises(); + + const switches = wrapper.findAll('[data-testid="plugin-switch"]'); + expect(switches.length).toBe(3); + expect((switches[0].element as HTMLInputElement).checked).toBe(true); + expect((switches[1].element as HTMLInputElement).checked).toBe(true); + expect((switches[2].element as HTMLInputElement).checked).toBe(false); + for (const pluginSwitch of switches) { + expect((pluginSwitch.element as HTMLInputElement).disabled).toBe(false); + } + + const nextButton = wrapper + .findAll('[data-testid="brand-button"]') + .find((button) => button.text().toLowerCase().includes('next')); + + expect(nextButton).toBeTruthy(); + await nextButton!.trigger('click'); + + expect(draftStore.setPlugins).toHaveBeenCalled(); + const lastCallIndex = draftStore.setPlugins.mock.calls.length - 1; + const selected = draftStore.setPlugins.mock.calls[lastCallIndex][0] as Set; + expect(Array.from(selected).sort()).toEqual(['community-apps', 'fix-common-problems'].sort()); + expect(props.onComplete).toHaveBeenCalledTimes(1); + }); + + it('skip clears selection and calls onSkip', async () => { + draftStore.pluginSelectionInitialized = true; + draftStore.selectedPlugins = new Set(['community-apps']); + + const { wrapper, props } = mountComponent(); + + await flushPromises(); + + const skipButton = wrapper + .findAll('button') + .find((button) => button.text().toLowerCase().includes('skip')); + + expect(skipButton).toBeTruthy(); + await skipButton!.trigger('click'); + + expect(draftStore.setPlugins).toHaveBeenCalledTimes(1); + const selected = draftStore.setPlugins.mock.calls[0][0] as Set; + expect(selected.size).toBe(0); + expect(props.onSkip).toHaveBeenCalledTimes(1); + expect(props.onComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts new file mode 100644 index 0000000000..ec963ed304 --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts @@ -0,0 +1,920 @@ +import { flushPromises, mount } from '@vue/test-utils'; + +import { GET_AVAILABLE_LANGUAGES_QUERY } from '@/components/Onboarding/graphql/availableLanguages.query'; +import { COMPLETE_ONBOARDING_MUTATION } from '@/components/Onboarding/graphql/completeUpgradeStep.mutation'; +import { + SET_LOCALE_MUTATION, + SET_THEME_MUTATION, + UPDATE_SERVER_IDENTITY_MUTATION, + UPDATE_SSH_SETTINGS_MUTATION, +} from '@/components/Onboarding/graphql/coreSettings.mutations'; +import { GET_CORE_SETTINGS_QUERY } from '@/components/Onboarding/graphql/getCoreSettings.query'; +import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query'; +import { UPDATE_SYSTEM_TIME_MUTATION } from '@/components/Onboarding/graphql/updateSystemTime.mutation'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import OnboardingSummaryStep from '~/components/Onboarding/steps/OnboardingSummaryStep.vue'; +import { PluginInstallStatus } from '~/composables/gql/graphql'; +import { createTestI18n } from '../../utils/i18n'; + +const { + draftStore, + registrationStateRef, + isFreshInstallRef, + activationCodeRef, + coreSettingsResult, + coreSettingsError, + installedPluginsResult, + availableLanguagesResult, + refetchInstalledPluginsMock, + refetchOnboardingMock, + setModalHiddenMock, + updateSystemTimeMock, + updateServerIdentityMock, + setThemeMock, + setLocaleMock, + updateSshSettingsMock, + completeOnboardingMock, + installLanguageMock, + installPluginMock, + useMutationMock, + useQueryMock, +} = vi.hoisted(() => ({ + draftStore: { + serverName: 'Tower', + serverDescription: '', + selectedTimeZone: 'UTC', + selectedTheme: 'white', + selectedLanguage: 'en_US', + useSsh: false, + selectedPlugins: new Set(), + }, + registrationStateRef: { value: 'ENOKEYFILE' }, + isFreshInstallRef: { value: true }, + activationCodeRef: { value: null as unknown }, + coreSettingsResult: { + value: null as unknown, + }, + coreSettingsError: { value: null as unknown }, + installedPluginsResult: { value: { installedUnraidPlugins: [] as string[] } }, + availableLanguagesResult: { + value: { + customization: { + availableLanguages: [ + { code: 'en_US', name: 'English', url: 'https://example.com/en_US.txz' }, + { code: 'fr_FR', name: 'French', url: 'https://example.com/fr_FR.txz' }, + ], + }, + }, + }, + refetchInstalledPluginsMock: vi.fn().mockResolvedValue(undefined), + refetchOnboardingMock: vi.fn().mockResolvedValue(undefined), + setModalHiddenMock: vi.fn(), + updateSystemTimeMock: vi.fn().mockResolvedValue({}), + updateServerIdentityMock: vi.fn().mockResolvedValue({}), + setThemeMock: vi.fn().mockResolvedValue({}), + setLocaleMock: vi.fn().mockResolvedValue({}), + updateSshSettingsMock: vi.fn().mockResolvedValue({}), + completeOnboardingMock: vi.fn().mockResolvedValue({}), + installLanguageMock: vi.fn(), + installPluginMock: vi.fn(), + useMutationMock: vi.fn(), + useQueryMock: vi.fn(), +})); + +vi.mock('pinia', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + storeToRefs: (store: Record) => store, + }; +}); + +vi.mock('@unraid/ui', () => ({ + BrandButton: { + props: ['text', 'disabled'], + emits: ['click'], + template: + '', + }, + Dialog: { + props: ['modelValue'], + template: '
', + }, +})); + +vi.mock('@headlessui/vue', () => ({ + Disclosure: { + template: '
', + }, + DisclosureButton: { + props: ['disabled'], + template: '', + }, + DisclosurePanel: { + template: '
', + }, +})); + +vi.mock('@/components/Onboarding/components/OnboardingConsole.vue', () => ({ + default: { + props: ['logs'], + template: '
{{ JSON.stringify(logs) }}
', + }, +})); + +vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({ + useOnboardingDraftStore: () => draftStore, +})); + +vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ + useActivationCodeDataStore: () => ({ + registrationState: registrationStateRef, + isFreshInstall: isFreshInstallRef, + activationCode: activationCodeRef, + }), +})); + +vi.mock('@/components/Onboarding/store/activationCodeModal', () => ({ + useActivationCodeModalStore: () => ({ + setIsHidden: setModalHiddenMock, + }), +})); + +vi.mock('@/components/Onboarding/store/upgradeOnboarding', () => ({ + useUpgradeOnboardingStore: () => ({ + refetchOnboarding: refetchOnboardingMock, + }), +})); + +vi.mock('@/components/Onboarding/composables/usePluginInstaller', () => ({ + INSTALL_OPERATION_TIMEOUT_CODE: 'INSTALL_OPERATION_TIMEOUT', + default: () => ({ + installLanguage: installLanguageMock, + installPlugin: installPluginMock, + }), +})); + +vi.mock('@vue/apollo-composable', async () => { + const actual = + await vi.importActual('@vue/apollo-composable'); + return { + ...actual, + useMutation: useMutationMock, + useQuery: useQueryMock, + }; +}); + +const setupApolloMocks = () => { + useMutationMock.mockImplementation((doc: unknown) => { + if (doc === UPDATE_SYSTEM_TIME_MUTATION) { + return { mutate: updateSystemTimeMock }; + } + if (doc === UPDATE_SERVER_IDENTITY_MUTATION) { + return { mutate: updateServerIdentityMock }; + } + if (doc === SET_THEME_MUTATION) { + return { mutate: setThemeMock }; + } + if (doc === SET_LOCALE_MUTATION) { + return { mutate: setLocaleMock }; + } + if (doc === UPDATE_SSH_SETTINGS_MUTATION) { + return { mutate: updateSshSettingsMock }; + } + if (doc === COMPLETE_ONBOARDING_MUTATION) { + return { mutate: completeOnboardingMock }; + } + return { mutate: vi.fn() }; + }); + + useQueryMock.mockImplementation((doc: unknown) => { + if (doc === GET_CORE_SETTINGS_QUERY) { + return { result: coreSettingsResult, error: coreSettingsError }; + } + if (doc === INSTALLED_UNRAID_PLUGINS_QUERY) { + return { + result: installedPluginsResult, + refetch: refetchInstalledPluginsMock, + }; + } + if (doc === GET_AVAILABLE_LANGUAGES_QUERY) { + return { result: availableLanguagesResult }; + } + return { result: { value: null } }; + }); +}; + +const mountComponent = (props: Record = {}) => { + const onComplete = vi.fn(); + const wrapper = mount(OnboardingSummaryStep, { + props: { + onComplete, + showBack: true, + ...props, + }, + global: { + plugins: [createTestI18n()], + }, + }); + + return { wrapper, onComplete }; +}; + +const clickApply = async (wrapper: ReturnType['wrapper']) => { + const buttons = wrapper.findAll('[data-testid="brand-button"]'); + const applyButton = buttons[buttons.length - 1]; + await applyButton.trigger('click'); + await flushPromises(); + await vi.runAllTimersAsync(); + await flushPromises(); +}; + +describe('OnboardingSummaryStep', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + setupApolloMocks(); + + draftStore.serverName = 'Tower'; + draftStore.serverDescription = ''; + draftStore.selectedTimeZone = 'UTC'; + draftStore.selectedTheme = 'white'; + draftStore.selectedLanguage = 'en_US'; + draftStore.useSsh = false; + draftStore.selectedPlugins = new Set(); + + registrationStateRef.value = 'ENOKEYFILE'; + isFreshInstallRef.value = true; + activationCodeRef.value = null; + coreSettingsError.value = null; + coreSettingsResult.value = { + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + server: { name: 'Tower', comment: '' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + info: { primaryNetwork: { ipAddress: '192.168.1.2' } }, + }; + installedPluginsResult.value = { installedUnraidPlugins: [] }; + availableLanguagesResult.value = { + customization: { + availableLanguages: [ + { code: 'en_US', name: 'English', url: 'https://example.com/en_US.txz' }, + { code: 'fr_FR', name: 'French', url: 'https://example.com/fr_FR.txz' }, + ], + }, + }; + + updateSystemTimeMock.mockResolvedValue({}); + updateServerIdentityMock.mockResolvedValue({}); + setThemeMock.mockResolvedValue({}); + setLocaleMock.mockResolvedValue({}); + updateSshSettingsMock.mockResolvedValue({}); + completeOnboardingMock.mockResolvedValue({}); + installLanguageMock.mockResolvedValue({ + operationId: 'lang-op', + status: PluginInstallStatus.SUCCEEDED, + output: [], + }); + installPluginMock.mockResolvedValue({ + operationId: 'plugin-op', + status: PluginInstallStatus.SUCCEEDED, + output: [], + }); + refetchInstalledPluginsMock.mockResolvedValue(undefined); + refetchOnboardingMock.mockResolvedValue(undefined); + }); + + it.each([ + { + caseName: 'skips install when plugin is already present', + apply: () => { + draftStore.selectedPlugins = new Set(['community-apps']); + installedPluginsResult.value = { installedUnraidPlugins: ['community.applications.plg'] }; + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(installPluginMock).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('Already installed'); + expect(wrapper.text()).toContain('Setup Applied'); + }, + }, + { + caseName: 'skips install when installed plugin matches after trim/lowercase normalization', + apply: () => { + draftStore.selectedPlugins = new Set(['community-apps']); + installedPluginsResult.value = { installedUnraidPlugins: [' COMMUNITY.APPLICATIONS.PLG '] }; + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(installPluginMock).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('Already installed'); + expect(wrapper.text()).toContain('Setup Applied'); + }, + }, + { + caseName: 'skips unknown plugin ids', + apply: () => { + draftStore.selectedPlugins = new Set(['unknown-plugin-id']); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(installPluginMock).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('Setup Applied'); + }, + }, + { + caseName: 'installs missing plugin successfully', + apply: () => { + draftStore.selectedPlugins = new Set(['community-apps']); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(installPluginMock).toHaveBeenCalledWith({ + url: 'https://raw.githubusercontent.com/unraid/community.applications/master/plugins/community.applications.plg', + name: 'Community Apps', + forced: false, + onEvent: expect.any(Function), + }); + expect(wrapper.text()).toContain('Community Apps installed.'); + }, + }, + { + caseName: 'marks warning when plugin install returns FAILED', + apply: () => { + draftStore.selectedPlugins = new Set(['community-apps']); + installPluginMock.mockResolvedValue({ + operationId: 'plugin-op', + status: PluginInstallStatus.FAILED, + output: ['failure'], + }); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(wrapper.text()).toContain('Community Apps installation failed. Continuing.'); + expect(wrapper.text()).toContain('Setup Applied with Warnings'); + }, + }, + { + caseName: 'marks timeout result when plugin tracking times out', + apply: () => { + draftStore.selectedPlugins = new Set(['community-apps']); + const timeoutError = new Error( + 'Timed out waiting for install operation plugin-op to finish' + ) as Error & { + code?: string; + }; + timeoutError.code = 'INSTALL_OPERATION_TIMEOUT'; + installPluginMock.mockRejectedValue(timeoutError); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(wrapper.text()).toContain('Setup Continued After Timeout'); + expect(wrapper.text()).toContain( + 'One or more install operations timed out. Some settings may have been applied.' + ); + }, + }, + ])('follows plugin install decision matrix ($caseName)', async (scenario) => { + scenario.apply(); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + scenario.assertExpected(wrapper); + }); + + it.each([ + { + caseName: 'switches to en_US directly without language pack install', + apply: () => { + coreSettingsResult.value = { + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + server: { name: 'Tower', comment: '' }, + display: { theme: 'white', locale: 'fr_FR' }, + systemTime: { timeZone: 'UTC' }, + info: { primaryNetwork: { ipAddress: '192.168.1.2' } }, + }; + draftStore.selectedLanguage = 'en_US'; + }, + assertExpected: () => { + expect(installLanguageMock).not.toHaveBeenCalled(); + expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' }); + }, + }, + { + caseName: 'installs language pack then sets locale', + apply: () => { + draftStore.selectedLanguage = 'fr_FR'; + }, + assertExpected: () => { + expect(installLanguageMock).toHaveBeenCalledWith({ + forced: false, + name: 'French', + url: 'https://example.com/fr_FR.txz', + }); + expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'fr_FR' }); + }, + }, + { + caseName: 'skips locale change when language metadata is missing', + apply: () => { + draftStore.selectedLanguage = 'fr_FR'; + availableLanguagesResult.value = { + customization: { + availableLanguages: [ + { code: 'en_US', name: 'English', url: 'https://example.com/en_US.txz' }, + ], + }, + }; + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(installLanguageMock).not.toHaveBeenCalled(); + expect(setLocaleMock).not.toHaveBeenCalledWith({ locale: 'fr_FR' }); + expect(wrapper.text()).toContain( + 'Language pack metadata for fr_FR is unavailable. Skipping locale change.' + ); + expect(wrapper.text()).toContain('Setup Applied with Warnings'); + }, + }, + { + caseName: 'keeps locale when language install returns FAILED', + apply: () => { + draftStore.selectedLanguage = 'fr_FR'; + installLanguageMock.mockResolvedValue({ + operationId: 'lang-op', + status: PluginInstallStatus.FAILED, + output: ['failed'], + }); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(installLanguageMock).toHaveBeenCalled(); + expect(setLocaleMock).not.toHaveBeenCalledWith({ locale: 'fr_FR' }); + expect(wrapper.text()).toContain( + 'Language pack installation did not succeed for French. Keeping current locale.' + ); + expect(wrapper.text()).toContain('Setup Applied with Warnings'); + }, + }, + { + caseName: 'keeps locale when language installer returns malformed payload', + apply: () => { + draftStore.selectedLanguage = 'fr_FR'; + installLanguageMock.mockResolvedValue({ + operationId: 'lang-op', + output: ['missing status'], + }); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(installLanguageMock).toHaveBeenCalled(); + expect(setLocaleMock).not.toHaveBeenCalledWith({ locale: 'fr_FR' }); + expect(wrapper.text()).toContain( + 'Language pack installation did not succeed for French. Keeping current locale.' + ); + expect(wrapper.text()).toContain('Setup Applied with Warnings'); + }, + }, + { + caseName: 'keeps locale when language installer returns unknown status', + apply: () => { + draftStore.selectedLanguage = 'fr_FR'; + installLanguageMock.mockResolvedValue({ + operationId: 'lang-op', + status: 'UNKNOWN', + output: ['unknown status'], + }); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(installLanguageMock).toHaveBeenCalled(); + expect(setLocaleMock).not.toHaveBeenCalledWith({ locale: 'fr_FR' }); + expect(wrapper.text()).toContain( + 'Language pack installation did not succeed for French. Keeping current locale.' + ); + expect(wrapper.text()).toContain('Setup Applied with Warnings'); + }, + }, + { + caseName: 'classifies language install timeout separately', + apply: () => { + draftStore.selectedLanguage = 'fr_FR'; + const timeoutError = new Error( + 'Timed out waiting for install operation lang-op to finish' + ) as Error & { + code?: string; + }; + timeoutError.code = 'INSTALL_OPERATION_TIMEOUT'; + installLanguageMock.mockRejectedValue(timeoutError); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(installLanguageMock).toHaveBeenCalled(); + expect(setLocaleMock).not.toHaveBeenCalledWith({ locale: 'fr_FR' }); + expect(wrapper.text()).toContain('Setup Continued After Timeout'); + }, + }, + ])('follows locale endpoint decision matrix ($caseName)', async (scenario) => { + scenario.apply(); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + scenario.assertExpected(wrapper); + }); + + it('locks modal visibility and ignores duplicate apply clicks while processing', async () => { + draftStore.selectedTimeZone = 'America/New_York'; + let resolveSystemTime: (() => void) | undefined; + updateSystemTimeMock.mockImplementation( + () => + new Promise((resolve) => { + resolveSystemTime = () => resolve({}); + }) + ); + + const { wrapper } = mountComponent(); + const buttons = wrapper.findAll('[data-testid="brand-button"]'); + const applyButton = buttons[buttons.length - 1]; + + await applyButton.trigger('click'); + await applyButton.trigger('click'); + + expect(setModalHiddenMock).toHaveBeenCalledWith(false); + expect(updateSystemTimeMock).toHaveBeenCalledTimes(1); + expect(applyButton.attributes('disabled')).toBeDefined(); + + if (resolveSystemTime) { + resolveSystemTime(); + } + await flushPromises(); + await vi.runAllTimersAsync(); + await flushPromises(); + + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); + }); + + it('skips core setting mutations when baseline is loaded and nothing changed', async () => { + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(updateSystemTimeMock).not.toHaveBeenCalled(); + expect(updateServerIdentityMock).not.toHaveBeenCalled(); + expect(setThemeMock).not.toHaveBeenCalled(); + expect(setLocaleMock).not.toHaveBeenCalled(); + expect(updateSshSettingsMock).not.toHaveBeenCalled(); + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); + }); + + it('keeps custom baseline server identity when draft mirrors baseline values', async () => { + coreSettingsResult.value = { + vars: { name: 'MyServer', useSsh: false, localTld: 'local' }, + server: { name: 'MyServer', comment: 'Primary host' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + info: { primaryNetwork: { ipAddress: '192.168.1.2' } }, + }; + draftStore.serverName = 'MyServer'; + draftStore.serverDescription = 'Primary host'; + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(updateServerIdentityMock).not.toHaveBeenCalled(); + expect(updateSystemTimeMock).not.toHaveBeenCalled(); + expect(setThemeMock).not.toHaveBeenCalled(); + expect(setLocaleMock).not.toHaveBeenCalled(); + expect(updateSshSettingsMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + caseName: 'server identity name only', + apply: () => { + draftStore.serverName = 'Tower2'; + }, + assertExpected: () => { + expect(updateServerIdentityMock).toHaveBeenCalledWith({ name: 'Tower2', comment: '' }); + }, + }, + { + caseName: 'server identity description only', + apply: () => { + draftStore.serverDescription = 'Edge host'; + }, + assertExpected: () => { + expect(updateServerIdentityMock).toHaveBeenCalledWith({ + name: 'Tower', + comment: 'Edge host', + }); + }, + }, + { + caseName: 'timezone only', + apply: () => { + draftStore.selectedTimeZone = 'America/New_York'; + }, + assertExpected: () => { + expect(updateSystemTimeMock).toHaveBeenCalledWith({ + input: { timeZone: 'America/New_York' }, + }); + }, + }, + { + caseName: 'theme only', + apply: () => { + draftStore.selectedTheme = 'black'; + }, + assertExpected: () => { + expect(setThemeMock).toHaveBeenCalledWith({ theme: 'black' }); + }, + }, + { + caseName: 'language only', + apply: () => { + draftStore.selectedLanguage = 'fr_FR'; + }, + assertExpected: () => { + expect(installLanguageMock).toHaveBeenCalledWith({ + forced: false, + name: 'French', + url: 'https://example.com/fr_FR.txz', + }); + expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'fr_FR' }); + }, + }, + { + caseName: 'ssh only', + apply: () => { + draftStore.useSsh = true; + }, + assertExpected: () => { + expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: true, port: 22 }); + }, + }, + ])('applies only the changed core setting when baseline is loaded ($caseName)', async (scenario) => { + scenario.apply(); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + scenario.assertExpected(); + + if ( + scenario.caseName !== 'server identity name only' && + scenario.caseName !== 'server identity description only' + ) { + expect(updateServerIdentityMock).not.toHaveBeenCalled(); + } + if (scenario.caseName !== 'timezone only') { + expect(updateSystemTimeMock).not.toHaveBeenCalled(); + } + if (scenario.caseName !== 'theme only') { + expect(setThemeMock).not.toHaveBeenCalled(); + } + if (scenario.caseName !== 'language only') { + expect(setLocaleMock).not.toHaveBeenCalled(); + expect(installLanguageMock).not.toHaveBeenCalled(); + } + if (scenario.caseName !== 'ssh only') { + expect(updateSshSettingsMock).not.toHaveBeenCalled(); + } + }); + + it('applies trusted defaults + draft values when baseline query is down', async () => { + coreSettingsResult.value = null; + coreSettingsError.value = new Error('Graphql is offline.'); + draftStore.serverName = 'MyTower'; + draftStore.serverDescription = 'Edge host'; + draftStore.selectedTimeZone = 'America/New_York'; + draftStore.selectedTheme = 'black'; + draftStore.selectedLanguage = 'en_US'; + draftStore.useSsh = true; + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(updateSystemTimeMock).toHaveBeenCalledWith({ input: { timeZone: 'America/New_York' } }); + expect(updateServerIdentityMock).toHaveBeenCalledWith({ + name: 'MyTower', + comment: 'Edge host', + }); + expect(setThemeMock).toHaveBeenCalledWith({ theme: 'black' }); + expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' }); + expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: true, port: 22 }); + }); + + it('applies trusted defaults when baseline query is down and draft values are empty', async () => { + coreSettingsResult.value = null; + coreSettingsError.value = new Error('Graphql is offline.'); + draftStore.serverName = ''; + draftStore.serverDescription = ''; + draftStore.selectedTimeZone = ''; + draftStore.selectedTheme = ''; + draftStore.selectedLanguage = ''; + draftStore.useSsh = false; + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(updateSystemTimeMock).toHaveBeenCalledWith({ input: { timeZone: 'UTC' } }); + expect(updateServerIdentityMock).toHaveBeenCalledWith({ + name: 'Tower', + comment: '', + }); + expect(setThemeMock).toHaveBeenCalledWith({ theme: 'white' }); + expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' }); + expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: false, port: 22 }); + }); + + it('keeps best-effort fallback path once readiness times out before baseline is ready', async () => { + coreSettingsResult.value = null; + coreSettingsError.value = null; + draftStore.serverName = 'Tower'; + draftStore.serverDescription = ''; + draftStore.selectedTimeZone = 'UTC'; + draftStore.selectedTheme = 'white'; + draftStore.selectedLanguage = 'en_US'; + draftStore.useSsh = false; + + const { wrapper } = mountComponent(); + await vi.advanceTimersByTimeAsync(10000); + + coreSettingsResult.value = { + vars: { name: 'Tower', useSsh: false, localTld: 'local' }, + server: { name: 'Tower', comment: '' }, + display: { theme: 'white', locale: 'en_US' }, + systemTime: { timeZone: 'UTC' }, + info: { primaryNetwork: { ipAddress: '192.168.1.2' } }, + }; + await flushPromises(); + + await clickApply(wrapper); + + expect(updateSystemTimeMock).toHaveBeenCalledWith({ input: { timeZone: 'UTC' } }); + expect(updateServerIdentityMock).toHaveBeenCalledWith({ + name: 'Tower', + comment: '', + }); + expect(setThemeMock).toHaveBeenCalledWith({ theme: 'white' }); + expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' }); + expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: false, port: 22 }); + expect(wrapper.text()).toContain( + 'Baseline settings unavailable. Applying trusted defaults + draft values without diff checks.' + ); + }); + + it.each([ + { + caseName: 'baseline available + completion/refetch succeed', + apply: () => {}, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); + expect(refetchOnboardingMock).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).not.toContain('Setup Saved in Best-Effort Mode'); + }, + }, + { + caseName: 'baseline available + onboarding refetch fails', + apply: () => { + refetchOnboardingMock.mockRejectedValue(new Error('refresh failed')); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); + expect(refetchOnboardingMock).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toContain('Could not refresh onboarding state right now. Continuing.'); + expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode'); + }, + }, + { + caseName: 'baseline unavailable + completion succeeds', + apply: () => { + coreSettingsResult.value = null; + coreSettingsError.value = new Error('Graphql is offline.'); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); + expect(refetchOnboardingMock).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('Skipping onboarding state refresh while API is unavailable.'); + expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode'); + }, + }, + { + caseName: 'completion mutation fails', + apply: () => { + completeOnboardingMock.mockRejectedValue(new Error('offline')); + }, + assertExpected: (wrapper: ReturnType['wrapper']) => { + expect(completeOnboardingMock).toHaveBeenCalledTimes(1); + expect(refetchOnboardingMock).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain( + 'Could not mark onboarding complete right now (API may be offline): offline' + ); + expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode'); + }, + }, + ])('follows completion endpoint decision matrix ($caseName)', async (scenario) => { + scenario.apply(); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + scenario.assertExpected(wrapper); + }); + + it('prefers best-effort result over timeout classification when completion fails', async () => { + draftStore.selectedPlugins = new Set(['community-apps']); + const timeoutError = new Error( + 'Timed out waiting for install operation plugin-op to finish' + ) as Error & { + code?: string; + }; + timeoutError.code = 'INSTALL_OPERATION_TIMEOUT'; + installPluginMock.mockRejectedValue(timeoutError); + completeOnboardingMock.mockRejectedValue(new Error('offline')); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode'); + expect(wrapper.text()).not.toContain('Setup Continued After Timeout'); + }); + + it('prefers timeout result over warning classification when completion succeeds', async () => { + draftStore.selectedPlugins = new Set(['community-apps']); + draftStore.serverName = 'bad name!'; + updateServerIdentityMock.mockRejectedValue(new Error('Server name contains invalid characters')); + const timeoutError = new Error( + 'Timed out waiting for install operation plugin-op to finish' + ) as Error & { + code?: string; + }; + timeoutError.code = 'INSTALL_OPERATION_TIMEOUT'; + installPluginMock.mockRejectedValue(timeoutError); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(wrapper.text()).toContain('Setup Continued After Timeout'); + expect(wrapper.text()).not.toContain('Setup Applied with Warnings'); + }); + + it('shows completion dialog in offline mode and advances only after OK', async () => { + coreSettingsResult.value = null; + coreSettingsError.value = new Error('Graphql is offline.'); + completeOnboardingMock.mockRejectedValue(new Error('offline')); + + const { wrapper, onComplete } = mountComponent(); + await clickApply(wrapper); + + expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode'); + expect(onComplete).not.toHaveBeenCalled(); + + const okButton = wrapper + .findAll('button') + .find((button) => button.text().trim().toUpperCase() === 'OK'); + expect(okButton).toBeTruthy(); + await okButton!.trigger('click'); + + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it('continues and classifies warnings when server identity mutation rejects invalid input', async () => { + draftStore.serverName = 'bad name!'; + updateServerIdentityMock.mockRejectedValue(new Error('Server name contains invalid characters')); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(wrapper.text()).toContain('Server identity request returned an error, continuing'); + expect(wrapper.text()).toContain('Setup Applied with Warnings'); + }); + + it('verifies SSH settings and reports fully applied setup when state matches', async () => { + draftStore.useSsh = true; + updateSshSettingsMock.mockResolvedValue({ + data: { + updateSshSettings: { id: 'vars', useSsh: true, portssh: 22 }, + }, + }); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: true, port: 22 }); + expect(wrapper.text()).toContain('SSH settings verified.'); + expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).not.toContain('Best-Effort'); + }); + + it('keeps best-effort messaging when SSH state cannot be verified in time', async () => { + draftStore.useSsh = true; + updateSshSettingsMock.mockResolvedValue({ + data: { + updateSshSettings: { id: 'vars', useSsh: false, portssh: 22 }, + }, + }); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: true, port: 22 }); + expect(wrapper.text()).toContain( + 'SSH update submitted, but final SSH state could not be verified yet.' + ); + expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode'); + }); +}); diff --git a/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts b/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts new file mode 100644 index 0000000000..1272b1c563 --- /dev/null +++ b/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts @@ -0,0 +1,45 @@ +import { ONBOARDING_TEMP_BYPASS_STORAGE_KEY } from '~/consts'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + cleanupOnboardingStorage, + clearOnboardingDraftStorage, + clearTemporaryBypassSessionState, +} from '~/components/Onboarding/store/onboardingStorageCleanup'; + +describe('onboardingStorageCleanup', () => { + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + + it('clears onboarding draft keys from localStorage', () => { + window.localStorage.setItem('onboardingDraft', '{"currentStepIndex":2}'); + window.localStorage.setItem('pinia-onboardingDraft', '{"currentStepIndex":1}'); + window.localStorage.setItem('unrelatedKey', 'keep'); + + clearOnboardingDraftStorage(); + + expect(window.localStorage.getItem('onboardingDraft')).toBeNull(); + expect(window.localStorage.getItem('pinia-onboardingDraft')).toBeNull(); + expect(window.localStorage.getItem('unrelatedKey')).toBe('keep'); + }); + + it('clears temporary bypass key from sessionStorage', () => { + window.sessionStorage.setItem(ONBOARDING_TEMP_BYPASS_STORAGE_KEY, '{"active":true}'); + + clearTemporaryBypassSessionState(); + + expect(window.sessionStorage.getItem(ONBOARDING_TEMP_BYPASS_STORAGE_KEY)).toBeNull(); + }); + + it('cleans draft storage and optional temporary bypass key together', () => { + window.localStorage.setItem('onboardingDraft', '{"currentStepIndex":4}'); + window.sessionStorage.setItem(ONBOARDING_TEMP_BYPASS_STORAGE_KEY, '{"active":true}'); + + cleanupOnboardingStorage({ clearTemporaryBypassSessionState: true }); + + expect(window.localStorage.getItem('onboardingDraft')).toBeNull(); + expect(window.sessionStorage.getItem(ONBOARDING_TEMP_BYPASS_STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/web/__test__/components/Onboarding/usePluginInstaller.test.ts b/web/__test__/components/Onboarding/usePluginInstaller.test.ts new file mode 100644 index 0000000000..57655f575a --- /dev/null +++ b/web/__test__/components/Onboarding/usePluginInstaller.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import usePluginInstaller from '~/components/Onboarding/composables/usePluginInstaller'; +import { PluginInstallStatus } from '~/composables/gql/graphql'; + +const { mutateMock, queryMock, subscribeMock, useApolloClientMock } = vi.hoisted(() => ({ + mutateMock: vi.fn(), + queryMock: vi.fn(), + subscribeMock: vi.fn(), + useApolloClientMock: vi.fn(), +})); + +vi.mock('@vue/apollo-composable', async () => { + const actual = + await vi.importActual('@vue/apollo-composable'); + return { + ...actual, + useApolloClient: useApolloClientMock, + }; +}); + +describe('usePluginInstaller', () => { + beforeEach(() => { + vi.clearAllMocks(); + useApolloClientMock.mockReturnValue({ + client: { + mutate: mutateMock, + query: queryMock, + subscribe: subscribeMock, + }, + }); + }); + + it('returns FAILED when plugin operation starts and finishes in failed state', async () => { + mutateMock.mockResolvedValue({ + data: { + unraidPlugins: { + installPlugin: { + id: 'plugin-op-1', + status: PluginInstallStatus.FAILED, + output: ['installation failed'], + }, + }, + }, + }); + + const { installPlugin } = usePluginInstaller(); + const result = await installPlugin({ + url: 'https://example.com/plugin.plg', + name: 'Example Plugin', + forced: false, + }); + + expect(result.status).toBe(PluginInstallStatus.FAILED); + expect(result.output).toContain('installation failed'); + expect(subscribeMock).not.toHaveBeenCalled(); + }); + + it('returns FAILED for installLanguage when subscription emits final failed event', async () => { + mutateMock.mockResolvedValue({ + data: { + unraidPlugins: { + installLanguage: { + id: 'lang-op-1', + status: PluginInstallStatus.RUNNING, + output: ['starting'], + }, + }, + }, + }); + + queryMock.mockResolvedValue({ + data: { + pluginInstallOperation: { + output: ['starting', 'network failed'], + }, + }, + }); + + subscribeMock.mockImplementation(() => ({ + subscribe: ({ next }: { next: (value: unknown) => void }) => { + next({ + data: { + pluginInstallUpdates: { + operationId: 'lang-op-1', + status: PluginInstallStatus.FAILED, + output: ['network failed'], + }, + }, + }); + return { + unsubscribe: vi.fn(), + }; + }, + })); + + const { installLanguage } = usePluginInstaller(); + const result = await installLanguage({ + url: 'https://example.com/lang.txz', + name: 'French', + forced: false, + }); + + expect(result.operationId).toBe('lang-op-1'); + expect(result.status).toBe(PluginInstallStatus.FAILED); + expect(result.output).toEqual(['starting', 'network failed']); + }); + + it('throws timeout error with code when operation does not finish in time', async () => { + vi.useFakeTimers(); + try { + mutateMock.mockResolvedValue({ + data: { + unraidPlugins: { + installPlugin: { + id: 'plugin-op-timeout', + status: PluginInstallStatus.RUNNING, + output: [], + }, + }, + }, + }); + + queryMock.mockResolvedValue({ + data: { + pluginInstallOperation: { + id: 'plugin-op-timeout', + status: PluginInstallStatus.RUNNING, + output: [], + }, + }, + }); + + subscribeMock.mockImplementation(() => ({ + subscribe: () => ({ + unsubscribe: vi.fn(), + }), + })); + + const { installPlugin } = usePluginInstaller(); + const pending = installPlugin({ + url: 'https://example.com/plugin.plg', + name: 'Slow Plugin', + }); + const handled = pending.then( + () => null, + (error) => error as { code?: string } + ); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 5000); + const error = await handled; + expect(error).toMatchObject({ code: 'INSTALL_OPERATION_TIMEOUT' }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/web/__test__/components/Registration.test.ts b/web/__test__/components/Registration.test.ts index 8a2826eb82..c665494809 100644 --- a/web/__test__/components/Registration.test.ts +++ b/web/__test__/components/Registration.test.ts @@ -18,6 +18,12 @@ import { useReplaceRenewStore } from '~/store/replaceRenew'; import { useServerStore } from '~/store/server'; import { createTestI18n, testTranslate } from '../utils/i18n'; +const { activationCodeStateHolder } = vi.hoisted(() => ({ + activationCodeStateHolder: { + current: null as { value: { code: string } | null } | null, + }, +})); + vi.mock('crypto-js/aes.js', () => ({ default: {} })); vi.mock('@unraid/shared-callbacks', () => ({ @@ -27,6 +33,21 @@ vi.mock('@unraid/shared-callbacks', () => ({ })), })); +vi.mock('~/components/Onboarding/store/activationCodeData', async () => { + const { computed, ref } = await import('vue'); + const { defineStore } = await import('pinia'); + + activationCodeStateHolder.current = ref<{ code: string } | null>(null); + + const useActivationCodeDataStore = defineStore('activationCodeDataMockForRegistration', () => { + return { + activationCode: computed(() => activationCodeStateHolder.current?.value ?? null), + }; + }); + + return { useActivationCodeDataStore }; +}); + // Mock vue-i18n for store tests vi.mock('vue-i18n', async (importOriginal) => { const actual = await importOriginal(); @@ -172,6 +193,8 @@ describe('Registration.standalone.vue', () => { vi.clearAllMocks(); + activationCodeStateHolder.current!.value = null; + // Mount after store setup wrapper = mount(Registration, { global: { @@ -250,4 +273,19 @@ describe('Registration.standalone.vue', () => { expect(findItemByLabel(t('Attached Storage Devices'))).toBeDefined(); expect(wrapper.find('[data-testid="key-actions"]').exists()).toBe(false); }); + + it('adds Activate Trial fallback for ENOKEYFILE partner activation', async () => { + activationCodeStateHolder.current!.value = { + code: 'PARTNER-CODE-123', + }; + + serverStore.state = 'ENOKEYFILE'; + serverStore.registered = false; + serverStore.connectPluginInstalled = '' as ServerconnectPluginInstalled; + + await wrapper.vm.$nextTick(); + + const actionNames = serverStore.keyActions?.map((action) => action.name); + expect(actionNames).toEqual(['activate', 'recover', 'trialStart']); + }); }); diff --git a/web/__test__/components/WelcomeModal.test.ts b/web/__test__/components/WelcomeModal.test.ts deleted file mode 100644 index 1091f518ef..0000000000 --- a/web/__test__/components/WelcomeModal.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * WelcomeModal Component Test Coverage - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { Server } from '~/types/server'; - -const mockServer: Server = { - name: 'Test Server', - description: 'Test Description', - guid: 'test-guid', - keyfile: 'test-keyfile', - lanIp: '192.168.1.1', - connectPluginInstalled: '', - state: 'PRO', - dateTimeFormat: { date: 'YYYY-MM-DD', time: 'HH:mm' }, -}; - -const mockSetServer = vi.fn(); -const mockPartnerName = vi.fn(); -const mockPartnerLogo = vi.fn(); - -const mockSetProperty = vi.fn(); -const mockQuerySelector = vi.fn(); - -vi.mock('~/store/server', () => ({ - useServerStore: () => ({ - setServer: mockSetServer, - }), -})); - -vi.mock('~/store/activationCode', () => ({ - useActivationCodeStore: () => ({ - partnerName: mockPartnerName, - partnerLogo: mockPartnerLogo, - }), -})); - -// Functions extracted from component to test -function processServer(server: Server | string | null) { - if (!server) { - throw new Error('Server data not present'); - } - - let serverData: Server; - - if (typeof server === 'string') { - serverData = JSON.parse(server); - } else { - serverData = server; - } - - mockSetServer(serverData); - - return serverData; -} - -// Calculate title based on partner name -function calculateTitle(partnerName: string | null) { - return partnerName - ? `Welcome to your new ${partnerName} system, powered by Unraid!` - : 'Welcome to Unraid!'; -} - -describe('WelcomeModal.standalone.vue', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPartnerName.mockReturnValue(null); - mockPartnerLogo.mockReturnValue(null); - }); - - it('sets server data in the store when given an object', () => { - processServer(mockServer); - expect(mockSetServer).toHaveBeenCalledWith(mockServer); - }); - - it('sets server data in the store when given a JSON string', () => { - processServer(JSON.stringify(mockServer)); - expect(mockSetServer).toHaveBeenCalledWith(mockServer); - }); - - it('throws error when server data is not provided', () => { - expect(() => processServer(null)).toThrow('Server data not present'); - }); - - it('computes custom title based on partner name', () => { - const partnerName = 'Test Partner'; - mockPartnerName.mockReturnValue(partnerName); - - const title = calculateTitle(mockPartnerName()); - - expect(title).toContain(partnerName); - expect(title).toContain('powered by Unraid'); - }); - - it('uses default title when partner name is not provided', () => { - mockPartnerName.mockReturnValue(null); - - const title = calculateTitle(mockPartnerName()); - - expect(title).toBe('Welcome to Unraid!'); - }); - - it('sets font-size to 62.5% when modal is shown and confirmPassword exists', () => { - mockQuerySelector.mockImplementation((selector: string) => { - if (selector === '#confirmPassword') { - return { exists: true }; - } - return null; - }); - - // Call a function that simulates the watchEffect - const setDocumentFontSize = (showModal = true) => { - if (showModal && mockQuerySelector('#confirmPassword')) { - mockSetProperty('font-size', '62.5%'); - } else { - mockSetProperty('font-size', '100%'); - } - }; - - setDocumentFontSize(true); - expect(mockSetProperty).toHaveBeenCalledWith('font-size', '62.5%'); - }); - - it('sets font-size to 100% when modal is closed', () => { - mockSetProperty.mockClear(); - - const setDocumentFontSize = (showModal = false) => { - if (showModal && mockQuerySelector('#confirmPassword')) { - mockSetProperty('font-size', '62.5%'); - } else { - mockSetProperty('font-size', '100%'); - } - }; - - setDocumentFontSize(false); - expect(mockSetProperty).toHaveBeenCalledWith('font-size', '100%'); - }); - - it('determines if partner logo should be shown', () => { - mockPartnerLogo.mockReturnValue('partner-logo-url'); - - const hasPartnerLogo = () => !!mockPartnerLogo(); - - expect(hasPartnerLogo()).toBe(true); - - mockPartnerLogo.mockReturnValue(null); - expect(hasPartnerLogo()).toBe(false); - }); - - it('uses a standardized description', () => { - const getDescription = () => - "First, you'll create your device's login credentials, then you'll activate your Unraid license—your device's operating system (OS)."; - - const expectedDescription = - "First, you'll create your device's login credentials, then you'll activate your Unraid license—your device's operating system (OS)."; - - expect(getDescription()).toBe(expectedDescription); - }); - - it('hides modal when BrandButton is clicked', () => { - let showModal = true; - - // Simulate the dropdownHide method from the component - const dropdownHide = () => { - showModal = false; - mockSetProperty('font-size', '100%'); - }; - - expect(showModal).toBe(true); - - dropdownHide(); - - expect(showModal).toBe(false); - expect(mockSetProperty).toHaveBeenCalledWith('font-size', '100%'); - }); -}); diff --git a/web/__test__/components/Wrapper/component-registry.test.ts b/web/__test__/components/Wrapper/component-registry.test.ts index f0135c4882..3e3550da23 100644 --- a/web/__test__/components/Wrapper/component-registry.test.ts +++ b/web/__test__/components/Wrapper/component-registry.test.ts @@ -20,7 +20,6 @@ vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' })); vi.mock('../CallbackHandler.standalone.vue', () => ({ default: 'CallbackHandler' })); vi.mock('../Logs/LogViewer.standalone.vue', () => ({ default: 'LogViewer' })); vi.mock('../SsoButton.standalone.vue', () => ({ default: 'SsoButton' })); -vi.mock('../Activation/WelcomeModal.standalone.vue', () => ({ default: 'WelcomeModal' })); vi.mock('../UpdateOs.standalone.vue', () => ({ default: 'UpdateOs' })); vi.mock('../DowngradeOs.standalone.vue', () => ({ default: 'DowngradeOs' })); vi.mock('../DevSettings.vue', () => ({ default: 'DevSettings' })); @@ -140,7 +139,6 @@ describe('component-registry', () => { 'callback-handler', 'log-viewer', 'sso-button', - 'welcome-modal', 'update-os', 'downgrade-os', 'dev-settings', diff --git a/web/__test__/components/component-registry.test.ts b/web/__test__/components/component-registry.test.ts index a487012c6f..0c7d090b88 100644 --- a/web/__test__/components/component-registry.test.ts +++ b/web/__test__/components/component-registry.test.ts @@ -37,9 +37,6 @@ vi.mock('~/components/Registration.standalone.vue', () => ({ vi.mock('~/components/WanIpCheck.standalone.vue', () => ({ default: { name: 'MockWanIpCheck', template: '
WanIpCheck
' }, })); -vi.mock('~/components/Activation/WelcomeModal.standalone.vue', () => ({ - default: { name: 'MockWelcomeModal', template: '
WelcomeModal
' }, -})); vi.mock('~/components/SsoButton.standalone.vue', () => ({ default: { name: 'MockSsoButton', template: '
SsoButton
' }, })); diff --git a/web/__test__/store/activationCodeData.test.ts b/web/__test__/store/activationCodeData.test.ts index 8bb0e02340..2795ac9414 100644 --- a/web/__test__/store/activationCodeData.test.ts +++ b/web/__test__/store/activationCodeData.test.ts @@ -4,11 +4,8 @@ import { useQuery } from '@vue/apollo-composable'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - ACTIVATION_CODE_QUERY, - PARTNER_INFO_QUERY, -} from '~/components/Activation/graphql/activationCode.query'; -import { useActivationCodeDataStore } from '~/components/Activation/store/activationCodeData'; +import { ACTIVATION_CODE_QUERY } from '~/components/Onboarding/graphql/activationCode.query'; +import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData'; import { RegistrationState } from '~/composables/gql/graphql'; // Create a complete mock of UseQueryReturn with all required properties @@ -63,20 +60,6 @@ describe('ActivationCodeData Store', () => { expect(store.loading).toBe(true); }); - it('should compute loading state when partnerInfoLoading is true', () => { - vi.mocked(useQuery).mockImplementation((query) => { - if (query === PARTNER_INFO_QUERY) { - return createCompleteQueryMock(null, true); - } - - return createCompleteQueryMock(null, false); - }); - - const store = useActivationCodeDataStore(); - - expect(store.loading).toBe(true); - }); - it('should compute loading state when both loadings are false', () => { vi.mocked(useQuery).mockImplementation(() => createCompleteQueryMock(null, false)); @@ -86,7 +69,7 @@ describe('ActivationCodeData Store', () => { }); it('should compute activationCode correctly', () => { - const mockActivationCode = 'TEST-CODE-123'; + const mockActivationCode = { code: 'TEST-CODE-123' }; vi.mocked(useQuery).mockImplementation((query) => { if (query === ACTIVATION_CODE_QUERY) { @@ -102,15 +85,22 @@ describe('ActivationCodeData Store', () => { const store = useActivationCodeDataStore(); - expect(store.activationCode).toBe(mockActivationCode); + expect(store.activationCode).toEqual(mockActivationCode); }); - it('should compute isFreshInstall as true when regState is ENOKEYFILE', () => { + it('should compute isFreshInstall from backend when regState is ENOKEYFILE', () => { vi.mocked(useQuery).mockImplementation((query) => { if (query === ACTIVATION_CODE_QUERY) { return createCompleteQueryMock( { - vars: { regState: RegistrationState.ENOKEYFILE }, + customization: { + onboarding: { + onboardingState: { + registrationState: RegistrationState.ENOKEYFILE, + isFreshInstall: true, // Backend determines this value + }, + }, + }, }, false ); @@ -124,12 +114,45 @@ describe('ActivationCodeData Store', () => { expect(store.isFreshInstall).toBe(true); }); - it('should compute isFreshInstall as false when regState is not ENOKEYFILE', () => { + it('should compute isFreshInstall from backend when regState is ENOKEYFILE1', () => { + vi.mocked(useQuery).mockImplementation((query) => { + if (query === ACTIVATION_CODE_QUERY) { + return createCompleteQueryMock( + { + customization: { + onboarding: { + onboardingState: { + registrationState: RegistrationState.ENOKEYFILE1, + isFreshInstall: false, // Backend determines this value + }, + }, + }, + }, + false + ); + } + + return createCompleteQueryMock(null, false); + }); + + const store = useActivationCodeDataStore(); + + expect(store.isFreshInstall).toBe(false); + }); + + it('should compute isFreshInstall from backend when regState is ENOKEYFILE2', () => { vi.mocked(useQuery).mockImplementation((query) => { if (query === ACTIVATION_CODE_QUERY) { return createCompleteQueryMock( { - vars: { regState: 'REGISTERED' as RegistrationState }, + customization: { + onboarding: { + onboardingState: { + registrationState: RegistrationState.ENOKEYFILE2, + isFreshInstall: false, // Backend determines this value + }, + }, + }, }, false ); @@ -143,13 +166,19 @@ describe('ActivationCodeData Store', () => { expect(store.isFreshInstall).toBe(false); }); - it('should use publicPartnerInfo when available', () => { - const mockPublicPartnerInfo = { name: 'Public Partner' }; + it('should compute isFreshInstall from backend when regState is not ENOKEYFILE', () => { vi.mocked(useQuery).mockImplementation((query) => { - if (query === PARTNER_INFO_QUERY) { + if (query === ACTIVATION_CODE_QUERY) { return createCompleteQueryMock( { - publicPartnerInfo: mockPublicPartnerInfo, + customization: { + onboarding: { + onboardingState: { + registrationState: RegistrationState.PRO, + isFreshInstall: false, // Backend determines this value + }, + }, + }, }, false ); @@ -160,23 +189,52 @@ describe('ActivationCodeData Store', () => { const store = useActivationCodeDataStore(); - expect(store.partnerInfo).toEqual(mockPublicPartnerInfo); + expect(store.isFreshInstall).toBe(false); + }); + + it('should return false for isFreshInstall when onboardingState is null (query not loaded)', () => { + vi.mocked(useQuery).mockImplementation(() => createCompleteQueryMock(null, false)); + + const store = useActivationCodeDataStore(); + + expect(store.isFreshInstall).toBe(false); }); - it('should fallback to activationCode partnerInfo when publicPartnerInfo is null', () => { - const mockPartnerInfo = { name: 'Activation Partner' }; + it('should derive partnerInfo from activationCode partner and branding', () => { + const mockPartner = { name: 'Activation Partner' }; + const mockBranding = { hasPartnerLogo: true }; vi.mocked(useQuery).mockImplementation((query) => { if (query === ACTIVATION_CODE_QUERY) { return createCompleteQueryMock( { - customization: { partnerInfo: mockPartnerInfo }, + customization: { + activationCode: { + partner: mockPartner, + branding: mockBranding, + }, + }, }, false ); - } else if (query === PARTNER_INFO_QUERY) { + } + + return createCompleteQueryMock(null, false); + }); + + const store = useActivationCodeDataStore(); + + expect(store.partnerInfo).toEqual({ + partner: mockPartner, + branding: mockBranding, + }); + }); + + it('should return null for partnerInfo when activationCode has no partner or branding', () => { + vi.mocked(useQuery).mockImplementation((query) => { + if (query === ACTIVATION_CODE_QUERY) { return createCompleteQueryMock( { - publicPartnerInfo: null, + customization: { activationCode: null }, }, false ); @@ -187,7 +245,7 @@ describe('ActivationCodeData Store', () => { const store = useActivationCodeDataStore(); - expect(store.partnerInfo).toEqual(mockPartnerInfo); + expect(store.partnerInfo).toBeNull(); }); }); }); diff --git a/web/__test__/store/activationCodeModal.test.ts b/web/__test__/store/activationCodeModal.test.ts index c6dbc97ae7..9baf27d25c 100644 --- a/web/__test__/store/activationCodeModal.test.ts +++ b/web/__test__/store/activationCodeModal.test.ts @@ -1,19 +1,22 @@ -import { ref } from 'vue'; +import { createApp, defineComponent, nextTick, ref } from 'vue'; import { createPinia, setActivePinia } from 'pinia'; import { useSessionStorage } from '@vueuse/core'; -import { ACTIVATION_CODE_MODAL_HIDDEN_STORAGE_KEY } from '~/consts'; +import { ACTIVATION_CODE_MODAL_HIDDEN_STORAGE_KEY, ONBOARDING_TEMP_BYPASS_STORAGE_KEY } from '~/consts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useActivationCodeDataStore } from '~/components/Activation/store/activationCodeData'; -import { useActivationCodeModalStore } from '~/components/Activation/store/activationCodeModal'; +import type { App } from 'vue'; + +import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData'; +import { useActivationCodeModalStore } from '~/components/Onboarding/store/activationCodeModal'; import { useCallbackActionsStore } from '~/store/callbackActions'; +import { useServerStore } from '~/store/server'; vi.mock('@vueuse/core', () => ({ useSessionStorage: vi.fn(), })); -vi.mock('~/components/Activation/store/activationCodeData', () => ({ +vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ useActivationCodeDataStore: vi.fn(), })); @@ -21,133 +24,306 @@ vi.mock('~/store/callbackActions', () => ({ useCallbackActionsStore: vi.fn(), })); +vi.mock('~/store/server', () => ({ + useServerStore: vi.fn(), +})); + describe('ActivationCodeModal Store', () => { let store: ReturnType; let mockIsHidden: ReturnType; + let mockTemporaryBypassState: ReturnType; let mockIsFreshInstall: ReturnType; - let mockActivationCode: ReturnType; let mockCallbackData: ReturnType; + let mockUptime: ReturnType; + let app: App | null = null; + let mountTarget: HTMLElement | null = null; - beforeEach(() => { - vi.clearAllMocks(); + const mountStoreHost = () => { + const pinia = createPinia(); + setActivePinia(pinia); - // Mock window.location to prevent navigation errors - Object.defineProperty(window, 'location', { - value: { href: '' }, - writable: true, + const TestHost = defineComponent({ + setup() { + store = useActivationCodeModalStore(); + return () => null; + }, }); + mountTarget = document.createElement('div'); + app = createApp(TestHost); + app.use(pinia); + app.mount(mountTarget); + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-19T12:00:00.000Z')); + vi.clearAllMocks(); + mockIsHidden = ref(null); + mockTemporaryBypassState = ref(null); mockIsFreshInstall = ref(false); - mockActivationCode = ref(null); mockCallbackData = ref(null); + mockUptime = ref(3600); + + vi.mocked(useSessionStorage).mockImplementation(((key: unknown, initialValue: unknown) => { + const storageKey = typeof key === 'string' ? key : ''; + if (storageKey === ACTIVATION_CODE_MODAL_HIDDEN_STORAGE_KEY) { + return mockIsHidden as unknown as ReturnType; + } + if (storageKey === ONBOARDING_TEMP_BYPASS_STORAGE_KEY) { + return mockTemporaryBypassState as unknown as ReturnType; + } + return ref(initialValue) as unknown as ReturnType; + }) as typeof useSessionStorage); - vi.mocked(useSessionStorage).mockReturnValue(mockIsHidden); vi.mocked(useActivationCodeDataStore).mockReturnValue({ isFreshInstall: mockIsFreshInstall, - activationCode: mockActivationCode, } as unknown as ReturnType); + vi.mocked(useCallbackActionsStore).mockReturnValue({ callbackData: mockCallbackData, } as unknown as ReturnType); - setActivePinia(createPinia()); - store = useActivationCodeModalStore(); + vi.mocked(useServerStore).mockReturnValue({ + uptime: mockUptime, + } as unknown as ReturnType); + + window.history.replaceState({}, '', '/Dashboard'); + mountStoreHost(); }); afterEach(() => { + if (app) { + app.unmount(); + app = null; + } + mountTarget = null; + vi.useRealTimers(); vi.resetAllMocks(); + }); + + it('initializes hidden and temporary bypass session-storage keys', () => { + expect(useSessionStorage).toHaveBeenNthCalledWith(1, ACTIVATION_CODE_MODAL_HIDDEN_STORAGE_KEY, null); + expect(useSessionStorage).toHaveBeenNthCalledWith( + 2, + ONBOARDING_TEMP_BYPASS_STORAGE_KEY, + null, + expect.objectContaining({ + serializer: expect.objectContaining({ + read: expect.any(Function), + write: expect.any(Function), + }), + }) + ); + }); + + it('sets hidden state directly', () => { + store.setIsHidden(true); + expect(mockIsHidden.value).toBe(true); + + store.setIsHidden(false); + expect(mockIsHidden.value).toBe(false); + + store.setIsHidden(null); + expect(mockIsHidden.value).toBe(null); + }); + + it('uses robust serializer for temporary bypass state', () => { + const call = vi.mocked(useSessionStorage).mock.calls[1]; + const options = call?.[2] as + | { + serializer?: { + read: (value: string) => unknown; + write: (value: unknown) => string; + }; + } + | undefined; + + expect(options?.serializer).toBeDefined(); + + const serializer = options!.serializer!; + expect(serializer.read('[object Object]')).toBe(null); + expect(serializer.read('')).toBe(null); + expect( + serializer.read( + JSON.stringify({ + active: true, + bootMarker: 123, + }) + ) + ).toEqual({ active: true, bootMarker: 123 }); + expect(serializer.write({ active: true, bootMarker: 123 })).toBe( + JSON.stringify({ active: true, bootMarker: 123 }) + ); + }); + + it('applies keyboard shortcut bypass without completing onboarding', () => { + window.localStorage.setItem('onboardingDraft', '{"currentStepIndex":2}'); + + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'o', + code: 'KeyO', + ctrlKey: true, + altKey: true, + shiftKey: true, + }) + ); + + expect(store.isTemporarilyBypassed).toBe(true); + expect(mockIsHidden.value).toBe(true); + expect(mockTemporaryBypassState.value).toMatchObject({ active: true }); + expect(window.localStorage.getItem('onboardingDraft')).toBeNull(); + }); + + it('does not bypass when using 0 key with modifiers', () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: ')', + code: 'Digit0', + metaKey: true, + altKey: true, + shiftKey: true, + }) + ); + + expect(store.isTemporarilyBypassed).toBe(false); + expect(mockIsHidden.value).toBe(null); + expect(mockTemporaryBypassState.value).toBe(null); + }); + + it('does not bypass when required modifiers are missing', () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'o', + code: 'KeyO', + metaKey: true, + altKey: true, + }) + ); + + expect(store.isTemporarilyBypassed).toBe(false); + expect(mockIsHidden.value).toBe(null); + expect(mockTemporaryBypassState.value).toBe(null); + }); + + it('does not bypass on repeated keydown events', () => { + window.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'o', + code: 'KeyO', + metaKey: true, + altKey: true, + shiftKey: true, + repeat: true, + }) + ); + + expect(store.isTemporarilyBypassed).toBe(false); + expect(mockIsHidden.value).toBe(null); + expect(mockTemporaryBypassState.value).toBe(null); + }); + + it('is visible on fresh install when not hidden or bypassed', () => { + mockIsFreshInstall.value = true; mockIsHidden.value = null; - mockIsFreshInstall.value = false; - mockActivationCode.value = null; mockCallbackData.value = null; + + expect(store.isVisible).toBe(true); }); - describe('State Management', () => { - it('should initialize with correct storage key', () => { - expect(useSessionStorage).toHaveBeenCalledWith(ACTIVATION_CODE_MODAL_HIDDEN_STORAGE_KEY, null); - }); + it('is not visible when temporary bypass is active', () => { + mockIsFreshInstall.value = true; + store.setTemporaryBypass(true); - it('should set isHidden value correctly', () => { - store.setIsHidden(true); - expect(mockIsHidden.value).toBe(true); + expect(store.isTemporarilyBypassed).toBe(true); + expect(store.isVisible).toBe(false); + }); - store.setIsHidden(false); - expect(mockIsHidden.value).toBe(false); + it('supports onboarding=bypass URL param and removes it from URL', () => { + const replaceStateSpy = vi.spyOn(window.history, 'replaceState'); + window.history.replaceState({}, '', '/Dashboard?onboarding=bypass'); - store.setIsHidden(null); - expect(mockIsHidden.value).toBe(null); - }); + store.applyBypassFromUrlParam(); + + expect(store.isTemporarilyBypassed).toBe(true); + expect(mockIsHidden.value).toBe(true); + expect(window.location.search).not.toContain('onboarding='); + expect(replaceStateSpy).toHaveBeenCalled(); }); - describe('Computed Properties', () => { - it('should be visible when explicitly set to show', () => { - mockIsHidden.value = false; + it('applies onboarding=bypass automatically on mount', () => { + if (app) { + app.unmount(); + app = null; + } - expect(store.isVisible).toBe(true); - }); + window.history.replaceState({}, '', '/Dashboard?onboarding=bypass'); + mountStoreHost(); - it('should be visible when fresh install and not explicitly hidden', () => { - mockIsHidden.value = null; - mockIsFreshInstall.value = true; - mockActivationCode.value = { code: '12345' }; - mockCallbackData.value = null; + expect(store.isTemporarilyBypassed).toBe(true); + expect(mockIsHidden.value).toBe(true); + expect(window.location.search).not.toContain('onboarding='); + }); - expect(store.isVisible).toBe(true); - }); + it('supports onboarding=resume URL param and removes bypass', () => { + store.setTemporaryBypass(true); + mockIsHidden.value = true; + window.history.replaceState({}, '', '/Dashboard?onboarding=resume'); - it('should not be visible when explicitly hidden', () => { - mockIsHidden.value = true; + store.applyBypassFromUrlParam(); - expect(store.isVisible).toBe(false); - }); + expect(store.isTemporarilyBypassed).toBe(false); + expect(mockTemporaryBypassState.value).toBe(null); + expect(mockIsHidden.value).toBe(false); + expect(window.location.search).not.toContain('onboarding='); + }); - it('should not be visible when not fresh install', () => { - mockIsHidden.value = null; - mockIsFreshInstall.value = false; + it('applies onboarding=resume automatically on mount', () => { + if (app) { + app.unmount(); + app = null; + } - expect(store.isVisible).toBe(false); - }); + mockTemporaryBypassState.value = { active: true, bootMarker: 0 }; + mockIsHidden.value = true; + window.history.replaceState({}, '', '/Dashboard?onboarding=resume'); + mountStoreHost(); - it('should not be visible when activation code is missing', () => { - mockIsHidden.value = null; - mockIsFreshInstall.value = true; - mockActivationCode.value = null; + expect(store.isTemporarilyBypassed).toBe(false); + expect(mockTemporaryBypassState.value).toBe(null); + expect(mockIsHidden.value).toBe(false); + expect(window.location.search).not.toContain('onboarding='); + }); - expect(store.isVisible).toBe(false); - }); + it('ignores unknown onboarding URL param actions', () => { + window.history.replaceState({}, '', '/Dashboard?onboarding=unknown'); - it('should not be visible when callback data exists', () => { - mockIsHidden.value = null; - mockIsFreshInstall.value = true; - mockActivationCode.value = { code: '12345' }; - mockCallbackData.value = { someData: 'test' }; + store.applyBypassFromUrlParam(); - expect(store.isVisible).toBe(false); - }); + expect(store.isTemporarilyBypassed).toBe(false); + expect(mockTemporaryBypassState.value).toBe(null); + expect(mockIsHidden.value).toBe(null); + expect(window.location.search).toContain('onboarding=unknown'); }); - describe('Konami Code Handling', () => { - const keySequence = [ - 'ArrowUp', - 'ArrowUp', - 'ArrowDown', - 'ArrowDown', - 'ArrowLeft', - 'ArrowRight', - 'ArrowLeft', - 'ArrowRight', - 'b', - 'a', - ]; - - it('should not trigger on partial sequence', () => { - keySequence.slice(0, 3).forEach((key) => { - window.dispatchEvent(new KeyboardEvent('keydown', { key })); - }); - - expect(mockIsHidden.value).toBe(null); - expect(window.location.href).toBe(''); - }); + it('keeps bypass active in-session when uptime is unavailable', async () => { + mockUptime.value = 0; + store.setTemporaryBypass(true); + await nextTick(); + + expect(store.isTemporarilyBypassed).toBe(true); + }); + + it('automatically invalidates bypass when boot marker changes', async () => { + store.setTemporaryBypass(true); + expect(store.isTemporarilyBypassed).toBe(true); + + // Simulate a reboot by drastically changing uptime-derived boot marker. + mockUptime.value = 120; + await nextTick(); + + expect(store.isTemporarilyBypassed).toBe(false); }); }); diff --git a/web/__test__/store/purchase.test.ts b/web/__test__/store/purchase.test.ts index 4f6254a66f..74fdc7f945 100644 --- a/web/__test__/store/purchase.test.ts +++ b/web/__test__/store/purchase.test.ts @@ -49,7 +49,7 @@ vi.mock('@unraid/shared-callbacks', () => { }); // Mock activation code data store -vi.mock('~/components/Activation/store/activationCodeData', () => ({ +vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ useActivationCodeDataStore: () => ({ activationCode: ref(null), }), @@ -94,7 +94,6 @@ describe('Purchase Store', () => { server: { guid: 'test-guid', name: 'test-server', - activationCodeData: null, }, type: 'activate', }, diff --git a/web/__test__/store/server.test.ts b/web/__test__/store/server.test.ts index 85fa9ae77d..11d1fa2214 100644 --- a/web/__test__/store/server.test.ts +++ b/web/__test__/store/server.test.ts @@ -300,16 +300,30 @@ vi.mock('~/composables/services/webgui', () => ({ WebguiUpdateIgnore: vi.fn().mockReturnValue({}), })); -vi.mock('@vue/apollo-composable', () => ({ - useLazyQuery: vi.fn(() => ({ - load: vi.fn(), - refetch: vi.fn(), - onResult: vi.fn((callback) => { - callback({ data: {} }); - }), - onError: vi.fn(), - })), -})); +vi.mock('@vue/apollo-composable', async () => { + const actual = + await vi.importActual('@vue/apollo-composable'); + + return { + ...actual, + useLazyQuery: vi.fn(() => ({ + load: vi.fn(), + refetch: vi.fn(), + onResult: vi.fn((callback) => { + callback({ data: {} }); + }), + onError: vi.fn(), + })), + useQuery: vi.fn(() => ({ + result: { value: null }, + loading: { value: false }, + error: { value: null }, + onResult: vi.fn(), + onError: vi.fn(), + refetch: vi.fn(), + })), + }; +}); // Mock the dependencies of the server store vi.mock('~/composables/locale', async () => { diff --git a/web/__test__/store/updateOsActions.test.ts b/web/__test__/store/updateOsActions.test.ts index b68e28db4f..a56f01936c 100644 --- a/web/__test__/store/updateOsActions.test.ts +++ b/web/__test__/store/updateOsActions.test.ts @@ -196,6 +196,8 @@ describe('UpdateOsActions Store', () => { sha256: 'test-sha256', }) ).rejects.toThrow('No payload.keyfile provided'); + + expect(mockGetOsReleaseBySha256).not.toHaveBeenCalled(); }); it('should throw error when getting release without sha256', async () => { @@ -546,5 +548,15 @@ describe('UpdateOsActions Store', () => { store = useUpdateOsActionsStore(); expect(store.ineligibleText).toBe(''); }); + + it('should stay eligible without a keyfile', () => { + mockServerStore.guid = 'test-guid'; + mockServerStore.keyfile = ''; + mockServerStore.osVersion = '6.12.4'; + mockServerStore.regUpdatesExpired = false; + store = useUpdateOsActionsStore(); + expect(store.ineligible).toBe(false); + expect(store.ineligibleText).toBe(''); + }); }); }); diff --git a/web/components.d.ts b/web/components.d.ts index 5edb9b47d7..7f77c5583f 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -8,10 +8,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { - ActivationModal: typeof import('./src/components/Activation/ActivationModal.vue')['default'] - ActivationPartnerLogo: typeof import('./src/components/Activation/ActivationPartnerLogo.vue')['default'] - ActivationPartnerLogoImg: typeof import('./src/components/Activation/ActivationPartnerLogoImg.vue')['default'] - ActivationSteps: typeof import('./src/components/Activation/ActivationSteps.vue')['default'] + ActivationCode: typeof import('./src/components/Registration/ActivationCode.vue')['default'] 'ApiKeyAuthorize.standalone': typeof import('./src/components/ApiKeyAuthorize.standalone.vue')['default'] ApiKeyCreate: typeof import('./src/components/ApiKey/ApiKeyCreate.vue')['default'] ApiKeyManager: typeof import('./src/components/ApiKey/ApiKeyManager.vue')['default'] @@ -98,6 +95,18 @@ declare module 'vue' { MultiValueCopyBadges: typeof import('./src/components/Common/MultiValueCopyBadges.vue')['default'] OidcDebugButton: typeof import('./src/components/Logs/OidcDebugButton.vue')['default'] OidcDebugLogs: typeof import('./src/components/ConnectSettings/OidcDebugLogs.vue')['default'] + 'OnboardingAdminPanel.standalone': typeof import('./src/components/Onboarding/standalone/OnboardingAdminPanel.standalone.vue')['default'] + OnboardingConsole: typeof import('./src/components/Onboarding/components/OnboardingConsole.vue')['default'] + OnboardingCoreSettingsStep: typeof import('./src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue')['default'] + OnboardingLicenseStep: typeof import('./src/components/Onboarding/steps/OnboardingLicenseStep.vue')['default'] + OnboardingModal: typeof import('./src/components/Onboarding/OnboardingModal.vue')['default'] + OnboardingNextStepsStep: typeof import('./src/components/Onboarding/steps/OnboardingNextStepsStep.vue')['default'] + OnboardingOverviewStep: typeof import('./src/components/Onboarding/steps/OnboardingOverviewStep.vue')['default'] + OnboardingPartnerLogo: typeof import('./src/components/Onboarding/components/OnboardingPartnerLogo.vue')['default'] + OnboardingPartnerLogoImg: typeof import('./src/components/Onboarding/components/OnboardingPartnerLogoImg.vue')['default'] + OnboardingPluginsStep: typeof import('./src/components/Onboarding/steps/OnboardingPluginsStep.vue')['default'] + OnboardingSteps: typeof import('./src/components/Onboarding/OnboardingSteps.vue')['default'] + OnboardingSummaryStep: typeof import('./src/components/Onboarding/steps/OnboardingSummaryStep.vue')['default'] Overview: typeof import('./src/components/Docker/Overview.vue')['default'] PermissionCounter: typeof import('./src/components/ApiKey/PermissionCounter.vue')['default'] Preview: typeof import('./src/components/Docker/Preview.vue')['default'] @@ -152,10 +161,10 @@ declare module 'vue' { USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default'] 'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default'] USkeleton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Skeleton.vue')['default'] + UStepper: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Stepper.vue')['default'] USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default'] UTable: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default'] UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default'] 'WanIpCheck.standalone': typeof import('./src/components/WanIpCheck.standalone.vue')['default'] - 'WelcomeModal.standalone': typeof import('./src/components/Activation/WelcomeModal.standalone.vue')['default'] } } diff --git a/web/package.json b/web/package.json index 4455262226..d7abaaca1f 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "dev": "vite --mode development", "preview": "vite preview", "serve": "NODE_ENV=production PORT=${PORT:-4321} vite preview --port ${PORT:-4321}", + "docker:build-and-run": "pnpm --filter @unraid/connect-plugin docker:build-and-run", "// Build": "", "build:dev": "pnpm run build && pnpm run deploy-to-unraid:dev", "prebuild": "pnpm predev", @@ -116,6 +117,7 @@ "@vue/apollo-composable": "4.2.2", "@vueuse/components": "13.8.0", "@vueuse/integrations": "13.8.0", + "@vvo/tzdb": "^6.186.0", "ajv": "8.17.1", "ansi_up": "6.0.6", "class-variance-authority": "0.7.1", diff --git a/web/public/test-pages/all-components.html b/web/public/test-pages/all-components.html index 0e51644c5d..48f206be44 100644 --- a/web/public/test-pages/all-components.html +++ b/web/public/test-pages/all-components.html @@ -232,14 +232,6 @@

Modals

-
-

Welcome Modal

- <unraid-welcome-modal> -
- -
-
-

Dev Modal Test

<unraid-dev-modal-test> diff --git a/web/public/test-pages/shared-header.js b/web/public/test-pages/shared-header.js index 6f2588b034..dfbdde20e8 100644 --- a/web/public/test-pages/shared-header.js +++ b/web/public/test-pages/shared-header.js @@ -19,6 +19,15 @@
+
+ + +