From b5cb2b48d17d114eece4c1bec2e1126eade5d2af Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Sat, 05 Sep 2026 18:27:43 +0000
Subject: [PATCH] Modernize the Windows MSI: WiX v5, x64, upgrade from 5.1.x (#701)

---
 .github/workflows/build.yml |  852 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 796 insertions(+), 56 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index efb811e..c009b5c 100644
--- a/.github/workflows/build.yml
+++ b/.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

--
Gitblit v1.10.0