ever-works-node 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,835 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Tests for install-service.logic.ps1.
4
+
5
+ .DESCRIPTION
6
+ install-service.ps1 configures the identity a fleet node runs as. Its
7
+ failure mode is silent - a node registered under an account that cannot
8
+ read its own credential starts, reports "not enrolled", and is
9
+ restarted every 10 seconds forever - and it cannot be exercised
10
+ end-to-end without registering a real service on a real workstation. So
11
+ the decisions were lifted into install-service.logic.ps1 and are
12
+ covered here instead.
13
+
14
+ That includes the P/Invoke signatures used to set the service's logon
15
+ account: each is exercised against a DELIBERATELY INVALID handle, which
16
+ proves the marshalling without reaching a service or the local security
17
+ policy. Nothing in this file changes the machine it runs on.
18
+
19
+ Style follows apps/node/native/windows-job-launcher/test-*.ps1, which
20
+ is this repository's existing PowerShell test shape: Set-StrictMode, a
21
+ $failures accumulator so ONE bad assertion does not hide the rest, and
22
+ an unhandled throw at the end (which exits non-zero under
23
+ `powershell.exe -File` while keeping the failing line in the trace).
24
+ Indentation is 4 spaces to match install-service.ps1 in this directory
25
+ rather than the tabs used under native/.
26
+
27
+ Pester is deliberately not used: the repository has no Pester tests and
28
+ no Pester CI step, and the fleet workstations carry only the Windows
29
+ in-box Pester 3.4.0 with no pwsh installed - so a Pester 5 suite could
30
+ not be run on the very machines this installer exists to configure
31
+ without first installing a module on them.
32
+
33
+ ASCII only, for the reason spelled out in install-service.logic.ps1:
34
+ Windows PowerShell 5.1 decodes a BOM-less script as the system ANSI
35
+ codepage, where a UTF-8 em dash ends in a curly quote that PowerShell
36
+ treats as a string terminator.
37
+ #>
38
+ [CmdletBinding()]
39
+ param()
40
+
41
+ $ErrorActionPreference = 'Stop'
42
+ Set-StrictMode -Version Latest
43
+
44
+ . (Join-Path $PSScriptRoot 'install-service.logic.ps1')
45
+
46
+ $failures = [Collections.Generic.List[string]]::new()
47
+
48
+ function Assert-Equal {
49
+ param(
50
+ [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][object] $Actual,
51
+ [Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][object] $Expected,
52
+ [Parameter(Mandatory = $true)][string] $Message
53
+ )
54
+ if ("$Actual" -cne "$Expected") {
55
+ $failures.Add("$Message (actual='$Actual' expected='$Expected')")
56
+ }
57
+ }
58
+
59
+ function Assert-True {
60
+ param(
61
+ [Parameter(Mandatory = $true)][AllowNull()][object] $Condition,
62
+ [Parameter(Mandatory = $true)][string] $Message
63
+ )
64
+ if (-not $Condition) {
65
+ $failures.Add($Message)
66
+ }
67
+ }
68
+
69
+ function Assert-False {
70
+ param(
71
+ [Parameter(Mandatory = $true)][AllowNull()][object] $Condition,
72
+ [Parameter(Mandatory = $true)][string] $Message
73
+ )
74
+ if ($Condition) {
75
+ $failures.Add($Message)
76
+ }
77
+ }
78
+
79
+ function Assert-ScriptFailsLike {
80
+ param(
81
+ [Parameter(Mandatory = $true)][scriptblock] $Action,
82
+ [Parameter(Mandatory = $true)][string] $Pattern,
83
+ [Parameter(Mandatory = $true)][string] $Message
84
+ )
85
+ try {
86
+ & $Action | Out-Null
87
+ $failures.Add("$Message (script unexpectedly succeeded)")
88
+ }
89
+ catch {
90
+ if ($_.Exception.Message -notlike $Pattern) {
91
+ $failures.Add("$Message (unexpected failure: $($_.Exception.Message))")
92
+ }
93
+ }
94
+ }
95
+
96
+ function New-TestIdentity {
97
+ param([string] $Account = 'CONTOSO\fleet')
98
+ return Resolve-ServiceAccountIdentity `
99
+ -Account $Account `
100
+ -MachineName 'EVERDESK2' `
101
+ -DefaultDomain 'EVERDESK2' `
102
+ -DefaultUser 'evereq'
103
+ }
104
+
105
+ # A probe in which everything the node needs is present and provably
106
+ # reachable. Individual tests override exactly the one fact under test, so a
107
+ # finding can only come from that fact.
108
+ function New-TestProbe {
109
+ param([hashtable] $Override = @{})
110
+ $probe = @{
111
+ AccountResolved = $true
112
+ AccountSid = 'S-1-5-21-1-2-3-1001'
113
+ ProfilePath = 'C:\Users\fleet'
114
+ ConfigPath = 'C:\Users\fleet\AppData\Roaming\ever-works-node\node-config.json'
115
+ ConfigPathSource = 'profile'
116
+ ConfigExists = $true
117
+ ConfigAccess = 'granted'
118
+ ClaudePath = 'C:\Users\fleet\AppData\Roaming\npm\claude.cmd'
119
+ ClaudePathState = 'ok'
120
+ ClaudeLoginPath = 'C:\Users\fleet\.claude\.credentials.json'
121
+ ClaudeLoginExists = $true
122
+ CodexPath = 'C:\Users\fleet\AppData\Roaming\npm\codex.cmd'
123
+ CodexPathState = 'ok'
124
+ CodexLoginPath = 'C:\Users\fleet\.codex'
125
+ CodexLoginExists = $true
126
+ WorkspaceRoot = 'D:\fleet-workspaces'
127
+ WorkspaceRootSource = 'flag'
128
+ WorkspaceRootProbedAncestor = 'D:\'
129
+ WorkspaceRootAccess = 'granted'
130
+ WorkspaceRootUnderSystemProfile = $false
131
+ StateDir = 'C:\ProgramData\ever-works-node'
132
+ StateDirAccess = 'granted'
133
+ UnwritableLogPaths = @()
134
+ }
135
+ foreach ($key in $Override.Keys) { $probe[$key] = $Override[$key] }
136
+ return $probe
137
+ }
138
+
139
+ # Two array idioms are in play here and they must not be mixed up. Measured
140
+ # on PowerShell 5.1.19041:
141
+ #
142
+ # * `return $array` ENUMERATES on the way out, so an empty result collapses
143
+ # to $null and a single-element result collapses to a scalar - both of
144
+ # which then fail `.Count` under Set-StrictMode -Version Latest. Hence
145
+ # `return , $array` everywhere an array is returned.
146
+ #
147
+ # * @(...) is the right wrapper around a PIPELINE, whose output is
148
+ # genuinely a stream. It is the WRONG wrapper around a call to a
149
+ # `, `-returning function, which already yields the array as one object:
150
+ # @() then wraps it a second time, producing a one-element array whose
151
+ # member is the real array. That reads as Count 1 for every result,
152
+ # empty or not, and is what made a clean preflight look like one
153
+ # unreadable finding here.
154
+ #
155
+ # So: @() around the Where-Object pipeline below, and a bare assignment of
156
+ # the Get-ServiceInstallPreflightFinding result.
157
+ function Get-FindingCode {
158
+ param(
159
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]] $Findings,
160
+ [Parameter(Mandatory = $true)][string] $Code
161
+ )
162
+ return , @($Findings | Where-Object { $_.Code -eq $Code })
163
+ }
164
+
165
+ function Invoke-Preflight {
166
+ param(
167
+ $Identity = (New-TestIdentity),
168
+ [bool] $HasPassword = $true,
169
+ [string] $Mechanism = 'service',
170
+ [hashtable] $Probe = (New-TestProbe)
171
+ )
172
+ $result = Get-ServiceInstallPreflightFinding `
173
+ -Identity $Identity `
174
+ -HasPassword $HasPassword `
175
+ -Mechanism $Mechanism `
176
+ -Probe $Probe
177
+ return , $result
178
+ }
179
+
180
+ function New-TestAce {
181
+ param(
182
+ [Parameter(Mandatory = $true)][string] $SidValue,
183
+ [Parameter(Mandatory = $true)][int] $Rights,
184
+ [bool] $IsDeny = $false
185
+ )
186
+ return [pscustomobject]@{ SidValue = $SidValue; Rights = $Rights; IsDeny = $IsDeny }
187
+ }
188
+
189
+ # --- Argument quoting -----------------------------------------------------
190
+
191
+ Assert-Equal (ConvertTo-CommandLineArgument 'plain') 'plain' 'an argument with no space or quote must stay bare'
192
+ Assert-Equal (ConvertTo-CommandLineArgument 'C:\Program Files\node.exe') '"C:\Program Files\node.exe"' 'a spaced path must be quoted'
193
+ Assert-Equal (ConvertTo-CommandLineArgument 'C:\dir\') 'C:\dir\' 'a trailing backslash needs no doubling when the argument is not quoted'
194
+ Assert-Equal (ConvertTo-CommandLineArgument 'C:\program files\dir\') '"C:\program files\dir\\"' 'a trailing backslash must be doubled inside a quoted argument'
195
+ Assert-Equal (ConvertTo-CommandLineArgument 'say "hi"') '"say \"hi\""' 'embedded quotes must be escaped'
196
+ Assert-Equal (ConvertTo-CommandLineArgument '') '""' 'an empty argument must survive as an empty quoted argument'
197
+ Assert-Equal (ConvertTo-CommandLine @('a', 'b c')) 'a "b c"' 'a whole vector must join with single spaces'
198
+ Assert-Equal (Format-NativeCommandLine -FilePath 'C:\Program Files\nssm\nssm.exe' -Arguments @('set', 'x')) '"C:\Program Files\nssm\nssm.exe" set x' 'a spaced program path must be quoted in a rendered command line'
199
+
200
+ # --- Account normalisation ------------------------------------------------
201
+
202
+ $default = New-TestIdentity -Account ''
203
+ Assert-Equal $default.Kind 'local' 'a blank -ServiceAccount must resolve to the installing account'
204
+ Assert-Equal $default.ServiceLogonName '.\evereq' 'the default account must reach the SCM in its local form'
205
+ Assert-Equal $default.TaskUserId 'EVERDESK2\evereq' 'the default account must reach the task scheduler machine-qualified'
206
+ Assert-True $default.RequiresPassword 'the installing account is a real user and needs a password'
207
+ Assert-True $default.NeedsLogonRight 'a real user account has to be granted SeServiceLogonRight'
208
+
209
+ $domain = New-TestIdentity -Account 'CONTOSO\fleet'
210
+ Assert-Equal $domain.Kind 'domain' 'DOMAIN\User must resolve as a domain account'
211
+ Assert-Equal $domain.ServiceLogonName 'CONTOSO\fleet' 'a domain account keeps its domain for the SCM'
212
+ Assert-Equal $domain.TaskUserId 'CONTOSO\fleet' 'a domain account keeps its domain for the task scheduler'
213
+
214
+ foreach ($form in @('.\alice', 'EVERDESK2\alice', 'everdesk2\alice', 'alice')) {
215
+ $local = New-TestIdentity -Account $form
216
+ Assert-Equal $local.Kind 'local' "'$form' must resolve as a local account"
217
+ Assert-Equal $local.ServiceLogonName '.\alice' "'$form' must reach the SCM as .\alice"
218
+ Assert-Equal $local.TaskUserId 'EVERDESK2\alice' "'$form' must reach the task scheduler as EVERDESK2\alice"
219
+ Assert-True $local.RequiresPassword "'$form' is a real user and needs a password"
220
+ }
221
+
222
+ $upn = New-TestIdentity -Account 'alice@contoso.com'
223
+ Assert-Equal $upn.Kind 'upn' 'a UPN must resolve as a UPN'
224
+ Assert-Equal $upn.ServiceLogonName 'alice@contoso.com' 'a UPN passes through to the SCM unchanged'
225
+ Assert-True $upn.RequiresPassword 'a UPN names a real user and needs a password'
226
+
227
+ $gmsa = New-TestIdentity -Account 'CONTOSO\gmsa-node$'
228
+ Assert-Equal $gmsa.Kind 'gmsa' 'a trailing $ on a domain account marks a gMSA'
229
+ Assert-False $gmsa.RequiresPassword 'a gMSA password is held by the domain controller, not the operator'
230
+ Assert-False $gmsa.IsBuiltIn 'a gMSA is a real account with a real profile, unlike the built-ins'
231
+ # The SCM takes a gMSA name with an EMPTY password, but the account still has
232
+ # to hold SeServiceLogonRight like any other - which is the half of the job
233
+ # NSSM used to do and this installer now does itself.
234
+ Assert-True $gmsa.NeedsLogonRight 'a gMSA still needs SeServiceLogonRight'
235
+
236
+ # A LOCAL name ending in '$' is not a gMSA: gMSAs are domain-scoped by
237
+ # definition, so this must stay a password-taking local account rather than
238
+ # silently becoming password-less.
239
+ $localDollar = New-TestIdentity -Account '.\weird$'
240
+ Assert-Equal $localDollar.Kind 'local' 'a local name ending in $ is not a gMSA'
241
+ Assert-True $localDollar.RequiresPassword 'a local name ending in $ still needs a password'
242
+
243
+ foreach ($case in @(
244
+ @{ Account = 'LocalSystem'; Service = 'LocalSystem'; Task = 'NT AUTHORITY\SYSTEM' },
245
+ @{ Account = 'NT AUTHORITY\SYSTEM'; Service = 'LocalSystem'; Task = 'NT AUTHORITY\SYSTEM' },
246
+ @{ Account = 'networkservice'; Service = 'NT AUTHORITY\NetworkService'; Task = 'NT AUTHORITY\NETWORK SERVICE' },
247
+ @{ Account = 'NT AUTHORITY\LocalService'; Service = 'NT AUTHORITY\LocalService'; Task = 'NT AUTHORITY\LOCAL SERVICE' }
248
+ )) {
249
+ $builtIn = New-TestIdentity -Account $case.Account
250
+ Assert-True $builtIn.IsBuiltIn "'$($case.Account)' must be recognised as a built-in service account"
251
+ Assert-False $builtIn.RequiresPassword "'$($case.Account)' takes no password"
252
+ Assert-False $builtIn.NeedsLogonRight "'$($case.Account)' already holds the service logon right implicitly"
253
+ Assert-Equal $builtIn.ServiceLogonName $case.Service "'$($case.Account)' must use the SCM's spelling"
254
+ Assert-Equal $builtIn.TaskUserId $case.Task "'$($case.Account)' must use the task scheduler's spelling"
255
+ }
256
+
257
+ $virtual = New-TestIdentity -Account 'NT SERVICE\EverWorksNode'
258
+ Assert-Equal $virtual.Kind 'virtual' 'NT SERVICE\<name> is a virtual service account'
259
+ Assert-False $virtual.RequiresPassword 'a virtual account password is derived by the SCM'
260
+ Assert-False $virtual.NeedsLogonRight 'a virtual account is granted the service logon right by the SCM itself'
261
+
262
+ Assert-ScriptFailsLike -Pattern '*more than one*' -Message 'two separators must be refused' -Action {
263
+ New-TestIdentity -Account 'A\B\C'
264
+ }
265
+ Assert-ScriptFailsLike -Pattern '*mixes*' -Message 'mixing DOMAIN\User and UPN forms must be refused' -Action {
266
+ New-TestIdentity -Account 'CONTOSO\alice@contoso.com'
267
+ }
268
+ Assert-ScriptFailsLike -Pattern '*names no user*' -Message 'a trailing separator must be refused' -Action {
269
+ New-TestIdentity -Account 'CONTOSO\'
270
+ }
271
+ Assert-ScriptFailsLike -Pattern '*names no domain*' -Message 'a leading separator must be refused' -Action {
272
+ New-TestIdentity -Account '\alice'
273
+ }
274
+ Assert-ScriptFailsLike -Pattern '*not a valid Windows account name*' -Message 'a forward slash must be refused' -Action {
275
+ New-TestIdentity -Account 'CONTOSO/alice'
276
+ }
277
+ Assert-ScriptFailsLike -Pattern '*not a valid UPN*' -Message 'a malformed UPN must be refused' -Action {
278
+ New-TestIdentity -Account 'alice@'
279
+ }
280
+
281
+ Assert-True (Test-ServiceAccountRequiresPassword -Account 'CONTOSO\fleet' -MachineName 'EVERDESK2') 'a domain user needs a password'
282
+ Assert-True (Test-ServiceAccountRequiresPassword -Account '' -MachineName 'EVERDESK2') 'the installing account needs a password'
283
+ Assert-False (Test-ServiceAccountRequiresPassword -Account 'CONTOSO\gmsa$' -MachineName 'EVERDESK2') 'a gMSA needs none'
284
+ Assert-False (Test-ServiceAccountRequiresPassword -Account 'LocalSystem' -MachineName 'EVERDESK2') 'LocalSystem needs none'
285
+
286
+ # --- Access verdicts ------------------------------------------------------
287
+
288
+ $read = [int] [Security.AccessControl.FileSystemRights]::Read
289
+ $modify = [int] [Security.AccessControl.FileSystemRights]::Modify
290
+ $accountSid = 'S-1-5-21-1-2-3-1001'
291
+
292
+ Assert-Equal (Get-AccessVerdictFromAcl `
293
+ -Rules @((New-TestAce -SidValue $accountSid -Rights ([int] [Security.AccessControl.FileSystemRights]::FullControl))) `
294
+ -IsProtected $true -ConsideredSids @($accountSid) -WantedRights $read) 'granted' 'one ACE covering the wanted rights grants them'
295
+
296
+ # Allow ACEs are cumulative: two partial grants together satisfy Modify even
297
+ # though neither does alone.
298
+ Assert-Equal (Get-AccessVerdictFromAcl `
299
+ -Rules @(
300
+ (New-TestAce -SidValue $accountSid -Rights ([int] [Security.AccessControl.FileSystemRights]::Read)),
301
+ (New-TestAce -SidValue 'S-1-1-0' -Rights $modify)
302
+ ) `
303
+ -IsProtected $true -ConsideredSids @($accountSid, 'S-1-1-0') -WantedRights $modify) 'granted' 'allow ACEs must accumulate across the considered SIDs'
304
+
305
+ # The regression: a Deny ACE covering only PART of the wanted rights still
306
+ # denies the request. Requiring it to be a superset reported 'granted' for a
307
+ # path the account cannot use.
308
+ Assert-Equal (Get-AccessVerdictFromAcl `
309
+ -Rules @(
310
+ (New-TestAce -SidValue $accountSid -Rights ([int] [Security.AccessControl.FileSystemRights]::FullControl)),
311
+ (New-TestAce -SidValue $accountSid -Rights ([int] [Security.AccessControl.FileSystemRights]::ReadData) -IsDeny $true)
312
+ ) `
313
+ -IsProtected $true -ConsideredSids @($accountSid) -WantedRights $read) 'denied' 'a partial Deny ACE must deny, not be skipped'
314
+
315
+ Assert-Equal (Get-AccessVerdictFromAcl `
316
+ -Rules @((New-TestAce -SidValue 'S-1-5-21-9-9-9-500' -Rights $modify -IsDeny $true)) `
317
+ -IsProtected $false -ConsideredSids @($accountSid) -WantedRights $read) 'unknown' 'a Deny ACE for another SID must not touch this verdict'
318
+
319
+ # The shape node-io.ts writes the config file in: inheritance stripped, one
320
+ # ACE for the enrolling user. That is the only case where absence PROVES a
321
+ # denial - and it is the check that matters most.
322
+ Assert-Equal (Get-AccessVerdictFromAcl `
323
+ -Rules @((New-TestAce -SidValue 'S-1-5-21-9-9-9-500' -Rights ([int] [Security.AccessControl.FileSystemRights]::FullControl))) `
324
+ -IsProtected $true -ConsideredSids @($accountSid) -WantedRights $read) 'denied' 'a protected DACL with no matching ACE proves a denial'
325
+
326
+ Assert-Equal (Get-AccessVerdictFromAcl `
327
+ -Rules @((New-TestAce -SidValue 'S-1-5-21-9-9-9-500' -Rights ([int] [Security.AccessControl.FileSystemRights]::FullControl))) `
328
+ -IsProtected $false -ConsideredSids @($accountSid) -WantedRights $read) 'unknown' 'an inheriting DACL with no matching ACE proves nothing'
329
+
330
+ Assert-Equal (Get-AccessVerdictFromAcl -Rules @() -IsProtected $false -ConsideredSids @($accountSid) -WantedRights $read) 'unknown' 'an empty inheriting DACL is inconclusive'
331
+
332
+ foreach ($kind in @('local', 'domain', 'upn')) {
333
+ $sids = Get-AccessProbeSidList -Sid $accountSid -IdentityKind $kind
334
+ Assert-True ($sids -contains 'S-1-5-32-545') "a $kind account is a member of BUILTIN\Users"
335
+ }
336
+ foreach ($kind in @('gmsa', 'virtual')) {
337
+ $sids = Get-AccessProbeSidList -Sid $accountSid -IdentityKind $kind
338
+ # Treating a gMSA as a member of the local Users group would turn a
339
+ # BUILTIN\Users ACE into a false 'granted' - the one wrong answer that
340
+ # hides the bug this installer exists to fix.
341
+ Assert-False ($sids -contains 'S-1-5-32-545') "a $kind account is NOT a member of BUILTIN\Users"
342
+ Assert-True ($sids -contains 'S-1-5-11') "a $kind account still authenticates"
343
+ }
344
+ $localSids = Get-AccessProbeSidList -Sid $accountSid -IdentityKind 'local'
345
+ Assert-True ($localSids -contains $accountSid) 'the account SID itself is always considered'
346
+ Assert-True ($localSids -contains 'S-1-1-0') 'Everyone is always considered'
347
+ Assert-False ($localSids -contains 'S-1-5-32-544') 'Administrators must NOT be assumed'
348
+
349
+ # --- Model CLI pin state --------------------------------------------------
350
+
351
+ Assert-Equal (Get-ModelCliPinStateFromFacts -PinnedPath '' -Exists $false -Extension '' -AccessVerdict 'unknown') 'unpinned' 'no pin is unpinned'
352
+ Assert-Equal (Get-ModelCliPinStateFromFacts -PinnedPath $null -Exists $false -Extension '' -AccessVerdict 'unknown') 'unpinned' 'a null pin is unpinned'
353
+ Assert-Equal (Get-ModelCliPinStateFromFacts -PinnedPath 'C:\npm\claude.cmd' -Exists $false -Extension '.cmd' -AccessVerdict 'unknown') 'missing' 'a pin that does not exist is missing'
354
+ Assert-Equal (Get-ModelCliPinStateFromFacts -PinnedPath 'C:\npm\claude' -Exists $true -Extension '' -AccessVerdict 'granted') 'not-launchable' "npm's extension-less shim is a bash script and cannot be spawned"
355
+ Assert-Equal (Get-ModelCliPinStateFromFacts -PinnedPath 'C:\npm\claude.ps1' -Exists $true -Extension '.ps1' -AccessVerdict 'granted') 'not-launchable' 'a .ps1 shim is not launchable by a service manager'
356
+ Assert-Equal (Get-ModelCliPinStateFromFacts -PinnedPath 'C:\npm\claude.CMD' -Exists $true -Extension '.CMD' -AccessVerdict 'granted') 'ok' 'the extension check must be case-insensitive'
357
+ Assert-Equal (Get-ModelCliPinStateFromFacts -PinnedPath 'C:\npm\claude.cmd' -Exists $true -Extension '.cmd' -AccessVerdict 'denied') 'unreadable' 'a pin the account cannot read is unreadable'
358
+ Assert-Equal (Get-ModelCliPinStateFromFacts -PinnedPath 'C:\npm\claude.cmd' -Exists $true -Extension '.cmd' -AccessVerdict 'unknown') 'ok' 'an inconclusive ACL must not disable a pin'
359
+ Assert-True ((Get-WindowsLaunchableExtension) -contains '.exe') 'the launchable set must mirror WINDOWS_LAUNCHABLE'
360
+
361
+ # --- Path containment -----------------------------------------------------
362
+
363
+ Assert-True (Test-PathUnder -Path 'C:\Windows\System32\config\systemprofile\x' -Ancestor 'C:\Windows\System32\config\systemprofile') 'a child path is under its ancestor'
364
+ Assert-True (Test-PathUnder -Path 'C:\Users\Fleet\x' -Ancestor 'c:\users\fleet') 'containment is case-insensitive, like the filesystem'
365
+ Assert-True (Test-PathUnder -Path 'D:\root\a' -Ancestor 'D:\root\') 'a trailing separator on the ancestor must not matter'
366
+ Assert-True (Test-PathUnder -Path 'D:\root' -Ancestor 'D:\root') 'a path is under itself'
367
+ # The prefix trap: D:\fleet2 must not count as living under D:\fleet.
368
+ Assert-False (Test-PathUnder -Path 'D:\fleet2\x' -Ancestor 'D:\fleet') 'a sibling with a shared prefix is not a child'
369
+ Assert-False (Test-PathUnder -Path 'D:\other' -Ancestor 'D:\root') 'an unrelated path is not a child'
370
+ Assert-False (Test-PathUnder -Path '' -Ancestor 'D:\root') 'an empty path is not a child of anything'
371
+ Assert-False (Test-PathUnder -Path 'D:\root\a' -Ancestor '') 'nothing is a child of an empty ancestor'
372
+
373
+ # --- Service configuration plan -------------------------------------------
374
+
375
+ $planArguments = @{
376
+ Name = 'EverWorksNode'
377
+ NodeExe = 'C:\Program Files\nodejs\node.exe'
378
+ ArgumentLine = '"C:\pkg\cli.js" start --work'
379
+ StateDir = 'C:\ProgramData\ever-works-node'
380
+ ServiceLogonName = 'CONTOSO\fleet'
381
+ }
382
+
383
+ $freshPlan = New-ServiceConfigurationPlan @planArguments -WithPassword
384
+ $nssmVerbs = @($freshPlan | Where-Object { $_.Kind -eq 'nssm' } | ForEach-Object { $_.Arguments[0] })
385
+ Assert-Equal $nssmVerbs[0] 'install' 'a fresh install must register the service first'
386
+ Assert-Equal @($nssmVerbs | Where-Object { $_ -eq 'install' }).Count 1 'the service must be registered exactly once'
387
+
388
+ $existingPlan = New-ServiceConfigurationPlan @planArguments -WithPassword -ServiceExists
389
+ Assert-Equal @($existingPlan | Where-Object { $_.Kind -eq 'nssm' -and $_.Arguments[0] -eq 'install' }).Count 0 'an existing service must not be re-registered'
390
+
391
+ # The regression this whole change exists to prevent: with no identity step
392
+ # the service keeps the start name CreateService gave it, which the Win32 API
393
+ # defines as LocalSystem.
394
+ foreach ($case in @(
395
+ @{ Label = 'fresh install'; Plan = $freshPlan },
396
+ @{ Label = 're-run over an existing service'; Plan = $existingPlan }
397
+ )) {
398
+ $kinds = @($case.Plan | ForEach-Object { $_.Kind })
399
+ Assert-True ($kinds -contains 'identity') "$($case.Label) must set the logon identity, or the service silently runs as LocalSystem"
400
+
401
+ $settingNames = @($case.Plan | ForEach-Object {
402
+ if ($_.Kind -eq 'identity') { 'ObjectName' } elseif ($_.Arguments[0] -eq 'set') { $_.Arguments[2] } else { $_.Arguments[0] }
403
+ })
404
+ $identityIndex = [Array]::IndexOf($settingNames, 'ObjectName')
405
+ $appParametersIndex = [Array]::IndexOf($settingNames, 'AppParameters')
406
+ $startIndex = [Array]::IndexOf($settingNames, 'Start')
407
+
408
+ # Convergence: identity must be re-applied on a re-run exactly like
409
+ # every other flag, so changing -ClaudePath cannot silently keep the
410
+ # first install's account.
411
+ Assert-True ($identityIndex -gt $appParametersIndex) "$($case.Label) must set the identity inside the convergent block"
412
+ Assert-True ($identityIndex -lt $startIndex) "$($case.Label) must set the identity before the service is marked auto-start"
413
+
414
+ # SERVICE_AUTO_START last, so a run that dies part-way cannot leave a
415
+ # half-configured service that comes back as LocalSystem at next boot.
416
+ Assert-Equal $startIndex ($settingNames.Count - 1) "$($case.Label) must mark the service auto-start LAST"
417
+ }
418
+
419
+ # Look the step up defensively. Deleting the identity call is the exact
420
+ # regression these tests exist to catch, and it must report THAT rather than
421
+ # crashing on an out-of-range index three assertions later.
422
+ function Get-IdentityStep {
423
+ param(
424
+ [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]] $Plan,
425
+ [Parameter(Mandatory = $true)][string] $Label
426
+ )
427
+ $steps = @($Plan | Where-Object { $_.Kind -eq 'identity' })
428
+ Assert-Equal $steps.Count 1 "$Label must contain exactly one identity step, or the service silently runs as LocalSystem"
429
+ if ($steps.Count -ne 1) {
430
+ # Everything below depends on it; stop with the real reason.
431
+ throw ("install-service logic test failures:`n- " + ($failures -join "`n- "))
432
+ }
433
+ return $steps[0]
434
+ }
435
+
436
+ $identityStep = Get-IdentityStep -Plan $freshPlan -Label 'the fresh-install plan'
437
+ Assert-Equal $identityStep.Account 'CONTOSO\fleet' 'the identity step must carry the resolved account'
438
+ Assert-Equal $identityStep.Arguments[0] 'EverWorksNode' 'the identity step must name the service'
439
+ Assert-Equal $identityStep.Arguments.Count 2 'the plan itself must never carry the password'
440
+ Assert-True $identityStep.RequiresPassword 'the identity step must be flagged as taking a password'
441
+
442
+ $passwordlessPlan = New-ServiceConfigurationPlan @planArguments
443
+ $passwordlessIdentity = Get-IdentityStep -Plan $passwordlessPlan -Label 'the password-less plan'
444
+ Assert-False $passwordlessIdentity.RequiresPassword 'a gMSA or built-in identity step must not ask for a password'
445
+
446
+ # Logging parity is part of the plan, not an afterthought.
447
+ $planSettings = @($freshPlan | Where-Object { $_.Kind -eq 'nssm' -and $_.Arguments[0] -eq 'set' } | ForEach-Object { $_.Arguments[2] })
448
+ foreach ($required in @('Application', 'AppParameters', 'AppDirectory', 'Start', 'AppStdout', 'AppStderr', 'AppStopMethodConsole', 'AppExit', 'AppRestartDelay')) {
449
+ Assert-True ($planSettings -contains $required) "the plan must still set $required"
450
+ }
451
+ # The identity is no longer an nssm call at all: NSSM takes the password as a
452
+ # command-line argument, which process-creation telemetry records.
453
+ Assert-False ($planSettings -contains 'ObjectName') 'the password must not reach nssm as an ObjectName argument'
454
+
455
+ # Self-build slice AP: both logs rotate - at start AND online - past the
456
+ # shared policy, on a fresh install and on a re-run over an old service.
457
+ $rotationPolicy = Get-NodeLogRotationPolicy
458
+ Assert-Equal $rotationPolicy.Bytes 10485760 'the rotation threshold is 10 MB'
459
+ foreach ($case in @(
460
+ @{ Label = 'fresh install'; Plan = $freshPlan },
461
+ @{ Label = 're-run over an existing service'; Plan = $existingPlan }
462
+ )) {
463
+ $rotateSettings = @{}
464
+ foreach ($step in @($case.Plan | Where-Object { $_.Kind -eq 'nssm' -and $_.Arguments[0] -eq 'set' -and $_.Arguments[2] -like 'AppRotate*' })) {
465
+ $rotateSettings[$step.Arguments[2]] = $step.Arguments[3]
466
+ }
467
+ Assert-Equal $rotateSettings['AppRotateFiles'] '1' "$($case.Label) must rotate the logs at service start"
468
+ Assert-Equal $rotateSettings['AppRotateOnline'] '1' "$($case.Label) must rotate the logs while the service runs"
469
+ Assert-Equal $rotateSettings['AppRotateBytes'] '10485760' "$($case.Label) must rotate past the shared size policy"
470
+ }
471
+
472
+ # --- Redaction ------------------------------------------------------------
473
+
474
+ $renderedSecret = Format-ServicePlanStep -NssmPath 'C:\nssm.exe' -Step $identityStep
475
+ Assert-True ($renderedSecret -like '*<redacted -ServicePassword>*') 'a password-bearing step must render the placeholder'
476
+ Assert-True ($renderedSecret -like '*-Account CONTOSO\fleet*') 'the rendering must still show which account is being set'
477
+ Assert-False ($renderedSecret -like '*nssm*') 'the identity step must not be rendered as an nssm call'
478
+
479
+ $renderedPlain = Format-ServicePlanStep -NssmPath 'C:\nssm.exe' -Step $passwordlessIdentity
480
+ Assert-False ($renderedPlain -like '*redacted*') 'a step with no password must not claim to have redacted one'
481
+
482
+ $renderedNssm = Format-ServicePlanStep -NssmPath 'C:\Program Files\nssm\nssm.exe' -Step $freshPlan[0]
483
+ Assert-True ($renderedNssm -like '"C:\Program Files\nssm\nssm.exe"*') 'a spaced nssm path must be quoted in the rendering'
484
+
485
+ # --- State directory and log ACLs -----------------------------------------
486
+
487
+ $aclPlanNoLogs = New-StateDirectoryAclPlan -StateDir 'C:\ProgramData\ever-works-node' -ExistingLogPaths @() -Account 'EVERDESK2\evereq'
488
+ Assert-Equal $aclPlanNoLogs.Count 1 'with no logs yet, only the state directory is granted'
489
+ Assert-Equal $aclPlanNoLogs[0].Arguments[0] 'C:\ProgramData\ever-works-node' 'the first grant must target the state directory'
490
+ Assert-Equal $aclPlanNoLogs[0].Arguments[1] '/grant' 'the ACE must be ADDED, never used to replace the DACL'
491
+ Assert-Equal $aclPlanNoLogs[0].Arguments[2] 'EVERDESK2\evereq:(OI)(CI)(M)' 'the directory ACE must be inheritable so new log files carry it'
492
+
493
+ $aclPlanWithLogs = New-StateDirectoryAclPlan `
494
+ -StateDir 'C:\ProgramData\ever-works-node' `
495
+ -ExistingLogPaths @('C:\ProgramData\ever-works-node\node.log', 'C:\ProgramData\ever-works-node\node.err.log') `
496
+ -Account 'EVERDESK2\evereq'
497
+ Assert-Equal $aclPlanWithLogs.Count 3 'every log left behind by a previous identity must be granted too'
498
+ Assert-Equal $aclPlanWithLogs[1].Arguments[2] 'EVERDESK2\evereq:(M)' 'a FILE ACE carries no inheritance flags'
499
+ Assert-True ($aclPlanWithLogs[2].Arguments[0] -like '*node.err.log') 'stderr log must be granted as well as stdout'
500
+
501
+ $aclRendered = Format-NativeCommandLine -FilePath 'C:\Windows\System32\icacls.exe' -Arguments $aclPlanWithLogs[0].Arguments
502
+ Assert-True ($aclRendered -like '*icacls.exe C:\ProgramData\ever-works-node /grant *') 'the ACL step must render as a readable icacls call'
503
+
504
+ $spacedAccountPlan = New-StateDirectoryAclPlan -StateDir 'C:\state dir' -ExistingLogPaths @() -Account 'CONTOSO\fleet svc'
505
+ $spacedRendered = Format-NativeCommandLine -FilePath 'C:\Windows\System32\icacls.exe' -Arguments $spacedAccountPlan[0].Arguments
506
+ Assert-True ($spacedRendered -like '*"C:\state dir"*') 'a spaced state directory must be quoted'
507
+ Assert-True ($spacedRendered -like '*"CONTOSO\fleet svc:(OI)(CI)(M)"*') 'a spaced account name must be quoted inside the ACE'
508
+
509
+ # --- Scheduled-task plan --------------------------------------------------
510
+
511
+ $s4uPlan = New-ScheduledTaskRegistrationPlan -Identity (New-TestIdentity -Account '.\alice')
512
+ Assert-Equal $s4uPlan.ParameterSet 'Principal' 'without a password the task must use the -Principal parameter set'
513
+ Assert-Equal $s4uPlan.LogonType 'S4U' 'without a password a real user has no logon but S4U'
514
+ Assert-Equal $s4uPlan.PrincipalParameters.UserId 'EVERDESK2\alice' 'the S4U principal must name the chosen account, not the installer'
515
+ Assert-Equal $s4uPlan.RegisterParameters.Count 0 'the -Principal set takes no -User/-Password'
516
+
517
+ $passwordPlan = New-ScheduledTaskRegistrationPlan -Identity (New-TestIdentity -Account '.\alice') -WithPassword
518
+ Assert-Equal $passwordPlan.ParameterSet 'User' 'a password forces the -User parameter set'
519
+ Assert-Equal $passwordPlan.LogonType 'Password' 'a password must flip the logon type away from S4U'
520
+ Assert-Equal $passwordPlan.PrincipalParameters $null 'the -User set is disjoint from -Principal'
521
+ Assert-Equal $passwordPlan.RegisterParameters.User 'EVERDESK2\alice' 'the -User set must name the chosen account'
522
+ Assert-False ($passwordPlan.RegisterParameters.Contains('Password')) 'the plan must never carry the password itself'
523
+
524
+ # A gMSA HAS a password - the domain controller holds it - so its supported
525
+ # shape is a Password logon with no value. S4U would be wrong and would also
526
+ # not read the account's stored credentials.
527
+ $gmsaTaskPlan = New-ScheduledTaskRegistrationPlan -Identity (New-TestIdentity -Account 'CONTOSO\gmsa-node$')
528
+ Assert-Equal $gmsaTaskPlan.ParameterSet 'Principal' 'a gMSA takes no -Password, so it uses the -Principal set'
529
+ Assert-Equal $gmsaTaskPlan.LogonType 'Password' 'a gMSA is a Password logon with no supplied value, never S4U'
530
+
531
+ $builtInTaskPlan = New-ScheduledTaskRegistrationPlan -Identity (New-TestIdentity -Account 'LocalSystem')
532
+ Assert-Equal $builtInTaskPlan.LogonType 'ServiceAccount' 'a built-in account is a ServiceAccount logon'
533
+
534
+ # --- Scheduled-task action ------------------------------------------------
535
+
536
+ $actionSpec = New-ScheduledTaskActionSpec `
537
+ -ComSpec 'C:\Windows\system32\cmd.exe' `
538
+ -NodeExe 'C:\Program Files\nodejs\node.exe' `
539
+ -ArgumentLine '"C:\pkg\cli.js" start --work' `
540
+ -StdoutPath 'C:\ProgramData\ever-works-node\node.log' `
541
+ -StderrPath 'C:\ProgramData\ever-works-node\node.err.log'
542
+
543
+ Assert-Equal $actionSpec.Execute 'C:\Windows\system32\cmd.exe' 'redirection requires cmd.exe as the executable'
544
+ Assert-True ($actionSpec.Argument.StartsWith('/s /c "')) 'cmd.exe needs /s so only the outermost quotes are stripped'
545
+ Assert-True ($actionSpec.Argument.EndsWith('"')) 'the /s command string must be quote-delimited at both ends'
546
+ Assert-True ($actionSpec.Argument -like '*>> C:\ProgramData\ever-works-node\node.log*') 'stdout must be appended to node.log, matching NSSM AppStdout'
547
+ Assert-True ($actionSpec.Argument -like '*2>> C:\ProgramData\ever-works-node\node.err.log*') 'stderr must be appended to node.err.log, matching NSSM AppStderr'
548
+ Assert-True ($actionSpec.Argument -like '*"C:\Program Files\nodejs\node.exe"*') 'the spaced node.exe path must stay quoted inside the cmd string'
549
+
550
+ $spacedLogSpec = New-ScheduledTaskActionSpec `
551
+ -ComSpec 'C:\Windows\system32\cmd.exe' `
552
+ -NodeExe 'C:\node.exe' `
553
+ -ArgumentLine 'cli.js' `
554
+ -StdoutPath 'C:\log dir\node.log' `
555
+ -StderrPath 'C:\log dir\node.err.log'
556
+ Assert-True ($spacedLogSpec.Argument -like '*>> "C:\log dir\node.log"*') 'a spaced log path must be quoted for cmd.exe'
557
+
558
+ # --- Scheduled-task log rotation (self-build slice AP) ---------------------
559
+
560
+ $rotationSpec = New-ScheduledTaskRotationActionSpec `
561
+ -PowerShellExe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
562
+ -LogPaths @('C:\ProgramData\ever-works-node\node.log', "C:\it's here\node.err.log") `
563
+ -RotateBytes 10485760 `
564
+ -Keep 5
565
+ Assert-Equal $rotationSpec.Execute 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' 'the rotation action runs Windows PowerShell'
566
+ Assert-True ($rotationSpec.Argument.StartsWith('-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ')) 'the rotation script travels encoded, never as a re-parsed command line'
567
+ $encodedScript = $rotationSpec.Argument.Substring($rotationSpec.Argument.LastIndexOf(' ') + 1)
568
+ $decodedScript = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedScript))
569
+ Assert-Equal $decodedScript $rotationSpec.Script 'the encoded command must decode to exactly the reviewed script'
570
+ Assert-True ($decodedScript.Contains("'C:\ProgramData\ever-works-node\node.log'")) 'stdout log must be rotated'
571
+ Assert-True ($decodedScript.Contains("'C:\it''s here\node.err.log'")) 'a quote in a log path must be doubled inside its single-quoted literal'
572
+ Assert-True ($decodedScript.Contains('-lt 10485760')) 'the threshold must be the policy bytes'
573
+ Assert-True ($decodedScript.TrimEnd().EndsWith('exit 0')) 'a rotation problem must never stop the node action'
574
+ $parseErrors = $null
575
+ [void][Management.Automation.Language.Parser]::ParseInput($decodedScript, [ref]$null, [ref]$parseErrors)
576
+ Assert-Equal @($parseErrors).Count 0 'the generated rotation script must parse under Windows PowerShell'
577
+
578
+ # Run the generated script for real, against a scratch directory under TEMP
579
+ # (the only thing in this file that touches a disk, and it removes it).
580
+ $rotationDir = Join-Path ([IO.Path]::GetTempPath()) ('ew-rotation-test-' + [Guid]::NewGuid().ToString('N'))
581
+ New-Item -ItemType Directory -Path $rotationDir | Out-Null
582
+ try {
583
+ $bigLog = Join-Path $rotationDir 'node.log'
584
+ $smallLog = Join-Path $rotationDir 'node.err.log'
585
+ Set-Content -LiteralPath $bigLog -Value ('x' * 64) -Encoding Ascii
586
+ Set-Content -LiteralPath $smallLog -Value 'x' -Encoding Ascii
587
+ foreach ($stamp in @('20260101T000000', '20260102T000000', '20260103T000000')) {
588
+ Set-Content -LiteralPath (Join-Path $rotationDir "node.log.$stamp") -Value 'old' -Encoding Ascii
589
+ }
590
+ Set-Content -LiteralPath (Join-Path $rotationDir 'node.log.keep-me') -Value 'not a rotation' -Encoding Ascii
591
+ $smallScript = New-LogRotationScript -LogPaths @($bigLog, $smallLog, (Join-Path $rotationDir 'missing.log')) -RotateBytes 32 -Keep 2
592
+ $psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
593
+ $encodedSmall = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($smallScript))
594
+ & $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedSmall
595
+ Assert-Equal $LASTEXITCODE 0 'the rotation script must exit 0'
596
+ Assert-False (Test-Path -LiteralPath $bigLog) 'a log past the threshold must be rotated away'
597
+ Assert-True (Test-Path -LiteralPath $smallLog) 'a log under the threshold must be left alone'
598
+ $rotatedNames = @(Get-ChildItem -LiteralPath $rotationDir -File | Where-Object { $_.Name -match '^node\.log\.\d{8}T\d{6}$' } | ForEach-Object { $_.Name })
599
+ Assert-Equal $rotatedNames.Count 2 'only the newest Keep rotated generations survive'
600
+ Assert-False ($rotatedNames -contains 'node.log.20260101T000000') 'the oldest generation is the one dropped'
601
+ Assert-True (Test-Path -LiteralPath (Join-Path $rotationDir 'node.log.keep-me')) 'a file that is not a rotation must never be touched'
602
+ }
603
+ finally {
604
+ Remove-Item -LiteralPath $rotationDir -Recurse -Force -ErrorAction SilentlyContinue
605
+ }
606
+
607
+ # --- NSSM log retention (self-build slice AP, review) ----------------------
608
+
609
+ # NSSM rotates but never deletes a rotated generation, so the service branch
610
+ # registers a prune task. Its script must keep the newest Keep generations of
611
+ # EACH log under BOTH naming schemes, and touch nothing else.
612
+ $pruneSpec = New-ScheduledTaskPruneActionSpec `
613
+ -PowerShellExe 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
614
+ -LogPaths @('C:\ProgramData\ever-works-node\node.log', 'C:\ProgramData\ever-works-node\node.err.log') `
615
+ -Keep 5
616
+ Assert-True ($pruneSpec.Argument.StartsWith('-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ')) 'the prune script travels encoded'
617
+ $decodedPrune = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($pruneSpec.Argument.Substring($pruneSpec.Argument.LastIndexOf(' ') + 1)))
618
+ Assert-Equal $decodedPrune $pruneSpec.Script 'the encoded prune command must decode to exactly the reviewed script'
619
+ Assert-False ($decodedPrune.Contains('Move-Item')) 'the prune script must never rotate the live log - NSSM owns that'
620
+ Assert-True ($decodedPrune.TrimEnd().EndsWith('exit 0')) 'a prune problem must never surface as a task failure'
621
+ $pruneParseErrors = $null
622
+ [void][Management.Automation.Language.Parser]::ParseInput($decodedPrune, [ref]$null, [ref]$pruneParseErrors)
623
+ Assert-Equal @($pruneParseErrors).Count 0 'the generated prune script must parse under Windows PowerShell'
624
+
625
+ $pruneDir = Join-Path ([IO.Path]::GetTempPath()) ('ew-prune-test-' + [Guid]::NewGuid().ToString('N'))
626
+ New-Item -ItemType Directory -Path $pruneDir | Out-Null
627
+ try {
628
+ $liveLog = Join-Path $pruneDir 'node.log'
629
+ $liveErr = Join-Path $pruneDir 'node.err.log'
630
+ Set-Content -LiteralPath $liveLog -Value 'live' -Encoding Ascii
631
+ Set-Content -LiteralPath $liveErr -Value 'live' -Encoding Ascii
632
+ # NSSM's own names, written NEWEST FIRST so modification time disagrees
633
+ # with the timestamp in the name: the name must win.
634
+ foreach ($stamp in @('20260104T000000.400', '20260103T000000.300', '20260102T000000.200', '20260101T000000.100')) {
635
+ Set-Content -LiteralPath (Join-Path $pruneDir "node-$stamp.log") -Value 'old' -Encoding Ascii
636
+ Set-Content -LiteralPath (Join-Path $pruneDir "node.err-$stamp.log") -Value 'old' -Encoding Ascii
637
+ }
638
+ Set-Content -LiteralPath (Join-Path $pruneDir 'node-notes.log') -Value 'not a rotation' -Encoding Ascii
639
+ $smallPrune = New-LogPruneScript -LogPaths @($liveLog, $liveErr) -Keep 2
640
+ $psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
641
+ & $psExe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ([Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($smallPrune)))
642
+ Assert-Equal $LASTEXITCODE 0 'the prune script must exit 0'
643
+ $left = @(Get-ChildItem -LiteralPath $pruneDir -File | ForEach-Object { $_.Name } | Sort-Object)
644
+ Assert-True ($left -contains 'node.log') 'the live stdout log is never pruned'
645
+ Assert-True ($left -contains 'node.err.log') 'the live stderr log is never pruned'
646
+ Assert-True ($left -contains 'node-notes.log') 'a file that is not a rotation must never be touched'
647
+ Assert-Equal (@($left | Where-Object { $_ -match '^node-\d{8}T' }) -join ',') 'node-20260103T000000.300.log,node-20260104T000000.400.log' 'NSSM stdout generations: the newest two BY NAME survive'
648
+ Assert-Equal (@($left | Where-Object { $_ -match '^node\.err-\d{8}T' }) -join ',') 'node.err-20260103T000000.300.log,node.err-20260104T000000.400.log' 'NSSM stderr generations: the newest two survive, counted per log'
649
+ }
650
+ finally {
651
+ Remove-Item -LiteralPath $pruneDir -Recurse -Force -ErrorAction SilentlyContinue
652
+ }
653
+
654
+ # --- Preflight ------------------------------------------------------------
655
+
656
+ Assert-Equal (Invoke-Preflight).Count 0 'a fully healthy service install must produce no findings at all'
657
+ Assert-False (Test-PreflightBlocked -Findings (Invoke-Preflight)) 'a healthy install must not be blocked'
658
+
659
+ # Built-in accounts are the exact bug this change exists to prevent.
660
+ foreach ($builtInAccount in @('LocalSystem', 'NT AUTHORITY\NetworkService', 'NT SERVICE\EverWorksNode')) {
661
+ $builtInFindings = Invoke-Preflight -Identity (New-TestIdentity -Account $builtInAccount) -HasPassword $false
662
+ Assert-Equal (Get-FindingCode -Findings $builtInFindings -Code 'built-in-service-account').Count 1 "$builtInAccount must be refused"
663
+ Assert-True (Test-PreflightBlocked -Findings $builtInFindings) "$builtInAccount must block the install"
664
+ }
665
+
666
+ $noPassword = Invoke-Preflight -HasPassword $false
667
+ Assert-Equal (Get-FindingCode -Findings $noPassword -Code 'password-required').Count 1 'a service account with no password must be an error'
668
+ Assert-True (Test-PreflightBlocked -Findings $noPassword) 'a missing service password must block the install'
669
+
670
+ # The same omission on the task path is survivable, so it warns rather than
671
+ # blocking - but it must still be SAID, because an S4U token cannot unlock
672
+ # the account's DPAPI master key.
673
+ $taskNoPassword = Invoke-Preflight -HasPassword $false -Mechanism 'task'
674
+ Assert-Equal (Get-FindingCode -Findings $taskNoPassword -Code 's4u-logon').Count 1 'the task path must warn about S4U'
675
+ Assert-Equal (Get-FindingCode -Findings $taskNoPassword -Code 'password-required').Count 0 'the task path must not demand a password'
676
+ Assert-False (Test-PreflightBlocked -Findings $taskNoPassword) 'an S4U task install must warn, not block'
677
+
678
+ $gmsaWithPassword = Invoke-Preflight -Identity (New-TestIdentity -Account 'CONTOSO\gmsa$') -HasPassword $true
679
+ Assert-Equal (Get-FindingCode -Findings $gmsaWithPassword -Code 'password-not-accepted').Count 1 'a gMSA must refuse a supplied password'
680
+
681
+ $unknownAccount = Invoke-Preflight -Probe (New-TestProbe @{ AccountResolved = $false })
682
+ Assert-Equal (Get-FindingCode -Findings $unknownAccount -Code 'unknown-account').Count 1 'an unresolvable account must be named'
683
+ Assert-Equal $unknownAccount.Count 1 'an unresolvable account must not produce a cascade of derived complaints'
684
+
685
+ $noProfile = Invoke-Preflight -Probe (New-TestProbe @{ ProfilePath = '' })
686
+ Assert-Equal (Get-FindingCode -Findings $noProfile -Code 'no-profile').Count 1 'an account that never signed in has no %APPDATA%'
687
+ Assert-Equal $noProfile.Count 1 'a missing profile must short-circuit the path-derived checks'
688
+ Assert-True ((Get-FindingCode -Findings $noProfile -Code 'no-profile')[0].Message -like '*Sign in once*') 'a normal account is told to sign in once'
689
+
690
+ # A gMSA is provisioned, never signed in to, so "sign in once as that
691
+ # account" is advice it can never take. The remedy has to be the one that
692
+ # exists.
693
+ $gmsaNoProfile = Invoke-Preflight `
694
+ -Identity (New-TestIdentity -Account 'CONTOSO\gmsa-node$') `
695
+ -HasPassword $false `
696
+ -Probe (New-TestProbe @{ ProfilePath = '' })
697
+ $gmsaProfileMessage = (Get-FindingCode -Findings $gmsaNoProfile -Code 'no-profile')[0].Message
698
+ Assert-True ($gmsaProfileMessage -like '*Install-ADServiceAccount*') 'a gMSA must be told how a gMSA is actually provisioned'
699
+ Assert-False ($gmsaProfileMessage -like '*Sign in once*') 'a gMSA cannot sign in, so it must not be told to'
700
+
701
+ $notEnrolled = Invoke-Preflight -Probe (New-TestProbe @{ ConfigExists = $false })
702
+ Assert-Equal (Get-FindingCode -Findings $notEnrolled -Code 'not-enrolled').Count 1 'a missing node config must be an error'
703
+ Assert-True ((Get-FindingCode -Findings $notEnrolled -Code 'not-enrolled')[0].Message -like '*node-config.json*') 'the not-enrolled message must name the exact path'
704
+
705
+ $configDenied = Invoke-Preflight -Probe (New-TestProbe @{ ConfigAccess = 'denied' })
706
+ Assert-Equal (Get-FindingCode -Findings $configDenied -Code 'config-unreadable').Count 1 'a config the account cannot read must be an error'
707
+ Assert-True ((Get-FindingCode -Findings $configDenied -Code 'config-unreadable')[0].Message -like '*icacls*') 'the unreadable-config message must carry the remedy'
708
+
709
+ $configUnknown = Invoke-Preflight -Probe (New-TestProbe @{ ConfigAccess = 'unknown' })
710
+ Assert-Equal (Get-FindingCode -Findings $configUnknown -Code 'config-access-unproven').Count 1 'an inconclusive ACL must warn'
711
+ Assert-False (Test-PreflightBlocked -Findings $configUnknown) 'an inconclusive ACL must not block a correct install'
712
+
713
+ $configOverride = Invoke-Preflight -Probe (New-TestProbe @{ ConfigPathSource = 'environment-override' })
714
+ Assert-Equal (Get-FindingCode -Findings $configOverride -Code 'config-env-override').Count 1 'a machine-wide config override must be called out'
715
+
716
+ foreach ($case in @(
717
+ @{ State = 'missing'; Code = 'claude-code-pin-missing' },
718
+ @{ State = 'not-launchable'; Code = 'claude-code-pin-not-launchable' },
719
+ @{ State = 'unreadable'; Code = 'claude-code-pin-unreadable' }
720
+ )) {
721
+ $pinFindings = Invoke-Preflight -Probe (New-TestProbe @{ ClaudePathState = $case.State })
722
+ Assert-Equal (Get-FindingCode -Findings $pinFindings -Code $case.Code).Count 1 "a '$($case.State)' Claude pin must raise $($case.Code)"
723
+ Assert-True (Test-PreflightBlocked -Findings $pinFindings) "a '$($case.State)' Claude pin must block the install"
724
+ }
725
+
726
+ $codexMissing = Invoke-Preflight -Probe (New-TestProbe @{ CodexPathState = 'missing' })
727
+ Assert-Equal (Get-FindingCode -Findings $codexMissing -Code 'codex-pin-missing').Count 1 'the Codex pin must be checked the same way as the Claude pin'
728
+
729
+ $unpinned = Invoke-Preflight -Probe (New-TestProbe @{ ClaudePathState = 'unpinned' })
730
+ Assert-Equal (Get-FindingCode -Findings $unpinned -Code 'claude-code-unpinned').Count 1 'an unpinned CLI must warn on the service path'
731
+ Assert-False (Test-PreflightBlocked -Findings $unpinned) 'an unpinned CLI must not block the install'
732
+
733
+ # A scheduled task with a password logon loads the user profile, so the
734
+ # per-user npm prefix is on PATH and the service-path warning would be noise.
735
+ $unpinnedTask = Invoke-Preflight -Mechanism 'task' -Probe (New-TestProbe @{ ClaudePathState = 'unpinned' })
736
+ Assert-Equal (Get-FindingCode -Findings $unpinnedTask -Code 'claude-code-unpinned').Count 0 'the PATH warning is specific to the service environment'
737
+
738
+ # The trap worth more than the one the pin check catches: a resolvable
739
+ # executable with no login makes the node ADVERTISE the capability and then
740
+ # fail every job it is routed.
741
+ $noLogin = Invoke-Preflight -Probe (New-TestProbe @{ ClaudeLoginExists = $false })
742
+ Assert-Equal (Get-FindingCode -Findings $noLogin -Code 'claude-code-login-missing').Count 1 'a pinned CLI with no login must warn'
743
+ Assert-True ((Get-FindingCode -Findings $noLogin -Code 'claude-code-login-missing')[0].Message -like '*.credentials.json*') 'the login warning must name the file it looked for'
744
+
745
+ $noLoginUnpinned = Invoke-Preflight -Probe (New-TestProbe @{ ClaudePathState = 'unpinned'; ClaudeLoginExists = $false })
746
+ Assert-Equal (Get-FindingCode -Findings $noLoginUnpinned -Code 'claude-code-login-missing').Count 0 'a CLI that is not pinned cannot be advertised, so its login is moot'
747
+
748
+ $systemProfileRoot = Invoke-Preflight -Probe (New-TestProbe @{
749
+ WorkspaceRootUnderSystemProfile = $true
750
+ WorkspaceRoot = 'C:\Windows\System32\config\systemprofile\.ever-works\fleet-workspaces'
751
+ })
752
+ Assert-Equal (Get-FindingCode -Findings $systemProfileRoot -Code 'workspace-root-in-system-profile').Count 1 'worktrees under the system profile must be refused'
753
+ Assert-True (Test-PreflightBlocked -Findings $systemProfileRoot) 'a system-profile workspace root must block the install'
754
+
755
+ $rootDenied = Invoke-Preflight -Probe (New-TestProbe @{ WorkspaceRootAccess = 'denied' })
756
+ Assert-Equal (Get-FindingCode -Findings $rootDenied -Code 'workspace-root-unwritable').Count 1 'an unwritable workspace root must be an error'
757
+ Assert-True (Test-PreflightBlocked -Findings $rootDenied) 'an unwritable workspace root must block the install'
758
+
759
+ $rootUnknown = Invoke-Preflight -Probe (New-TestProbe @{ WorkspaceRootAccess = 'unknown' })
760
+ Assert-Equal (Get-FindingCode -Findings $rootUnknown -Code 'workspace-root-access-unproven').Count 1 'an inconclusive workspace-root ACL must warn'
761
+ Assert-False (Test-PreflightBlocked -Findings $rootUnknown) 'an inconclusive workspace-root ACL must not block'
762
+
763
+ # A system-profile root is already fatal; do not also complain that its ACL
764
+ # could not be proven.
765
+ $systemProfileUnknown = Invoke-Preflight -Probe (New-TestProbe @{
766
+ WorkspaceRootUnderSystemProfile = $true
767
+ WorkspaceRootAccess = 'unknown'
768
+ })
769
+ Assert-Equal (Get-FindingCode -Findings $systemProfileUnknown -Code 'workspace-root-access-unproven').Count 0 'the system-profile error must not be doubled by an ACL warning'
770
+
771
+ # --- Preflight: where the logs go -----------------------------------------
772
+ #
773
+ # Warnings rather than errors BECAUSE the installer repairs both. Reporting
774
+ # them as errors would refuse the exact install that fixes them: a machine
775
+ # whose node.log was written by the old LocalSystem service.
776
+
777
+ $stateDirDenied = Invoke-Preflight -Probe (New-TestProbe @{ StateDirAccess = 'denied' })
778
+ Assert-Equal (Get-FindingCode -Findings $stateDirDenied -Code 'state-dir-unwritable').Count 1 'an unwritable state directory must be reported'
779
+ Assert-False (Test-PreflightBlocked -Findings $stateDirDenied) 'the installer grants the state directory itself, so this must not block'
780
+ Assert-True ((Get-FindingCode -Findings $stateDirDenied -Code 'state-dir-unwritable')[0].Message -like '*C:\ProgramData\ever-works-node*') 'the state-directory warning must name the path'
781
+
782
+ $logsDenied = Invoke-Preflight -Probe (New-TestProbe @{
783
+ UnwritableLogPaths = @('C:\ProgramData\ever-works-node\node.log')
784
+ })
785
+ Assert-Equal (Get-FindingCode -Findings $logsDenied -Code 'log-unwritable').Count 1 'a log file the account cannot append to must be reported'
786
+ Assert-True ((Get-FindingCode -Findings $logsDenied -Code 'log-unwritable')[0].Message -like '*node.log*') 'the log warning must name the file'
787
+ Assert-False (Test-PreflightBlocked -Findings $logsDenied) 'the installer grants the log files too, so this must not block'
788
+
789
+ # --- Report rendering -----------------------------------------------------
790
+
791
+ $mixed = Invoke-Preflight -HasPassword $false -Probe (New-TestProbe @{ ClaudePathState = 'unpinned' })
792
+ $report = Format-PreflightFindingReport -Findings $mixed
793
+ Assert-True ($report.Count -ge 2) 'the report must render every finding'
794
+ Assert-True ($report[0] -like '*[ERROR]*') 'errors must be rendered before warnings'
795
+ Assert-True ($report[-1] -like '*[WARNING]*') 'warnings must come last'
796
+
797
+ Assert-Equal (Format-PreflightFindingReport -Findings @()).Count 0 'an empty finding set must render no lines'
798
+ Assert-False (Test-PreflightBlocked -Findings @()) 'an empty finding set must not block'
799
+
800
+ # --- Service identity interop ---------------------------------------------
801
+ #
802
+ # The two P/Invokes that replace `nssm set ObjectName`. Both are exercised
803
+ # against a DELIBERATELY INVALID handle: that runs the whole marshalling path
804
+ # - struct layout, LSA_UNICODE_STRING[] and the PSID byte[] - and returns a
805
+ # documented error without reaching a service or the local security policy,
806
+ # so this suite still changes nothing on the machine it runs on.
807
+
808
+ $interopSource = Get-ServiceIdentityInteropSource
809
+ Assert-True ($interopSource -like '*ChangeServiceConfigW*') 'the interop must set the logon account through the SCM API'
810
+ Assert-True ($interopSource -like '*LsaAddAccountRights*') 'the interop must grant SeServiceLogonRight, which ChangeServiceConfig does not'
811
+
812
+ if (-not ('EverWorksServiceIdentity' -as [type])) {
813
+ Add-Type -TypeDefinition $interopSource -Language CSharp
814
+ }
815
+
816
+ $changeConfigError = [EverWorksServiceIdentity]::ProbeChangeServiceConfigSignature()
817
+ # Observed 1725 (RPC_S_INVALID_BINDING) on Windows 10 22H2: the SCM is
818
+ # reached over RPC, so an invalid handle surfaces as an RPC error rather than
819
+ # ERROR_INVALID_HANDLE. The value is not the point - a returned error at all
820
+ # proves the eleven-argument signature marshalled instead of throwing.
821
+ Assert-True ($changeConfigError -ne 0) 'ChangeServiceConfigW must reject an invalid handle with an error, not succeed'
822
+
823
+ $probeSid = New-Object Security.Principal.SecurityIdentifier('S-1-5-18')
824
+ $probeSidBytes = New-Object byte[] $probeSid.BinaryLength
825
+ $probeSid.GetBinaryForm($probeSidBytes, 0)
826
+ $addRightsStatus = [EverWorksServiceIdentity]::ProbeAddAccountRightsSignature($probeSidBytes)
827
+ # 0xC0000008 is STATUS_INVALID_HANDLE. Reaching the LSA's own handle check
828
+ # means the LSA_UNICODE_STRING[] and the PSID both marshalled.
829
+ Assert-True ($addRightsStatus -ne 0) 'LsaAddAccountRights must reject a null policy handle with an NTSTATUS'
830
+
831
+ if ($failures.Count -gt 0) {
832
+ throw ("install-service logic test failures:`n- " + ($failures -join "`n- "))
833
+ }
834
+ $global:LASTEXITCODE = 0
835
+ Write-Output 'install-service logic tests passed'