mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
yesterday b5cb2b48d17d114eece4c1bec2e1126eade5d2af
.github/workflows/build.yml
@@ -46,19 +46,12 @@
          - { os: 'windows-latest', java: '26' }
      fail-fast: false
    steps:
    - name:  Install wine+rpm for distribution
    - name:  Install rpm for distribution
      if: runner.os == 'Linux'
      shell: bash
      run:   |
        sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list
        sudo dpkg --add-architecture i386
        sudo mkdir -pm755 /etc/apt/keyrings && sudo wget -O /etc/apt/keyrings/winehq-archive.key https://dl.winehq.org/wine-builds/winehq.key
        sudo wget -NP /etc/apt/sources.list.d/ https://dl.winehq.org/wine-builds/ubuntu/dists/$(lsb_release -c -s)/winehq-$(lsb_release -c -s).sources
        sudo apt-get update
        sudo apt install --install-recommends winehq-stable || sudo apt install --install-recommends winehq-staging
        wine --version
        version="9.4.0"; sudo wget "https://dl.winehq.org/wine/wine-mono/$version/wine-mono-$version-x86.msi" -O /tmp/wine-mono.msi
        wine msiexec /i /tmp/wine-mono.msi
        sudo apt-get install -y rpm
    - uses: actions/checkout@v6
      with:
        fetch-depth: 0
@@ -88,15 +81,45 @@
      shell: cmd
      run: |
        cd opendj-server-legacy\src\build-tools\windows
        nmake all
        xcopy /Y *.exe ..\..\..\lib\
        nmake all || exit /b 1
        xcopy /Y *.exe ..\..\..\lib\ || exit /b 1
        git status
    # Also the source of truth for the committed opendj-server-legacy/lib/*.exe: on a
    # successful push build, deploy.yml downloads windows-exe-11 from this very run and
    # commits its contents back to the branch. Nothing here compares them with what is
    # committed - an MSVC toolchain bump on the runner image changes the bytes on its own,
    # so a byte-for-byte gate would fire without a source change.
    - name: Upload Windows exe artifacts
      if: runner.os == 'Windows'
      uses: actions/upload-artifact@v7
      with:
        name: windows-exe-${{ matrix.java }}
        retention-days: 5
        path: opendj-server-legacy/src/build-tools/windows/*.exe
    - name: Set Integration Test Environment
      id: failsafe
      if: runner.os == 'Linux'
      run:   |
        echo "MAVEN_PROFILE_FLAG=-P precommit" >> $GITHUB_OUTPUT
    - name: Setup WiX (.NET tool) for MSI
      # Only the java 11 job's MSI is consumed downstream (test-msi*, deploy.yml); without
      # wix installed the distribution-windows-msi profile stays inactive, so the other
      # Windows jobs skip the MSI build entirely instead of producing an artifact nothing
      # uses.
      if: runner.os == 'Windows' && matrix.java == '11'
      shell: bash
      run: |
        # The MSI builds on Windows only (WiX cannot author MSIs on Linux/macOS). WiX 5 ships as a
        # net6.0 tool; allow it to run on the newer .NET runtime present on the runner.
        echo "DOTNET_ROLL_FORWARD=Major" >> "$GITHUB_ENV"
        export DOTNET_ROLL_FORWARD=Major
        dotnet tool install --global wix --version 5.0.2 || dotnet tool update --global wix --version 5.0.2
        echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
        export PATH="$HOME/.dotnet/tools:$PATH"
        wix --version
        wix extension add -g WixToolset.UI.wixext/5.0.2 || true
    # The per-module javadoc:jar that runs during verify only ever sees one
    # module's sources, so it cannot report a package declared by two modules
    # at once. Only the aggregate on the root reactor can, and it used to run
@@ -116,6 +139,21 @@
      env:
        MAVEN_OPTS: -Dhttps.protocols=TLSv1.2 -Dmaven.wagon.httpconnectionManager.ttlSeconds=120 -Dmaven.wagon.http.retryHandler.requestSentEnabled=true -Dmaven.wagon.http.retryHandler.count=10
      run: mvn --batch-mode --errors --update-snapshots verify ${{ steps.javadoc.outputs.MAVEN_JAVADOC_GOAL }} --file pom.xml ${{ steps.failsafe.outputs.MAVEN_PROFILE_FLAG }}
    - name: Validate the MSI (ICE)
      # wix build runs no ICE validation (only MSBuild projects or an explicit validate
      # do), so a green build alone proves the authoring compiles, not that it validates -
      # e.g. the ICE63 rule about script-generating actions sequenced before
      # RemoveExistingProducts would go unnoticed without this step.
      if: runner.os == 'Windows' && matrix.java == '11'
      shell: bash
      run: |
        msi=$(ls opendj-packages/opendj-msi/opendj-msi-standard/target/*.msi)
        # ICE61 fires by design: AllowSameVersionUpgrades authors an UpgradeVersion row
        # whose range includes the product's own version, which is exactly what makes a
        # rebuilt hotfix at the same 3-part version upgrade rather than install alongside.
        # Left unsuppressed it is permanent noise this step could not tell from a real
        # regression.
        wix msi validate -sice ICE61 "$msi"
    - name: Test on Unix
      if: runner.os == 'Linux'
      run:   |
@@ -311,21 +349,7 @@
    - name: Test on Windows
      if: runner.os == 'Windows'
      run:   |
        # Verify a stop took effect before moving on: wait until the server
        # releases the exclusive byte-range lock it holds on locks\server.lock.
        # The explicit Lock(0, 1) probe is required: a byte-range lock does not
        # prevent opening the file, so a bare Open() would always succeed.
        function Wait-ServerStopped($lockFile) {
          $lockFile = Join-Path $PWD $lockFile
          for ($i = 0; $i -lt 30; $i++) {
            if (-not (Test-Path $lockFile)) { return }
            try {
              $fs = [System.IO.File]::Open($lockFile, 'Open', 'ReadWrite', 'ReadWrite')
              try { $fs.Lock(0, 1); $fs.Unlock(0, 1); return } finally { $fs.Close() }
            } catch { Start-Sleep -Seconds 2 }
          }
          throw "The server still holds the lock on ${lockFile}: the stop did not take effect"
        }
        . .github\scripts\wait-server-stopped.ps1
        set OPENDJ_JAVA_ARGS="-server -Xmx512m"
        opendj-server-legacy\target\package\opendj\setup.bat -h localhost -p 1389 --ldapsPort 1636 --adminConnectorPort 4444 --enableStartTLS --generateSelfSignedCertificate --rootUserDN "cn=Directory Manager" --rootUserPassword password --baseDN dc=example,dc=com --sampleData 5000 --cli --acceptLicense --no-prompt
        opendj-server-legacy\target\package\opendj\bat\status.bat --hostname localhost --bindDN "cn=Directory Manager" --bindPassword password --trustAll
@@ -385,14 +409,6 @@
        if ($LASTEXITCODE -ne 0) { throw "net stop 'OpenDJ Server' failed with exit code $LASTEXITCODE" }
        opendj-server-legacy\target\package\opendj\bat\windows-service.bat --disableService
    - name: Upload Windows exe artifacts
      if: runner.os == 'Windows'
      uses: actions/upload-artifact@v7
      with:
        name: windows-exe-${{ matrix.java }}
        retention-days: 5
        path: opendj-server-legacy/src/build-tools/windows/*.exe
    - name: Upload artifacts OpenDJ Server
      uses: actions/upload-artifact@v7
      with:
@@ -915,9 +931,258 @@
            rpm -e opendj
          '
  # The gate both MSI jobs wait on. Deliberately not "needs: build-maven": that waits for
  # the whole matrix, whose ubuntu legs run for about two hours, so any push landing inside
  # that window cancels the run before those two-minute jobs have started - which is how the
  # MSI work reached its eighth review round with no completed run behind it. The only input
  # they have is the windows-latest-11 artifact, so wait for exactly that.
  #
  # On ubuntu, and in a job of its own, for two reasons. A waiter on windows-latest holds a
  # Windows runner from t=0 for the whole wait, competing for the capacity the leg it is
  # waiting for needs - in run 31578978374 the windows-latest-11 leg sat in the queue for 38
  # minutes while windows-latest-26 started within one. And the wait was duplicated
  # verbatim in both jobs, with a 45-minute budget measured from t=0 that covered the leg's
  # runtime but not its queue time: in that same run the artifact appeared at +52 minutes,
  # so both jobs would have failed a perfectly healthy build.
  wait-msi-artifact:
    runs-on: 'ubuntu-latest'
    # Well past any queue seen so far, and only reached if the Windows leg neither publishes
    # nor finishes; the usual exits are the artifact appearing or the leg failing.
    timeout-minutes: 130
    permissions:
      contents: read
      # Listing the run's artifacts and jobs, which the wait below polls.
      actions: read
    steps:
      - name: Wait for the Windows build artifact
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          deadline=$(( $(date +%s) + 120 * 60 ))
          while true; do
            if gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" \
                 --jq '.artifacts[].name' | grep -qx 'windows-latest-11'; then
              echo 'windows-latest-11 is available'
              exit 0
            fi
            # Stop waiting the moment the leg that would publish it has finished without
            # doing so, instead of sitting out the deadline.
            # || true: a transient API error is a reason to poll again, not to fail the run.
            conclusion=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?per_page=100" \
              --jq '.jobs[] | select(.name | startswith("build-maven (windows-latest, 11)")) | .conclusion' || true)
            if [ -n "$conclusion" ] && [ "$conclusion" != "null" ]; then
              echo "the Windows build leg finished as '$conclusion' without publishing windows-latest-11" >&2
              exit 1
            fi
            if [ "$(date +%s)" -ge "$deadline" ]; then
              echo 'windows-latest-11 was not published within 120 minutes' >&2
              exit 1
            fi
            sleep 30
          done
  test-msi:
    needs: build-maven
    needs: wait-msi-artifact
    runs-on: 'windows-latest'
    permissions:
      contents: read
    steps:
      # Only for .github/scripts/wait-server-stopped.ps1 and the MSI authoring the guard
      # step below reads, and it has to come first: checkout cleans the workspace the
      # artifact is unpacked into. Sparse because those two are the entire reason for it.
      - uses: actions/checkout@v6
        with:
          sparse-checkout: |
            .github/scripts
            opendj-packages/opendj-msi/opendj-msi-standard/resources/msi
      - name: Download artifacts
        uses: actions/download-artifact@v8
        with:
          name: windows-latest-11
      - name: Set up Java
        uses: actions/setup-java@v5
        with:
          java-version: '25'
          distribution: 'zulu'
      - name: The upgrade guards must hold up on their own
        shell: pwsh
        run: |
          # Two things no install scenario can see, both of which have already gone wrong here.
          # The first is what a guard is allowed to READ: the execute sequence is processed in
          # the installer service, so a private property set in the UI sequence is empty by the
          # time the guard evaluates - which inverts it in exactly the full-UI sessions that no
          # /qn scenario runs. The second is the inline PowerShell itself: the scenarios reach
          # it only through a ten-minute install and can then only look at msiexec's exit code,
          # so a guard that always exits 0 looks the same as one that never has to refuse.
          # Read from the authoring rather than from the built MSI's tables: wix copies both
          # the sequence conditions and ExeCommand across verbatim, and it is test-msi-upgrade
          # that judges the artifact - those scenarios now assert the refusal's own 1603 and
          # "Return value 3" rather than any non-zero exit code.
          $wxs = Get-Content -Raw opendj-packages/opendj-msi/opendj-msi-standard/resources/msi/package.wxs
          $sequence = [regex]::Match($wxs, '(?s)<InstallExecuteSequence>(.*?)</InstallExecuteSequence>')
          if (-not $sequence.Success) { throw "package.wxs has no InstallExecuteSequence" }
          foreach ($action in @('RequireDirOnCustomUpgrade', 'RefuseRelocatingUpgrade')) {
            $scheduled = [regex]::Match($sequence.Groups[1].Value, '(?s)<Custom Action="' + $action + '"(.*?)/>')
            if (-not $scheduled.Success) { throw "$action is not scheduled in InstallExecuteSequence" }
            $found = [regex]::Match($scheduled.Groups[1].Value, '(?s)Condition="([^"]*)"')
            if (-not $found.Success) { throw "$action is scheduled without a condition" }
            $condition = [System.Net.WebUtility]::HtmlDecode($found.Groups[1].Value)
            # Property names in these conditions are upper case, and so are AND/NOT: a
            # lower-case letter is a private property, which reads as empty in the service.
            if ($condition -cmatch '[a-z]') { throw "$action reads a private property, which never reaches the installer service: $condition" }
            Write-Host "$action : $condition"
          }
          foreach ($property in @('OPENDJ', 'OPENDJ_GIVEN')) {
            if ($wxs -notmatch ('<Property Id="' + $property + '"[^>]*Secure="yes"')) { throw "$property must be Secure: the guards read it in the service, where a non-administrator's value is dropped otherwise" }
          }
          # Sequence="first" is what keeps OPENDJ_GIVEN meaning "the directory was named":
          # without it the action re-runs in the execute sequence of a full-UI install, where
          # OPENDJ has long been resolved, and the guards' prefix test would be comparing a
          # value with itself. Every msiexec call in this workflow is /qn, which runs no UI
          # sequence, so nothing else here would notice it going.
          if ($wxs -notmatch '<SetProperty Id="OPENDJ_GIVEN"[^>]*Sequence="first"') { throw 'OPENDJ_GIVEN must be captured with Sequence="first", or it stops meaning "named" in a full-UI install' }
          # CheckServerNotRunning, run exactly as msiexec runs it: the Formatted field
          # resolved - [\[] and [\]] are its escapes for literal brackets, [property]
          # references become their values - and handed to cmd.exe. Windows PowerShell 5.1 is
          # what the command names, so that is what this exercises.
          $found = [regex]::Match($wxs, '(?s)<CustomAction Id="CheckServerNotRunning".*?ExeCommand="([^"]*)"')
          if (-not $found.Success) { throw "CheckServerNotRunning has no ExeCommand" }
          $target = [System.Net.WebUtility]::HtmlDecode($found.Groups[1].Value)
          $probeRoot = Join-Path $PWD 'guard-probe'
          New-Item -ItemType Directory -Force (Join-Path $probeRoot 'locks') | Out-Null
          $command = $target.Replace('[\[]', '[').Replace('[\]]', ']').Replace('[System64Folder]', "$env:SystemRoot\System32\").Replace('[OPENDJ]', "$probeRoot\")
          Set-Content -Encoding Ascii -Path guard-probe.cmd -Value $command
          Write-Host $command
          function Invoke-Guard { (Start-Process cmd.exe -Wait -PassThru -ArgumentList '/c', 'guard-probe.cmd' -WorkingDirectory $PWD).ExitCode }
          $lock = Join-Path $probeRoot 'locks\server.lock'
          Remove-Item $lock -Force -ErrorAction SilentlyContinue
          $rc = Invoke-Guard
          if ($rc -ne 0) { throw "no lock file at all must let the upgrade proceed, got $rc" }
          Set-Content -Path $lock -Value ''
          $rc = Invoke-Guard
          if ($rc -ne 0) { throw "an unlocked server.lock must let the upgrade proceed, got $rc" }
          # A running server holds a mandatory byte-range lock over the whole file
          # (LockFileManager: RandomAccessFile "rw" plus tryLock) and opens it shared, so the
          # open succeeds by design and only the Lock call raises IOException - the exception
          # PowerShell hands to the catch wrapped in a MethodInvocationException.
          Set-Content -Path guard-holder.ps1 -Value @(
            '$fs = [System.IO.File]::Open($env:GUARD_PROBE_LOCK, ''Open'', ''ReadWrite'', ''ReadWrite'')',
            '$fs.Lock(0, [Int64]::MaxValue)',
            'New-Item -ItemType File -Force -Path "$env:GUARD_PROBE_LOCK.held" | Out-Null',
            'Start-Sleep -Seconds 300'
          )
          $env:GUARD_PROBE_LOCK = $lock
          Remove-Item "$lock.held" -Force -ErrorAction SilentlyContinue
          $holder = Start-Process pwsh -PassThru -ArgumentList '-NoProfile', '-File', 'guard-holder.ps1' -WorkingDirectory $PWD
          for ($i = 0; $i -lt 30 -and -not (Test-Path "$lock.held"); $i++) { Start-Sleep -Seconds 1 }
          if (-not (Test-Path "$lock.held")) { throw "the holder process never took the lock" }
          $started = Get-Date
          $rc = Invoke-Guard
          $elapsed = [int]((Get-Date) - $started).TotalSeconds
          Stop-Process -Id $holder.Id -Force -ErrorAction SilentlyContinue
          if ($rc -ne 1) { throw "a held server.lock must refuse the upgrade (exit 1), got $rc after ${elapsed}s" }
          # Refusing before the grace is up would mean the loop exited for some other reason.
          if ($elapsed -lt 55) { throw "the refusal came after ${elapsed}s, so the 60 s grace was not polled through" }
          Write-Host "CheckServerNotRunning: 0 with no lock file, 0 unlocked, 1 while held (after ${elapsed}s)"
          Remove-Item -Recurse -Force $probeRoot, guard-probe.cmd, guard-holder.ps1 -ErrorAction SilentlyContinue
      - name: Install MSI (silent)
        shell: pwsh
        run: |
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          if (-not $msi) { throw "MSI not found in the windows-latest-11 artifact" }
          Write-Host "MSI: $msi"
          # No OPENDJ property: exercise the x64 default C:\Program Files\OpenDJ (a path with
          # spaces, which the server scripts must handle).
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v install.log"
          if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 80; throw "msiexec /i failed: $($p.ExitCode)" }
          $root = "C:\Program Files\OpenDJ"
          if (-not (Test-Path "$root\setup.bat")) { Get-Content install.log -Tail 80; throw "OpenDJ not installed into the x64 default $root" }
          Write-Host "Installed to $root"
          # The package lays the files down and registers no service: enabling one stays
          # the administrator's explicit step (windows-service.bat / setup), exactly as
          # for the zip distribution, so start-ds.bat keeps starting the server directly.
          if (Get-Service OpenDJ -ErrorAction SilentlyContinue) { throw "the package must not register a service of its own" }
          if (Get-Service "OpenDJ Server" -ErrorAction SilentlyContinue) { throw "the package must not register a service of its own" }
          # The SCM wrapper is harvested with the rest of lib; windows-service.bat needs it.
          if (-not (Test-Path "$root\lib\opendj_service.exe")) { throw "lib\opendj_service.exe missing from the MSI install" }
          # Custom extension jars go into lib\extensions; the server warns on startup
          # (WARN_ADMIN_NO_EXTENSIONS_DIR) when it is missing.
          if (-not (Test-Path "$root\lib\extensions")) { throw "lib\extensions missing from the MSI install" }
          "OPENDJ_ROOT=$root" | Out-File -FilePath $env:GITHUB_ENV -Append
      - name: Setup, then start and stop the server without a service
        shell: pwsh
        run: |
          # An MSI install that was never asked for service mode must behave exactly like
          # a zip one: start-ds.bat starts the server in this very session rather than
          # dispatching to the SCM, and needs no elevation to do it.
          $root = $env:OPENDJ_ROOT
          $env:OPENDJ_JAVA_ARGS = "-server -Xmx512m"
          & "$root\setup.bat" -h localhost -p 1389 --ldapsPort 1636 --adminConnectorPort 4444 --enableStartTLS --generateSelfSignedCertificate --rootUserDN "cn=Directory Manager" --rootUserPassword password --baseDN dc=example,dc=com --addBaseEntry --cli --acceptLicense --no-prompt --doNotStart
          if ($LASTEXITCODE -ne 0) { throw "setup.bat failed: $LASTEXITCODE" }
          & "$root\bat\start-ds.bat"
          if ($LASTEXITCODE -ne 0) { throw "start-ds.bat failed: $LASTEXITCODE" }
          for ($i=0; $i -lt 12; $i++) { try { $c = New-Object System.Net.Sockets.TcpClient('localhost', 1636); $c.Close(); break } catch { Start-Sleep -Seconds 5 } }
          & "$root\bat\ldapsearch.bat" --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword password --useSsl --trustAll --baseDN "dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1
          if ($LASTEXITCODE -ne 0) { throw "ldapsearch failed: $LASTEXITCODE" }
          & "$root\bat\stop-ds.bat"
          if ($LASTEXITCODE -ne 0) { throw "stop-ds.bat failed: $LASTEXITCODE" }
          # A zero exit code from stop-ds does not mean the JVM let go of the lock (#768),
          # and the next step registers and starts the service against this same instance.
          . .github\scripts\wait-server-stopped.ps1
          Wait-ServerStopped "$root\locks\server.lock"
      - name: Enable, start, stop and disable the Windows service
        shell: pwsh
        run: |
          # Service mode is opt-in and driven entirely by windows-service.bat, the same
          # command the zip distribution uses. The service it registers takes the display
          # name "OpenDJ Server" for the first instance on the host.
          $root = $env:OPENDJ_ROOT
          & "$root\bat\windows-service.bat" --enableService
          if ($LASTEXITCODE -ne 0) { throw "--enableService failed: $LASTEXITCODE" }
          if (-not (Get-Service -DisplayName "OpenDJ Server" -ErrorAction SilentlyContinue)) { sc.exe query; throw "--enableService did not register the service" }
          net start "OpenDJ Server"
          if ($LASTEXITCODE -ne 0) { throw "net start failed: $LASTEXITCODE" }
          for ($i=0; $i -lt 12; $i++) { try { $c = New-Object System.Net.Sockets.TcpClient('localhost', 1636); $c.Close(); break } catch { Start-Sleep -Seconds 5 } }
          & "$root\bat\ldapsearch.bat" --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword password --useSsl --trustAll --baseDN "dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1
          if ($LASTEXITCODE -ne 0) { throw "ldapsearch (as a service) failed: $LASTEXITCODE" }
          net stop "OpenDJ Server"
          if ($LASTEXITCODE -ne 0) { throw "net stop failed: $LASTEXITCODE" }
          & "$root\bat\windows-service.bat" --disableService
          if ($LASTEXITCODE -ne 0) { throw "--disableService failed: $LASTEXITCODE" }
          if (Get-Service -DisplayName "OpenDJ Server" -ErrorAction SilentlyContinue) { throw "--disableService left the service registered" }
      - name: uninstall.bat disables the service it finds and removes the instance
        shell: pwsh
        run: |
          # Re-enable the service so the uninstaller exercises its disable path: nothing
          # in the package owns the service, so removing it is the uninstaller's job.
          $root = $env:OPENDJ_ROOT
          & "$root\bat\windows-service.bat" --enableService
          if ($LASTEXITCODE -ne 0) { throw "--enableService failed: $LASTEXITCODE" }
          & "$root\uninstall.bat" --cli --remove-all --no-prompt --forceOnError --quiet
          if ($LASTEXITCODE -ne 0) { throw "uninstall.bat failed: $LASTEXITCODE" }
          if (Test-Path "$root\config\config.ldif") { throw "uninstall.bat did not remove the instance files" }
          if (Get-Service -DisplayName "OpenDJ Server" -ErrorAction SilentlyContinue) { throw "uninstall.bat left the service registered" }
          Write-Host "uninstall.bat removed the instance and disabled the service"
          exit 0
      - name: Uninstall MSI
        shell: pwsh
        run: |
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall.log -Tail 80; throw "msiexec /x failed: $($p.ExitCode)" }
          if (Test-Path "$env:OPENDJ_ROOT\lib\opendj_service.exe") { throw "msiexec /x left the payload behind" }
          Write-Host "Uninstalled OK"
  # Upgrade path: released 5.1.2 x86 MSI (wine-built, WiX3) -> this build's x64 MSI.
  # Verifies the new installer detects the legacy Program Files (x86) install, keeps the
  # instance data in place, stops the running service for the file replacement and leaves
  # its registration alone, and that the upgraded server starts with the old data.
  test-msi-upgrade:
    needs: wait-msi-artifact
    runs-on: 'windows-latest'
    permissions:
      contents: read
    steps:
      - name: Download artifacts
        uses: actions/download-artifact@v8
@@ -928,41 +1193,516 @@
        with:
          java-version: '25'
          distribution: 'zulu'
      - name: Install MSI (silent)
      - name: Install released 5.1.2 MSI and configure an instance
        shell: pwsh
        run: |
          $uri = "https://github.com/OpenIdentityPlatform/OpenDJ/releases/download/5.1.2/opendj-5.1.2.msi"
          for ($i = 1; $i -le 5; $i++) {
            try { Invoke-WebRequest -Uri $uri -OutFile opendj-5.1.2.msi; break }
            catch { if ($i -eq 5) { throw }; Write-Host "download attempt $i failed, retrying"; Start-Sleep -Seconds (10 * $i) }
          }
          # 5.1.2 already contains the script-quoting fixes from #671 (the tag post-dates
          # the merge), so install it into its own x86 default - spaces and parentheses
          # included - to reproduce the real upgrade starting point.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i opendj-5.1.2.msi /quiet /qn /norestart /l*v install-old.log"
          if ($p.ExitCode -ne 0) { Get-Content install-old.log -Tail 80; throw "msiexec /i (5.1.2) failed: $($p.ExitCode)" }
          $root = "C:\Program Files (x86)\OpenDJ"
          if (-not (Test-Path "$root\setup.bat")) { Get-Content install-old.log -Tail 80; throw "5.1.2 install root not found at $root" }
          $env:OPENDJ_JAVA_ARGS = "-server -Xmx512m"
          & "$root\setup.bat" -h localhost -p 1389 --ldapsPort 1636 --adminConnectorPort 4444 --enableStartTLS --generateSelfSignedCertificate --rootUserDN "cn=Directory Manager" --rootUserPassword password --baseDN dc=example,dc=com --addBaseEntry --cli --acceptLicense --no-prompt --doNotStart
          if ($LASTEXITCODE -ne 0) { throw "setup.bat (5.1.2) failed: $LASTEXITCODE" }
          # Register the service the pre-MSI way, prove it works, and LEAVE IT RUNNING:
          # the upgrade itself must stop it (StopServiceBeforeUpgrade runs elevated here)
          # before CheckServiceStopped would otherwise refuse.
          & "$root\bat\windows-service.bat" --enableService
          if ($LASTEXITCODE -ne 0) { throw "windows-service --enableService failed: $LASTEXITCODE" }
          net start "OpenDJ Server"
          if ($LASTEXITCODE -ne 0) { throw "net start (5.1.2) failed: $LASTEXITCODE" }
      - name: Upgrade with the newly built MSI (no OPENDJ - location auto-detected)
        shell: pwsh
        run: |
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          if (-not $msi) { throw "MSI not found in the windows-latest-11 artifact" }
          Write-Host "MSI: $msi"
          # No OPENDJ property: use the default install directory (a path with spaces),
          # which the server scripts must handle.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v install.log"
          if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 80; throw "msiexec /i failed: $($p.ExitCode)" }
          $root = @("C:\Program Files (x86)\OpenDJ","C:\Program Files\OpenDJ") | Where-Object { Test-Path "$_\setup.bat" } | Select-Object -First 1
          if (-not $root) { Get-Content install.log -Tail 80; throw "OpenDJ install root with setup.bat not found" }
          Write-Host "Installed to $root"
          # The headline upgrade path: no OPENDJ property, the installer must find the
          # legacy default directory on its own.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v upgrade.log"
          if ($p.ExitCode -ne 0) { Get-Content upgrade.log -Tail 120; throw "msiexec /i (upgrade) failed: $($p.ExitCode)" }
          $root = "C:\Program Files (x86)\OpenDJ"
          # New package files landed in the old directory, not the x64 default
          if (-not (Test-Path "$root\setup.bat")) { throw "upgrade did not keep the old install dir" }
          if (Test-Path "C:\Program Files\OpenDJ") { throw "upgrade unexpectedly installed into the x64 default dir" }
          # Instance data survived
          if (-not (Test-Path "$root\config\config.ldif")) { throw "instance data (config\config.ldif) lost by the upgrade" }
          # The service registration is the administrator's, not the package's: the upgrade
          # stopped it to free the jars, and must have left it registered and pointing at
          # the same tree - the wrapper it names has just been replaced in place.
          $svc = Get-Service -DisplayName "OpenDJ Server" -ErrorAction SilentlyContinue
          if (-not $svc) { sc.exe query; throw "the upgrade unregistered the administrator's service" }
          if ($svc.Status -ne "Stopped") { throw "the upgrade left the service $($svc.Status), expected Stopped" }
          if (Get-Service OpenDJ -ErrorAction SilentlyContinue) { sc.exe query; throw "the package registered a service of its own" }
          sc.exe qc "$($svc.Name)"
          "OPENDJ_ROOT=$root" | Out-File -FilePath $env:GITHUB_ENV -Append
      - name: Setup and start/stop the Windows service
      - name: Run upgrade.bat and start the upgraded server through the service
        shell: pwsh
        run: |
          $root = $env:OPENDJ_ROOT
          $env:OPENDJ_JAVA_ARGS = "-server -Xmx512m"
          & "$root\setup.bat" -h localhost -p 1389 --ldapsPort 1636 --adminConnectorPort 4444 --enableStartTLS --generateSelfSignedCertificate --rootUserDN "cn=Directory Manager" --rootUserPassword password --baseDN dc=example,dc=com --addBaseEntry --cli --acceptLicense --no-prompt --doNotStart
          if ($LASTEXITCODE -ne 0) { throw "setup.bat failed: $LASTEXITCODE" }
          & "$root\bat\windows-service.bat" --enableService
          if ($LASTEXITCODE -ne 0) { throw "windows-service --enableService failed: $LASTEXITCODE" }
          & "$root\upgrade.bat" --no-prompt --acceptLicense --force
          if ($LASTEXITCODE -ne 0) { throw "upgrade.bat failed: $LASTEXITCODE" }
          # The service registered before the upgrade still drives the refreshed tree.
          net start "OpenDJ Server"
          if ($LASTEXITCODE -ne 0) { throw "net start failed: $LASTEXITCODE" }
          if ($LASTEXITCODE -ne 0) { throw "net start (upgraded) failed: $LASTEXITCODE" }
          for ($i=0; $i -lt 12; $i++) { try { $c = New-Object System.Net.Sockets.TcpClient('localhost', 1636); $c.Close(); break } catch { Start-Sleep -Seconds 5 } }
          # The pre-upgrade data must still be served
          & "$root\bat\ldapsearch.bat" --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword password --useSsl --trustAll --baseDN "dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1
          if ($LASTEXITCODE -ne 0) { throw "ldapsearch failed: $LASTEXITCODE" }
          if ($LASTEXITCODE -ne 0) { throw "ldapsearch after upgrade failed: $LASTEXITCODE" }
          net stop "OpenDJ Server"
          if ($LASTEXITCODE -ne 0) { throw "net stop failed: $LASTEXITCODE" }
          if ($LASTEXITCODE -ne 0) { throw "net stop (upgraded) failed: $LASTEXITCODE" }
      - name: Repair must leave the service registration alone
        shell: pwsh
        run: |
          # Nothing in the package controls a service, so a repair must not disturb the
          # registration the administrator made - neither the one this upgrade inherited
          # nor one belonging to an unrelated instance.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          $before = (Get-Service -DisplayName "OpenDJ Server").Name
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" REINSTALL=ALL REINSTALLMODE=vomus /quiet /qn /norestart /l*v repair.log"
          if ($p.ExitCode -ne 0) { Get-Content repair.log -Tail 80; throw "repair failed: $($p.ExitCode)" }
          $after = Get-Service -DisplayName "OpenDJ Server" -ErrorAction SilentlyContinue
          if (-not $after) { throw "repair unregistered the service" }
          if ($after.Name -ne $before) { throw "repair changed the service key name: $before -> $($after.Name)" }
          Write-Host "Repair left the '$before' service in place"
      - name: Disabling the service before uninstalling leaves no orphan
        shell: pwsh
        run: |
          # msiexec /x removes the files it installed and nothing else - as the WiX3-era
          # package did. Disabling the service is the administrator's step (the install
          # guide says so, and uninstall.bat does it too); skipping it would leave an
          # auto-start service pointing at a deleted tree.
          $root = $env:OPENDJ_ROOT
          & "$root\bat\windows-service.bat" --disableService
      - name: Uninstall MSI
          if ($LASTEXITCODE -ne 0) { throw "--disableService failed: $LASTEXITCODE" }
          if (Get-Service -DisplayName "OpenDJ Server" -ErrorAction SilentlyContinue) { throw "--disableService left the service registered" }
      - name: Auto-detect the legacy default directory on a fresh install
        shell: pwsh
        run: |
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall.log -Tail 80; throw "msiexec /x failed: $($p.ExitCode)" }
          Write-Host "Uninstalled OK"
          # Clean up the previous scenario first.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall1.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall1.log -Tail 80; throw "msiexec /x failed: $($p.ExitCode)" }
          # An existing legacy default directory must be picked up when OPENDJ is not given.
          New-Item -ItemType Directory -Force "C:\Program Files (x86)\OpenDJ" | Out-Null
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v install-autodetect.log"
          if ($p.ExitCode -ne 0) { Get-Content install-autodetect.log -Tail 80; throw "msiexec /i (autodetect) failed: $($p.ExitCode)" }
          if (-not (Test-Path "C:\Program Files (x86)\OpenDJ\setup.bat")) { Get-Content install-autodetect.log -Tail 80; throw "installer did not auto-detect the legacy default dir" }
          if (Test-Path "C:\Program Files\OpenDJ") { throw "installer used the x64 default dir despite an existing legacy dir" }
          Write-Host "Legacy default directory auto-detected OK"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall2.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall2.log -Tail 80; throw "msiexec /x (cleanup) failed: $($p.ExitCode)" }
      - name: Registry install-location detection on a fresh install
        shell: pwsh
        run: |
          # The InstallDir registry value must be picked up when OPENDJ is not given, and it
          # must beat the legacy Program Files (x86) directory (explicit SetProperty order).
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          New-Item -ItemType Directory -Force "C:\opendj-registry" | Out-Null
          New-Item -ItemType Directory -Force "C:\Program Files (x86)\OpenDJ" | Out-Null
          New-Item -Path HKLM:\SOFTWARE\OpenDJ -Force | Out-Null
          Set-ItemProperty -Path HKLM:\SOFTWARE\OpenDJ -Name InstallDir -Value 'C:\opendj-registry\'
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v install-registry.log"
          if ($p.ExitCode -ne 0) { Get-Content install-registry.log -Tail 80; throw "msiexec /i (registry detect) failed: $($p.ExitCode)" }
          if (-not (Test-Path "C:\opendj-registry\setup.bat")) { Get-Content install-registry.log -Tail 80; throw "installer did not use the registry InstallDir" }
          if (Test-Path "C:\Program Files (x86)\OpenDJ\setup.bat") { throw "legacy directory beat the registry InstallDir" }
          Write-Host "Registry install location detected OK"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall3.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall3.log -Tail 80; throw "msiexec /x (registry cleanup) failed: $($p.ExitCode)" }
      - name: Silent upgrade from an undetectable directory must refuse with guidance
        shell: pwsh
        run: |
          # A 5.1.x at a custom directory wrote no registry value: a /quiet upgrade
          # without OPENDJ used to relocate to the default while RemoveExistingProducts
          # emptied the old tree. The installer must refuse instead.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          # No detection signals at all - the guard under test is the one that fires when
          # none resolves, so clear every one of them here rather than rely on the
          # preceding step's uninstall having removed the registry value.
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i opendj-5.1.2.msi /quiet /qn /norestart OPENDJ=C:\opendj-custom /l*v install-custom.log"
          if ($p.ExitCode -ne 0) { Get-Content install-custom.log -Tail 80; throw "msiexec /i (5.1.2 custom dir) failed: $($p.ExitCode)" }
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v upgrade-custom.log"
          if ($p.ExitCode -eq 0) { Get-Content upgrade-custom.log -Tail 80; throw "upgrade without OPENDJ must refuse when the old location cannot be determined" }
          # Match on the part of the message that states the condition, not on the
          # instruction: the wording of the guidance has already been reworded once.
          if (-not (Select-String -Path upgrade-custom.log -Pattern "location could not be determined" -Quiet)) { Get-Content upgrade-custom.log -Tail 60; throw "expected the explicit-OPENDJ guidance message in the log" }
          if (-not (Test-Path "C:\opendj-custom\setup.bat")) { throw "the refused upgrade damaged the original install" }
          Write-Host "Upgrade refused with guidance, original install untouched (exit $($p.ExitCode))"
          # A named directory that is not the installation strands exactly as much data as
          # naming none. With no registry value and no server in the legacy default, a typo
          # used to satisfy this guard - the resolved directory is not the default - and the
          # relocation guard had nothing to compare it against, so RemoveExistingProducts
          # emptied C:\opendj-custom while the new tree landed one letter away.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ=C:\opendj-custmo /quiet /qn /norestart /l*v upgrade-typo.log"
          if ($p.ExitCode -ne 1603) { Get-Content upgrade-typo.log -Tail 120; throw "a named directory holding no server must be refused (expected 1603, got $($p.ExitCode))" }
          if (-not (Select-String -Path upgrade-typo.log -Pattern "location could not be determined" -Quiet)) { Get-Content upgrade-typo.log -Tail 60; throw "expected the explicit-OPENDJ guidance message in the log" }
          if (Test-Path "C:\opendj-custmo") { throw "the refused upgrade still created the mistyped directory" }
          if (-not (Test-Path "C:\opendj-custom\setup.bat")) { throw "the refused upgrade damaged the original install" }
          Write-Host "A mistyped target was refused, original install untouched (exit $($p.ExitCode))"
          # ...and naming the directory makes the very same upgrade proceed. This is what
          # a GUI administrator does by browsing to it in InstallDirDlg, which the refusal
          # must leave reachable: it fires on the resolved directory, not on the absence
          # of a command-line property.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ=C:\opendj-custom /quiet /qn /norestart /l*v upgrade-custom-ok.log"
          if ($p.ExitCode -ne 0) { Get-Content upgrade-custom-ok.log -Tail 120; throw "upgrade with an explicit OPENDJ must succeed: $($p.ExitCode)" }
          if (-not (Test-Path "C:\opendj-custom\lib\opendj_service.exe")) { throw "the upgrade did not land in C:\opendj-custom" }
          if (Test-Path "C:\Program Files\OpenDJ") { throw "the upgrade installed into the default directory as well" }
          Write-Host "Upgrade into the named directory succeeded"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall-custom.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall-custom.log -Tail 80; throw "msiexec /x (custom cleanup) failed: $($p.ExitCode)" }
          Remove-Item -Recurse -Force "C:\opendj-custom" -ErrorAction SilentlyContinue
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
      - name: An empty legacy directory must not be adopted during an upgrade
        shell: pwsh
        run: |
          # NOT Installed holds during a major upgrade too, so the legacy-directory
          # fallback used to fire on a leftover EMPTY Program Files (x86)\OpenDJ: the new
          # tree would land there while RemoveExistingProducts emptied the real install
          # somewhere else. During an upgrade the directory must prove it holds a server.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i opendj-5.1.2.msi /quiet /qn /norestart OPENDJ=C:\opendj-old /l*v install-old-custom.log"
          if ($p.ExitCode -ne 0) { Get-Content install-old-custom.log -Tail 80; throw "msiexec /i (5.1.2 at C:\opendj-old) failed: $($p.ExitCode)" }
          New-Item -ItemType Directory -Force "C:\Program Files (x86)\OpenDJ" | Out-Null
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v upgrade-emptylegacy.log"
          if ($p.ExitCode -eq 0) { Get-Content upgrade-emptylegacy.log -Tail 120; throw "an upgrade with no determinable location must be refused, not routed to an empty legacy directory" }
          # A non-zero exit code on its own only says msiexec failed; both neighbouring
          # scenarios name the guard they are about, and so must this one.
          if (-not (Select-String -Path upgrade-emptylegacy.log -Pattern "location could not be determined" -Quiet)) { Get-Content upgrade-emptylegacy.log -Tail 60; throw "expected the explicit-OPENDJ guidance message in the log" }
          if (Test-Path "C:\Program Files (x86)\OpenDJ\setup.bat") { throw "the upgrade installed into the empty legacy directory" }
          if (-not (Test-Path "C:\opendj-old\setup.bat")) { throw "the refused upgrade damaged the original install" }
          Write-Host "Empty legacy directory not adopted, upgrade refused (exit $($p.ExitCode))"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x opendj-5.1.2.msi /quiet /qn /norestart /l*v uninstall-old-custom.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall-old-custom.log -Tail 80; throw "msiexec /x (5.1.2 cleanup) failed: $($p.ExitCode)" }
          Remove-Item -Recurse -Force "C:\opendj-old" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
      - name: An upgrade that would relocate the installation must refuse
        shell: pwsh
        run: |
          # Passing a different OPENDJ over a detected installation is not a move:
          # RemoveExistingProducts would empty the old tree while the new one is installed
          # elsewhere, stranding config/db/logs (and any service registration) behind.
          #
          # The older package has to be a different ProductCode for this to be an upgrade
          # at all: reinstalling this very MSI over itself is maintenance mode, where
          # FindRelatedProducts does not run, so WIX_UPGRADE_DETECTED would never be set
          # and the guard could not fire. Hence the released 5.1.2 package, installed at
          # its native legacy default so both branches of the guard have something to
          # compare against.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          # Clear every location this scenario reasons about itself, rather than inheriting
          # the previous step's teardown: the starting state is what the guard is judged
          # against, so it belongs in the step that makes the judgement.
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\opendj-b" -ErrorAction SilentlyContinue
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i opendj-5.1.2.msi /quiet /qn /norestart /l*v install-old-legacy.log"
          if ($p.ExitCode -ne 0) { Get-Content install-old-legacy.log -Tail 80; throw "msiexec /i (5.1.2) failed: $($p.ExitCode)" }
          if (-not (Test-Path "C:\Program Files (x86)\OpenDJ\setup.bat")) { throw "5.1.2 did not install into the legacy default" }
          # (a) the recorded-location branch: a host that came through a 5.2.0-or-later
          # package has its install directory in the registry.
          New-Item -Path HKLM:\SOFTWARE\OpenDJ -Force | Out-Null
          Set-ItemProperty -Path HKLM:\SOFTWARE\OpenDJ -Name InstallDir -Value 'C:\Program Files (x86)\OpenDJ\'
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ=C:\opendj-b /quiet /qn /norestart /l*v relocate-reg.log"
          if ($p.ExitCode -eq 0) { Get-Content relocate-reg.log -Tail 120; throw "a relocating upgrade must be refused (recorded location)" }
          if (-not (Select-String -Path relocate-reg.log -Pattern "cannot move an existing installation" -Quiet)) { Get-Content relocate-reg.log -Tail 60; throw "expected the relocation guidance message in the log" }
          if (Test-Path "C:\opendj-b") { throw "the refused relocation still created C:\opendj-b" }
          # (b) the legacy-directory branch: no registry value, the old install proven by
          # the setup.bat in the legacy default.
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ=C:\opendj-b /quiet /qn /norestart /l*v relocate-legacy.log"
          if ($p.ExitCode -eq 0) { Get-Content relocate-legacy.log -Tail 120; throw "a relocating upgrade must be refused (legacy directory)" }
          if (-not (Select-String -Path relocate-legacy.log -Pattern "cannot move an existing installation" -Quiet)) { Get-Content relocate-legacy.log -Tail 60; throw "expected the relocation guidance message in the log" }
          if (Test-Path "C:\opendj-b") { throw "the refused relocation still created C:\opendj-b" }
          if (-not (Test-Path "C:\Program Files (x86)\OpenDJ\setup.bat")) { throw "the refused relocation damaged the original install" }
          Write-Host "Relocating upgrade refused on both branches, original install untouched"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x opendj-5.1.2.msi /quiet /qn /norestart /l*v uninstall-old-legacy.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall-old-legacy.log -Tail 80; throw "msiexec /x (5.1.2 cleanup) failed: $($p.ExitCode)" }
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
      - name: A stray OpenDJ tree in the default directory must not be adopted
        shell: pwsh
        run: |
          # The product being upgraded is a 5.1.x in a custom directory, which recorded
          # nothing, while some unrelated OpenDJ tree - a zip install, a copy - sits in the
          # x64 default. A setup.bat existence test cannot tell the two apart, so a guard
          # keyed on it stood down: RemoveExistingProducts gutted the real installation
          # while InstallFiles landed on the stranger, and msiexec exited 0.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i opendj-5.1.2.msi /quiet /qn /norestart OPENDJ=C:\opendj-real /l*v install-old-real.log"
          if ($p.ExitCode -ne 0) { Get-Content install-old-real.log -Tail 80; throw "msiexec /i (5.1.2 at C:\opendj-real) failed: $($p.ExitCode)" }
          # The decoy: everything the installer is able to ask about a directory.
          New-Item -ItemType Directory -Force "C:\Program Files\OpenDJ\lib" | Out-Null
          Set-Content "C:\Program Files\OpenDJ\setup.bat" '@echo off'
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v upgrade-decoy.log"
          if ($p.ExitCode -eq 0) { Get-Content upgrade-decoy.log -Tail 120; throw "an upgrade must not adopt a stray tree in the default directory" }
          if (-not (Select-String -Path upgrade-decoy.log -Pattern "location could not be determined" -Quiet)) { Get-Content upgrade-decoy.log -Tail 60; throw "expected the explicit-OPENDJ guidance message in the log" }
          if (Test-Path "C:\Program Files\OpenDJ\lib\opendj_service.exe") { throw "the refused upgrade installed into the stray tree" }
          if (-not (Test-Path "C:\opendj-real\setup.bat")) { throw "the refused upgrade damaged the original install" }
          # ...and naming the real directory gets the administrator through, decoy or not.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ=C:\opendj-real /quiet /qn /norestart /l*v upgrade-decoy-ok.log"
          if ($p.ExitCode -ne 0) { Get-Content upgrade-decoy-ok.log -Tail 120; throw "upgrade with an explicit OPENDJ must succeed: $($p.ExitCode)" }
          if (-not (Test-Path "C:\opendj-real\lib\opendj_service.exe")) { throw "the upgrade did not land in C:\opendj-real" }
          if (Test-Path "C:\Program Files\OpenDJ\lib\opendj_service.exe") { throw "the upgrade also installed into the stray tree" }
          Write-Host "Stray default-directory tree ignored: refused, then upgraded where told"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall-real.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall-real.log -Tail 80; throw "msiexec /x (decoy cleanup) failed: $($p.ExitCode)" }
          Remove-Item -Recurse -Force "C:\opendj-real" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
      - name: An old server in the default directory upgrades once it is named
        shell: pwsh
        run: |
          # The price of the scenario above: a 5.1.x that really does live in the x64
          # default recorded nothing either, so the package cannot tell it from the decoy
          # and refuses the silent upgrade that would have gone through before. What it
          # must not do is dead-end - the directory is a configurable property, and naming
          # it (which is also what browsing to it in the wizard amounts to) has to work.
          #
          # The old tree gets there by hand rather than through OPENDJ=: the released
          # 5.1.x package is x86, and a 32-bit package cannot install into the 64-bit
          # Program Files at all - Windows Installer resolves its [ProgramFilesFolder] to
          # Program Files (x86) whatever the directory property says, so msiexec exits 0
          # while the files land in the legacy default, which is a different scenario (one
          # the legacy-directory search resolves on its own). Installing into a custom
          # directory and moving the tree leaves exactly what this one needs: an
          # upgradable 5.1.x registration, no recorded location, no legacy directory, and
          # a real old server sitting in C:\Program Files\OpenDJ. That the registration is
          # left pointing at the directory the move emptied costs nothing - no guard reads
          # it, and RemoveExistingProducts tolerates the files being gone.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\opendj-x64src" -ErrorAction SilentlyContinue
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i opendj-5.1.2.msi /quiet /qn /norestart OPENDJ=C:\opendj-x64src /l*v install-old-x64.log"
          if ($p.ExitCode -ne 0) { Get-Content install-old-x64.log -Tail 80; throw "msiexec /i (5.1.2 at C:\opendj-x64src) failed: $($p.ExitCode)" }
          # Exit code 0 says msiexec ran, not that it put the files where it was told, and
          # a directory it silently declined to use is worth naming in the failure.
          if (-not (Test-Path "C:\opendj-x64src\setup.bat")) { Get-ChildItem "C:\Program Files\OpenDJ","C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue | Select-Object -First 5 -ExpandProperty FullName; Get-Content install-old-x64.log -Tail 80; throw "5.1.2 did not install into C:\opendj-x64src" }
          Move-Item "C:\opendj-x64src" "C:\Program Files\OpenDJ"
          if (-not (Test-Path "C:\Program Files\OpenDJ\setup.bat")) { throw "the 5.1.2 tree did not move into C:\Program Files\OpenDJ" }
          # 5.1.x ships lib\opendj_service.exe itself, so the file cannot say whose tree
          # this is; its content can. Both halves below are judged on the two things only
          # the new package produces: this payload, and the InstallDir registry value.
          $oldWrapper = (Get-FileHash "C:\Program Files\OpenDJ\lib\opendj_service.exe").Hash
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v upgrade-x64-silent.log"
          if ($p.ExitCode -eq 0) { Get-Content upgrade-x64-silent.log -Tail 120; throw "a silent upgrade with nothing recording the location must refuse" }
          if (-not (Select-String -Path upgrade-x64-silent.log -Pattern "location could not be determined" -Quiet)) { Get-Content upgrade-x64-silent.log -Tail 60; throw "expected the explicit-OPENDJ guidance message in the log" }
          if (Test-Path HKLM:\SOFTWARE\OpenDJ) { throw "the refused upgrade registered an install location" }
          if ((Get-FileHash "C:\Program Files\OpenDJ\lib\opendj_service.exe").Hash -ne $oldWrapper) { throw "the refused upgrade overwrote the old server" }
          # The signal that says "this directory was named" has to be public - a private
          # property set in the UI sequence never reaches the installer service, where the
          # guards run - so it must not be usable as a switch. It holds the named path and
          # the guard requires the resolved directory to START WITH it: a flag-shaped value
          # disarms nothing, and a value that does pass has spelled out the directory, which
          # is naming it. It is not the only conjunct either - the target still has to hold a
          # server - so even a matching prefix cannot stand in for that evidence.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ_GIVEN=1 /quiet /qn /norestart /l*v upgrade-x64-switch.log"
          if ($p.ExitCode -ne 1603) { Get-Content upgrade-x64-switch.log -Tail 120; throw "OPENDJ_GIVEN=1 must not switch the guard off (expected 1603, got $($p.ExitCode))" }
          if (-not (Select-String -Path upgrade-x64-switch.log -Pattern "location could not be determined" -Quiet)) { Get-Content upgrade-x64-switch.log -Tail 60; throw "the refusal must still come from the same guard" }
          if ((Get-FileHash "C:\Program Files\OpenDJ\lib\opendj_service.exe").Hash -ne $oldWrapper) { throw "the upgrade that OPENDJ_GIVEN=1 let through overwrote the old server" }
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ=`"C:\Program Files\OpenDJ`" /quiet /qn /norestart /l*v upgrade-x64-named.log"
          if ($p.ExitCode -ne 0) { Get-Content upgrade-x64-named.log -Tail 120; throw "the named upgrade into the default directory must succeed: $($p.ExitCode)" }
          if ((Get-FileHash "C:\Program Files\OpenDJ\lib\opendj_service.exe").Hash -eq $oldWrapper) { Get-Content upgrade-x64-named.log -Tail 120; throw "the upgrade did not land in C:\Program Files\OpenDJ" }
          # The only OPENDJ in this workflow whose value carries spaces: what the package
          # recorded proves it survived the command line and the elevation intact.
          $recorded = (Get-ItemProperty -Path HKLM:\SOFTWARE\OpenDJ -Name InstallDir -ErrorAction SilentlyContinue).InstallDir
          if ($recorded -notlike "C:\Program Files\OpenDJ*") { throw "the upgrade recorded '$recorded', not the directory it was told to use" }
          Write-Host "Default-directory upgrade refused silently, accepted when named"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall-x64.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall-x64.log -Tail 80; throw "msiexec /x (x64 default cleanup) failed: $($p.ExitCode)" }
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\opendj-x64src" -ErrorAction SilentlyContinue
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
      - name: Fresh install must not touch a service registered by another instance
        shell: pwsh
        run: |
          # A leftover Program Files (x86)\OpenDJ plus an "OpenDJ Server" belonging to a
          # zip instance elsewhere: installing to a third directory must leave that
          # registration completely alone. The package controls no service at all, so this
          # holds by construction - the scenario guards against reintroducing one.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          New-Item -ItemType Directory -Force "C:\Program Files (x86)\OpenDJ" | Out-Null
          sc.exe create "OpenDJ Server" binPath= "C:\zip-instance\lib\opendj_service.exe start ""C:\zip-instance.""" start= demand
          if ($LASTEXITCODE -ne 0) { throw "sc create failed: $LASTEXITCODE" }
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ=C:\opendj-elsewhere /quiet /qn /norestart /l*v install-elsewhere.log"
          if ($p.ExitCode -ne 0) { Get-Content install-elsewhere.log -Tail 80; throw "msiexec /i (elsewhere) failed: $($p.ExitCode)" }
          if (-not (Test-Path "C:\opendj-elsewhere\setup.bat")) { throw "install did not land in C:\opendj-elsewhere" }
          if (-not (Get-Service "OpenDJ Server" -ErrorAction SilentlyContinue)) { throw "fresh install elsewhere deleted an unrelated instance's service" }
          sc.exe delete "OpenDJ Server"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall-elsewhere.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall-elsewhere.log -Tail 80; throw "msiexec /x (elsewhere cleanup) failed: $($p.ExitCode)" }
          Write-Host "Fresh install elsewhere left the unrelated 'OpenDJ Server' service in place"
      - name: An upgrade must refuse while the service is still starting
        shell: pwsh
        run: |
          # The SCM takes no controls in a pending state: StopServiceBeforeUpgrade's
          # 'net stop' fails instantly with ERROR_SERVICE_CANNOT_ACCEPT_CTRL and
          # Return="ignore" eats it. StartPending is not 'Running', so a check that
          # sampled the state once waved the upgrade through with a JVM coming up on the
          # tree being replaced - and the jars are unversioned, so the delete-on-reboot
          # entries left behind by the nested uninstall name the paths the NEW jars
          # occupy. Reproducible because the wrapper reports START_PENDING for as long as
          # bat\start-ds.bat runs, which service.c gives 300 s.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i opendj-5.1.2.msi /quiet /qn /norestart /l*v install-old-pending.log"
          if ($p.ExitCode -ne 0) { Get-Content install-old-pending.log -Tail 80; throw "msiexec /i (5.1.2) failed: $($p.ExitCode)" }
          $root = "C:\Program Files (x86)\OpenDJ"
          $env:OPENDJ_JAVA_ARGS = "-server -Xmx512m"
          & "$root\setup.bat" -h localhost -p 1389 --ldapsPort 1636 --adminConnectorPort 4444 --enableStartTLS --generateSelfSignedCertificate --rootUserDN "cn=Directory Manager" --rootUserPassword password --baseDN dc=example,dc=com --addBaseEntry --cli --acceptLicense --no-prompt --doNotStart
          if ($LASTEXITCODE -ne 0) { throw "setup.bat (5.1.2) failed: $LASTEXITCODE" }
          & "$root\bat\windows-service.bat" --enableService
          if ($LASTEXITCODE -ne 0) { throw "windows-service --enableService failed: $LASTEXITCODE" }
          # Hold the start open: the wrapper waits for this script, reporting START_PENDING
          # the whole time. No JVM is needed - the guard is being asked about a service
          # state, not about a lock.
          Copy-Item "$root\bat\start-ds.bat" "$root\bat\start-ds.bat.orig"
          Set-Content "$root\bat\start-ds.bat" "@echo off`r`nping -n 240 127.0.0.1 >nul"
          sc.exe start "OpenDJ Server" | Out-Null
          for ($i = 0; $i -lt 15; $i++) {
            $st = (Get-Service "OpenDJ Server" -ErrorAction SilentlyContinue).Status
            if ($st -eq 'StartPending') { break }
            Start-Sleep -Seconds 1
          }
          $st = (Get-Service "OpenDJ Server" -ErrorAction SilentlyContinue).Status
          if ($st -ne 'StartPending') { throw "expected the service to be StartPending, got '$st'" }
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v upgrade-pending.log"
          # A refusal from a Return="check" custom action is 1722 in the log and 1603 out of
          # msiexec, and nothing else. "-ne 0" would also accept 3010 - which is the FAIL-OPEN
          # outcome, the upgrade going through and leaving the files it could not replace to a
          # reboot - so the exact code is what gets asserted. Same reasoning for the log: the
          # action NAME appears whether it ran and passed, ran and failed, or was skipped by
          # its condition, so the return value has to be part of the pattern.
          if ($p.ExitCode -ne 1603) { Get-Content upgrade-pending.log -Tail 120; throw "the upgrade must refuse while the service is starting (expected 1603, got $($p.ExitCode))" }
          if (-not (Select-String -Path upgrade-pending.log -Pattern "CheckServiceStopped\. Return value 3" -Quiet)) { Get-Content upgrade-pending.log -Tail 60; throw "the refusal must come from CheckServiceStopped" }
          if (-not (Test-Path "$root\config\config.ldif")) { throw "the refused upgrade damaged the instance" }
          if (-not (Test-Path "$root\setup.bat")) { throw "the refused upgrade damaged the installation" }
          Write-Host "Upgrade refused while the service was StartPending (exit $($p.ExitCode))"
          # Teardown: the wrapper is still sitting on the held-open start.
          Stop-Process -Name opendj_service -Force -ErrorAction SilentlyContinue
          Get-Process -Name PING -ErrorAction SilentlyContinue | Stop-Process -Force
          Start-Sleep -Seconds 5
          Move-Item -Force "$root\bat\start-ds.bat.orig" "$root\bat\start-ds.bat"
          & "$root\bat\windows-service.bat" --disableService
          if (Get-Service "OpenDJ Server" -ErrorAction SilentlyContinue) { sc.exe delete "OpenDJ Server" }
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x opendj-5.1.2.msi /quiet /qn /norestart /l*v uninstall-pending.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall-pending.log -Tail 80; throw "msiexec /x (5.1.2 cleanup) failed: $($p.ExitCode)" }
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
      - name: A decoy in the legacy default must not block the documented workaround
        shell: pwsh
        run: |
          # The mirror of "a stray OpenDJ tree in the default directory must not be adopted",
          # with the stray tree in the LEGACY default instead - where the install guide says
          # to pass OPENDJ, and where the relocation guard used to refuse that very command:
          # the legacy directory holds A server, the named directory is not it, refuse. The
          # installation then had no upgrade path at all, silent or named.
          # Both halves are asserted here, because the exception that fixes it is narrow: the
          # named directory has to hold a server. Naming an empty one is still a relocation.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\opendj-b" -ErrorAction SilentlyContinue
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i opendj-5.1.2.msi /quiet /qn /norestart OPENDJ=C:\opendj-mine /l*v install-old-mine.log"
          if ($p.ExitCode -ne 0) { Get-Content install-old-mine.log -Tail 80; throw "msiexec /i (5.1.2 at C:\opendj-mine) failed: $($p.ExitCode)" }
          if (-not (Test-Path "C:\opendj-mine\setup.bat")) { Get-Content install-old-mine.log -Tail 80; throw "5.1.2 did not install into C:\opendj-mine" }
          # The decoy: a zip installation, a copy, a decommissioned instance - anything a
          # setup.bat search cannot tell from the product being upgraded.
          New-Item -ItemType Directory -Force "C:\Program Files (x86)\OpenDJ\lib" | Out-Null
          Set-Content "C:\Program Files (x86)\OpenDJ\setup.bat" '@echo off'
          # Naming a directory that holds no server is still a relocation, decoy or not.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ=C:\opendj-b /quiet /qn /norestart /l*v relocate-decoy.log"
          if ($p.ExitCode -eq 0) { Get-Content relocate-decoy.log -Tail 120; throw "naming an empty directory is a relocation and must be refused" }
          if (-not (Select-String -Path relocate-decoy.log -Pattern "cannot move an existing installation" -Quiet)) { Get-Content relocate-decoy.log -Tail 60; throw "expected the relocation guidance message in the log" }
          if (Test-Path "C:\opendj-b") { throw "the refused relocation still created C:\opendj-b" }
          # ...and naming the real one is the workaround the install guide prescribes.
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" OPENDJ=C:\opendj-mine /quiet /qn /norestart /l*v upgrade-mine.log"
          if ($p.ExitCode -ne 0) { Get-Content upgrade-mine.log -Tail 120; throw "the documented workaround must upgrade the named installation: $($p.ExitCode)" }
          if (-not (Test-Path "C:\opendj-mine\lib\opendj_service.exe")) { throw "the upgrade did not land in C:\opendj-mine" }
          if (Test-Path "C:\Program Files (x86)\OpenDJ\lib\opendj_service.exe") { throw "the upgrade also installed into the decoy" }
          Write-Host "Legacy-default decoy: empty target refused, named installation upgraded"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall-mine.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall-mine.log -Tail 80; throw "msiexec /x (decoy cleanup) failed: $($p.ExitCode)" }
          Remove-Item -Recurse -Force "C:\opendj-mine" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
      - name: An upgrade must refuse while a server runs without a service
        shell: pwsh
        run: |
          # The mode this package ships by default: setup registers no service, so the
          # server started by bat\start-ds.bat is a plain JVM holding lib\*.jar. There is no
          # service key for the ImagePath-gated pair to match, and Restart Manager is not
          # allowed to shut anything down, so CheckServerNotRunning - the byte-range lock on
          # locks\server.lock - is the only thing standing between a running server and
          # RemoveExistingProducts renaming its jars into delete-on-reboot entries.
          $msi = (Get-ChildItem -Recurse -Filter *.msi -Path opendj-packages/opendj-msi | Select-Object -First 1).FullName
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Recurse -Force "C:\Program Files\OpenDJ" -ErrorAction SilentlyContinue
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i opendj-5.1.2.msi /quiet /qn /norestart /l*v install-old-running.log"
          if ($p.ExitCode -ne 0) { Get-Content install-old-running.log -Tail 80; throw "msiexec /i (5.1.2) failed: $($p.ExitCode)" }
          $root = "C:\Program Files (x86)\OpenDJ"
          $env:OPENDJ_JAVA_ARGS = "-server -Xmx512m"
          & "$root\setup.bat" -h localhost -p 1389 --ldapsPort 1636 --adminConnectorPort 4444 --enableStartTLS --generateSelfSignedCertificate --rootUserDN "cn=Directory Manager" --rootUserPassword password --baseDN dc=example,dc=com --addBaseEntry --cli --acceptLicense --no-prompt --doNotStart
          if ($LASTEXITCODE -ne 0) { throw "setup.bat (5.1.2) failed: $LASTEXITCODE" }
          & "$root\bat\start-ds.bat"
          if ($LASTEXITCODE -ne 0) { throw "start-ds.bat failed: $LASTEXITCODE" }
          for ($i=0; $i -lt 12; $i++) { try { $c = New-Object System.Net.Sockets.TcpClient('localhost', 1636); $c.Close(); break } catch { Start-Sleep -Seconds 5 } }
          if (Get-Service "OpenDJ Server" -ErrorAction SilentlyContinue) { throw "this scenario is about a server with NO service registered" }
          # The headline auto-detected upgrade, which would otherwise proceed straight into
          # the running server's tree. The refusal costs the full 60 s grace inside the
          # check: a server that is genuinely up never releases the lock.
          $pendingKey = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
          $pendingBefore = @((Get-ItemProperty -Path $pendingKey -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations)
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v upgrade-running.log"
          # Exactly 1603 (custom action 1722), for the reason spelled out in the StartPending
          # scenario above: 3010 is what a guard that fails open produces here, and "-ne 0"
          # cannot tell the two apart. The action name alone cannot either - it is written to
          # the log whether the action refused or waved the upgrade through.
          if ($p.ExitCode -ne 1603) { Get-Content upgrade-running.log -Tail 120; throw "the upgrade must refuse while a server is running out of the tree (expected 1603, got $($p.ExitCode))" }
          if (-not (Select-String -Path upgrade-running.log -Pattern "CheckServerNotRunning\. Return value 3" -Quiet)) { Get-Content upgrade-running.log -Tail 60; throw "the refusal must come from CheckServerNotRunning" }
          # The signature of the fail-open, and the damage it does: RemoveExistingProducts
          # cannot rename a jar the JVM holds, so it leaves a delete-on-reboot entry naming
          # the path the new jar occupies. A refusal leaves none.
          $pendingAfter = @((Get-ItemProperty -Path $pendingKey -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations)
          $pendingNew = $pendingAfter | Where-Object { $_ -and $pendingBefore -notcontains $_ }
          if ($pendingNew) { throw "the refused upgrade still scheduled files for delete-on-reboot: $($pendingNew -join '; ')" }
          if (-not (Test-Path "$root\config\config.ldif")) { throw "the refused upgrade damaged the instance" }
          & "$root\bat\ldapsearch.bat" --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword password --useSsl --trustAll --baseDN "dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1
          if ($LASTEXITCODE -ne 0) { throw "the refused upgrade disturbed the running server" }
          Write-Host "Upgrade refused while a non-service server was running (exit $($p.ExitCode))"
          # Stopping it makes the very same upgrade proceed - and the 60 s grace inside the
          # check is what absorbs the gap between stop-ds returning and the JVM releasing
          # the lock, so no wait is needed here to keep this half honest.
          & "$root\bat\stop-ds.bat"
          if ($LASTEXITCODE -ne 0) { throw "stop-ds.bat failed: $LASTEXITCODE" }
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/i `"$msi`" /quiet /qn /norestart /l*v upgrade-stopped.log"
          if ($p.ExitCode -ne 0) { Get-Content upgrade-stopped.log -Tail 120; throw "the upgrade must proceed once the server is stopped: $($p.ExitCode)" }
          if (-not (Test-Path "$root\lib\opendj_service.exe")) { throw "the upgrade did not land in $root" }
          if (-not (Test-Path "$root\config\config.ldif")) { throw "the upgrade lost the instance data" }
          Write-Host "The same upgrade proceeded once the server was stopped"
          $p = Start-Process msiexec -Wait -PassThru -ArgumentList "/x `"$msi`" /quiet /qn /norestart /l*v uninstall-running.log"
          if ($p.ExitCode -ne 0) { Get-Content uninstall-running.log -Tail 80; throw "msiexec /x (running-server cleanup) failed: $($p.ExitCode)" }
          Remove-Item -Recurse -Force "C:\Program Files (x86)\OpenDJ" -ErrorAction SilentlyContinue
          Remove-Item -Path HKLM:\SOFTWARE\OpenDJ -Recurse -Force -ErrorAction SilentlyContinue