diff --git a/_bmad-output/initiative-ping-mee-portal/deferred-work.md b/_bmad-output/initiative-ping-mee-portal/deferred-work.md
new file mode 100644
index 0000000..a7a70d9
--- /dev/null
+++ b/_bmad-output/initiative-ping-mee-portal/deferred-work.md
@@ -0,0 +1,3 @@
+- source_plan: `_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-prepare-the-test-environment-plan.md`
+ summary: ARCHITECTURE-SPINE AD-2 says the service account's read rights are limited to a list, but Active Directory gives authenticated users broad read by default, so the statement is not enforced by the test-environment setup.
+ evidence: The setup script grants only write rights and the Change Password right; read access is the default. Either soften AD-2 ("needs no more than this list") or enforce it with deny entries; decide when the architecture is next updated.
diff --git a/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-prepare-the-test-environment-plan.md b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-prepare-the-test-environment-plan.md
new file mode 100644
index 0000000..9a5ab24
--- /dev/null
+++ b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-prepare-the-test-environment-plan.md
@@ -0,0 +1,59 @@
+---
+title: 'Prepare the test environment'
+type: 'chore'
+ticket: '2'
+created: '2026-10-15'
+status: 'built'
+baseline_revision: '840bea1cbaac1f32136399677d7ae8ef06ce532c'
+route: 'oneshot'
+route_source: 'auto'
+risk: 'medium'
+review: 'quick'
+review_source: 'pinned'
+lenses_ran: ['quick']
+review_loop_iteration: 0
+context:
+ - '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md'
+---
+
+
+
+## Intent
+
+**Problem:** Entries 4 and 5 need a test OU with a scoped service account, a dedicated OIDC application in the production authentik, the internal CA bundle, the Zoraxy route and a test mailbox. A person must create these in the production systems; none of it exists yet.
+
+**Approach:** Provide a runbook, a domain-controller script that creates the test OU, service account, groups and test users with only the spine's delegated rights, and a verification script that proves the rights over LDAPS. The person runs them and writes down the values; this entry is done only when they confirm the verification passed.
+
+
+
+## Implementation Notes
+
+Oneshot route: the output is documents and two PowerShell scripts under `docs/test-environment/`, no application code. The agent cannot reach the homelab from the development machine (it is on a different domain), and creating objects in the production directory is the person's action, so the scripts are not run by the agent. Syntax is checked with the PowerShell parser only.
+
+## Plan Change Log
+
+## Review Triage Log
+
+Pass 1 (quick lens). Counts: high 1, medium 8, low 1, false 1.
+
+| # | Finding | Verdict | Route | Evidence |
+|---|---------|---------|-------|----------|
+| 1 | The check script can print "All checks passed." when a request throws | high | patch | A .NET exception ends only the statement when `$ErrorActionPreference` is `Continue`; `Report` was never called. Fixed with `Stop`, try/catch around steps 2, 3 and 6. |
+| 2 | Generated passwords are lost if a later step fails | medium | patch | Passwords were printed only after delegation; the setup now runs inside try/finally so they always print. |
+| 3 | Checks test only one of six allowed attributes | medium | patch | The check now writes and restores all six; the header states what is not covered (create, delete, reset). |
+| 4 | Read rights are not limited as AD-2 says | medium | defer | AD gives authenticated users broad read by default; the fix is to soften the spine wording, which is a spec edit. README states the fact. |
+| 5 | `-WhatIf` on a rerun reports creating existing objects | medium | patch | Existence checks are read-only and now run in both modes. |
+| 6 | Any error counts as "does not exist" | low | patch | `Test-Dn` now catches only the identity-not-found error; direct correction. |
+| 7 | CA export may miss the intermediate chain | medium | patch | README now exports the whole chain and checks it with `openssl s_client`. |
+| 8 | Redirect URI presented as final | medium | patch | README says the sign-in ticket will use this path and where to edit it. |
+| 9 | Step D.1 may mean editing the production LDAP source | medium | patch | README now checks read-only first and stops if the source does not cover the test OU. |
+| 10 | Port 8000 exposure not mentioned | medium | patch | README note: bind to 127.0.0.1 or firewall to Zoraxy. |
+| 11 | Ticket numbering 1.2 / 1.5 inconsistent | false | rejected | `tickets.py` prints these refs (epic id, entry id). |
+
+## Verification
+
+**Commands:**
+- PowerShell parser check on both scripts -- expected: no syntax errors
+
+**Manual checks (if no CLI):**
+- The person runs the setup script on the domain controller and the verification script from a machine that can reach it; every check prints PASS.
diff --git a/docs/test-environment/New-PortalTestEnvironment.ps1 b/docs/test-environment/New-PortalTestEnvironment.ps1
new file mode 100644
index 0000000..9e75628
--- /dev/null
+++ b/docs/test-environment/New-PortalTestEnvironment.ps1
@@ -0,0 +1,172 @@
+<#
+.SYNOPSIS
+ Creates the Active Directory objects for testing the self-service portal.
+
+.DESCRIPTION
+ Creates, under a parent OU you choose:
+ - a test OU with test users and two test groups,
+ - a separate OU with a service account that is NOT inside the test OU,
+ - the delegated rights the portal needs on user objects in the test OU, and nothing else.
+
+ Delegated rights (inherited to user objects in the test OU only):
+ write givenName, sn, displayName, mail, telephoneNumber, mobile; the "Change Password" right.
+ Reading is already allowed for authenticated users. The account cannot create or delete
+ objects, cannot reset passwords (unless -AllowResetPassword), and cannot write anything else.
+
+ Run on a domain controller, or a machine with the AD module, as a domain administrator.
+ Run it once with -WhatIf first. Nothing outside the two new OUs is changed.
+ Not tested by the author against a live directory: read the -WhatIf output before you run it.
+
+.PARAMETER ParentDn
+ Distinguished name of an existing OU or container that will hold the two new OUs.
+
+.EXAMPLE
+ .\New-PortalTestEnvironment.ps1 -ParentDn "OU=Organisation,DC=example,DC=local" -WhatIf
+#>
+[CmdletBinding(SupportsShouldProcess = $true)]
+param(
+ [Parameter(Mandatory = $true)][string]$ParentDn,
+ [string]$TestOuName = 'PortalTest',
+ [string]$ServiceOuName = 'PortalTestService',
+ [string]$ServiceAccountName = 'svc-portal-test',
+ [string]$GroupPrefix = 'PortalTest',
+ [string]$MailDomain = 'example.test',
+ [switch]$AllowResetPassword
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+Import-Module ActiveDirectory
+
+# --- helpers -----------------------------------------------------------------
+$script:rng = New-Object System.Security.Cryptography.RNGCryptoServiceProvider
+function Get-RandomIndex([int]$Max) {
+ $bytes = New-Object byte[] 4
+ $script:rng.GetBytes($bytes)
+ return [int]([BitConverter]::ToUInt32($bytes, 0) % $Max)
+}
+function New-RandomPassword([int]$Length = 24) {
+ $sets = @('ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnopqrstuvwxyz', '23456789', '!#%&*+-=?@^_')
+ $all = -join $sets
+ $chars = New-Object System.Collections.Generic.List[char]
+ foreach ($s in $sets) { $chars.Add($s[(Get-RandomIndex $s.Length)]) }
+ while ($chars.Count -lt $Length) { $chars.Add($all[(Get-RandomIndex $all.Length)]) }
+ for ($i = $chars.Count - 1; $i -gt 0; $i--) {
+ $j = Get-RandomIndex ($i + 1)
+ $t = $chars[$i]; $chars[$i] = $chars[$j]; $chars[$j] = $t
+ }
+ return -join $chars
+}
+function Test-Dn([string]$Dn) {
+ try { Get-ADObject -Identity $Dn | Out-Null; return $true }
+ catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] { return $false }
+}
+function Test-UserExists([string]$Sam, [string]$SearchBase) {
+ if (-not (Test-Dn $SearchBase)) { return $false }
+ return [bool](Get-ADUser -Filter "SamAccountName -eq '$Sam'" -SearchBase $SearchBase)
+}
+
+# --- checks ------------------------------------------------------------------
+if (-not (Test-Dn $ParentDn)) { throw "ParentDn does not exist: $ParentDn" }
+$domain = Get-ADDomain
+$netbios = $domain.NetBIOSName
+$testOuDn = "OU=$TestOuName,$ParentDn"
+$svcOuDn = "OU=$ServiceOuName,$ParentDn"
+$real = -not $WhatIfPreference
+$secrets = [ordered]@{}
+$groups = @{}
+$users = @()
+
+# Everything below is wrapped so that passwords generated so far are always shown, even if a
+# later step fails. Existing objects are detected with read-only calls, also under -WhatIf.
+try {
+# --- OUs ---------------------------------------------------------------------
+foreach ($ou in @(@($TestOuName, $testOuDn), @($ServiceOuName, $svcOuDn))) {
+ if (Test-Dn $ou[1]) { Write-Host "OU exists: $($ou[1])"; continue }
+ if ($PSCmdlet.ShouldProcess($ou[1], 'Create OU')) {
+ New-ADOrganizationalUnit -Name $ou[0] -Path $ParentDn -ProtectedFromAccidentalDeletion $false
+ }
+}
+
+# --- service account (outside the test OU on purpose) ------------------------
+$svcDn = "CN=$ServiceAccountName,$svcOuDn"
+if (Test-Dn $svcDn) {
+ Write-Host "Service account exists: $svcDn (password unchanged)"
+}
+elseif ($PSCmdlet.ShouldProcess($svcDn, 'Create service account')) {
+ $pw = New-RandomPassword 32
+ New-ADUser -Name $ServiceAccountName -SamAccountName $ServiceAccountName `
+ -UserPrincipalName "$ServiceAccountName@$($domain.DNSRoot)" -Path $svcOuDn `
+ -AccountPassword (ConvertTo-SecureString $pw -AsPlainText -Force) -Enabled $true `
+ -PasswordNeverExpires $true -CannotChangePassword $true `
+ -Description 'Self-service portal TEST service account'
+ $secrets[$ServiceAccountName] = $pw
+}
+
+# --- groups ------------------------------------------------------------------
+foreach ($g in @('Publishers', 'Admins')) {
+ $name = "$GroupPrefix-$g"
+ $groups[$g] = "CN=$name,$testOuDn"
+ if (Test-Dn $groups[$g]) { Write-Host "Group exists: $name"; continue }
+ if ($PSCmdlet.ShouldProcess($groups[$g], 'Create group')) {
+ New-ADGroup -Name $name -SamAccountName $name -GroupScope Global -GroupCategory Security -Path $testOuDn
+ }
+}
+
+# --- test users --------------------------------------------------------------
+$users = @(
+ @{ Sam = 'ptest-user1'; Given = 'Test'; Sur = 'UserOne'; Group = $null; Enabled = $true; Expired = $false },
+ @{ Sam = 'ptest-user2'; Given = 'Test'; Sur = 'UserTwo'; Group = $null; Enabled = $true; Expired = $false },
+ @{ Sam = 'ptest-publisher'; Given = 'Test'; Sur = 'Publisher'; Group = 'Publishers'; Enabled = $true; Expired = $false },
+ @{ Sam = 'ptest-admin'; Given = 'Test'; Sur = 'Admin'; Group = 'Admins'; Enabled = $true; Expired = $false },
+ @{ Sam = 'ptest-disabled'; Given = 'Test'; Sur = 'Disabled'; Group = $null; Enabled = $false; Expired = $false },
+ @{ Sam = 'ptest-expired'; Given = 'Test'; Sur = 'MustChange'; Group = $null; Enabled = $true; Expired = $true }
+)
+foreach ($u in $users) {
+ $dn = "CN=$($u.Given) $($u.Sur),$testOuDn"
+ if (Test-UserExists $u.Sam $testOuDn) { Write-Host "User exists: $($u.Sam) (password unchanged)"; continue }
+ if ($PSCmdlet.ShouldProcess($dn, "Create test user $($u.Sam)")) {
+ $pw = New-RandomPassword 24
+ New-ADUser -Name "$($u.Given) $($u.Sur)" -SamAccountName $u.Sam -GivenName $u.Given -Surname $u.Sur `
+ -DisplayName "$($u.Given) $($u.Sur)" -EmailAddress "$($u.Sam)@$MailDomain" `
+ -UserPrincipalName "$($u.Sam)@$($domain.DNSRoot)" -Path $testOuDn `
+ -AccountPassword (ConvertTo-SecureString $pw -AsPlainText -Force) -Enabled $true `
+ -ChangePasswordAtLogon $u.Expired
+ if (-not $u.Enabled) { Disable-ADAccount -Identity $u.Sam }
+ if ($u.Group) { Add-ADGroupMember -Identity $groups[$u.Group] -Members $u.Sam }
+ $secrets[$u.Sam] = $pw
+ }
+}
+
+# --- delegation on the test OU (user objects only, inherited to children) -------
+$grants = @(
+ 'WP;givenName;user', 'WP;sn;user', 'WP;displayName;user', 'WP;mail;user',
+ 'WP;telephoneNumber;user', 'WP;mobile;user', 'CA;Change Password;user'
+)
+if ($AllowResetPassword) { $grants += 'CA;Reset Password;user' }
+foreach ($g in $grants) {
+ if ($PSCmdlet.ShouldProcess($testOuDn, "Grant $g to $netbios\$ServiceAccountName")) {
+ $out = & dsacls.exe $testOuDn /I:S /G "$netbios\$($ServiceAccountName):$g" 2>&1
+ if ($LASTEXITCODE -ne 0) { throw "dsacls failed for '$g': $out" }
+ }
+}
+
+}
+finally {
+# --- summary (always shown) ----------------------------------------------------
+Write-Host ''
+Write-Host '=== Values to write down (not secrets) ==='
+Write-Host "Domain DNS name : $($domain.DNSRoot)"
+Write-Host "Directory server (FQDN): $($domain.PDCEmulator)"
+Write-Host "Test OU (base DN) : $testOuDn"
+Write-Host "Service account : $ServiceAccountName@$($domain.DNSRoot) ($svcDn)"
+Write-Host "Publisher group DN : $($groups['Publishers'])"
+Write-Host "Admin group DN : $($groups['Admins'])"
+Write-Host "Test users : $(($users | ForEach-Object { $_.Sam }) -join ', ')"
+if ($secrets.Count -gt 0) {
+ Write-Host ''
+ Write-Host '=== PASSWORDS (shown once; store them in your password manager now) ===' -ForegroundColor Yellow
+ foreach ($k in $secrets.Keys) { Write-Host ('{0,-18} {1}' -f $k, $secrets[$k]) }
+}
+if (-not $real) { Write-Host "`n-WhatIf was used: nothing was changed." }
+}
diff --git a/docs/test-environment/README.md b/docs/test-environment/README.md
new file mode 100644
index 0000000..f9cf324
--- /dev/null
+++ b/docs/test-environment/README.md
@@ -0,0 +1,106 @@
+# Test environment runbook
+
+There is no staging environment. The portal is tested against a separate **test OU** in your real Active Directory and a **dedicated application** in your real authentik. This runbook creates both, with the smallest possible rights. Nothing here touches existing users, groups or applications.
+
+Time needed: about 45 minutes. You need domain-admin rights, access to authentik as an administrator, your reverse proxy (Zoraxy) and your mail provider's panel.
+
+## What gets created
+
+| Where | What |
+| --- | --- |
+| Active Directory | A test OU with six test users and two groups; a separate OU with a service account; delegated rights for that account on user objects in the test OU only |
+| authentik | One OIDC provider and one application, restricted to a test group |
+| Zoraxy | One host name that forwards to the portal container |
+| Mail provider | One test mailbox for sending |
+
+Reading is not restricted by this setup: in Active Directory, authenticated users can read most attributes of user objects by default, and so can the service account. The spine's wording that read rights are limited to a list is not enforced here (see the deferred-work note).
+
+The service account may change `givenName`, `sn`, `displayName`, `mail`, `telephoneNumber`, `mobile`, and may use "Change Password", on **user objects inside the test OU**. It cannot create or delete anything, cannot reset passwords (unless you add `-AllowResetPassword`), and cannot write anywhere else.
+
+## Step A: Active Directory
+
+1. Copy `New-PortalTestEnvironment.ps1` to a domain controller (or a machine with the AD tools).
+2. Pick the parent OU for the two new OUs. Run a dry run first and read the output:
+
+ ```powershell
+ .\New-PortalTestEnvironment.ps1 -ParentDn "OU=Organisation,DC=example,DC=local" -WhatIf
+ ```
+
+3. Run it for real (drop `-WhatIf`). The script prints **the passwords once**. Put them in your password manager now. It also prints the values to write down (see the values sheet below).
+4. The six test users: `ptest-user1`, `ptest-user2` (for cross-user checks), `ptest-publisher`, `ptest-admin`, `ptest-disabled` (disabled account), `ptest-expired` (must change password at next logon). Their e-mail addresses use the example domain; change the address of one user to your test mailbox if you want to receive test e-mails.
+
+## Step B: The certificate authority file
+
+The portal checks the directory server's certificate against your internal CA, so the file must contain the **whole chain**: the root CA and any intermediate CA that signed the domain controller's certificate.
+
+1. On the certificate authority server, export each CA certificate that is part of the chain (`certutil -ca.cert root.cer`, and the same for an intermediate if you have one), or export them from the "Certification Path" tab of the domain controller's LDAPS certificate.
+2. Convert each to PEM (`certutil -encode root.cer root.pem`) and join them into one file `ca.pem` (the files can simply be put one after another).
+3. Check it from the Linux host (replace the names):
+
+ ```sh
+ openssl s_client -connect dc1.example.local:636 -CAfile ca.pem -verify_return_error /auth/callback` (the sign-in ticket will use exactly this path; if it ever has to change, edit it here); scopes `openid`, `profile`, `email`; choose a signing key. Write down client ID and client secret.
+4. Create an **application** for it and bind the group `portal-test` so only those users can use it.
+5. Open the application's OpenID configuration page and write down the issuer address.
+
+Please note what authentik does when you sign in as `ptest-disabled` and `ptest-expired` (it may refuse them). That answers an open question in the spec.
+
+## Step E: Zoraxy
+
+1. Add a host name for the test portal, for example `portal-test.`, with a valid certificate, forwarding to `http://:8000`. The compose file currently publishes port 8000 on every interface of the host. Either bind it to `127.0.0.1:8000:8000` when Zoraxy runs on the same host, or firewall port 8000 so that only Zoraxy can reach it; the portal trusts proxy headers and must not be reachable directly.
+2. Write down the address Zoraxy connects from as seen by the container. If Zoraxy and Docker run on the same host, that is usually the Docker network gateway, not the host's LAN address. The sign-in ticket (1.5) sets `TRUSTED_PROXIES` from it and checks it with a test request.
+
+## Step F: Test mailbox
+
+Create a mailbox at your provider for test mail. Write down the SMTP server name, port, security mode (usually STARTTLS or SSL), user name, password and the sender address.
+
+## Values sheet (fill in; keep secrets out of this repository)
+
+| Item | Value |
+| --- | --- |
+| Directory server (FQDN) | |
+| Test OU (base DN) | |
+| Service account (user name) | stored in password manager |
+| Publisher group DN | |
+| Admin group DN | |
+| CA file location on the Linux host | |
+| OIDC issuer address | |
+| OIDC client ID | |
+| OIDC client secret | stored in password manager |
+| Portal test host name | |
+| Zoraxy address as seen by the container | |
+| SMTP server, port, mode | |
+| SMTP user, sender address | |
+| SMTP password | stored in password manager |
+
+## Clean up when you are done
+
+Delete the authentik application, provider and group; the Zoraxy host; the test mailbox; and the two OUs. The script created the OUs without deletion protection, so they can be removed in Active Directory Users and Computers with "Advanced Features" switched on. Remove the service account's OU last.
+
+## When you are finished
+
+Tell the assistant "1.2 is done". Ticket 1.2 stays open until you confirm that every check in Step C says PASS and that the authentik application lets `ptest-user1` sign in.
diff --git a/docs/test-environment/Test-PortalLdaps.ps1 b/docs/test-environment/Test-PortalLdaps.ps1
new file mode 100644
index 0000000..fe79a76
--- /dev/null
+++ b/docs/test-environment/Test-PortalLdaps.ps1
@@ -0,0 +1,143 @@
+<#
+.SYNOPSIS
+ Checks the portal service account's main rights and refusals over LDAPS.
+
+.DESCRIPTION
+ Binds over LDAPS (port 636) with certificate verification as the service account and checks:
+ 1. the bind works and the server certificate is trusted by this machine,
+ 2. a test user can be found and its account state read,
+ 3. the domain password policy can be read,
+ 4. each of the six allowed attributes can be written (and is restored),
+ 5. a not-allowed attribute cannot be written,
+ 6. a user OUTSIDE the test OU cannot be written (a no-change request, safe even if it succeeds),
+ 7. optionally a password change works with the Change Password right (-TestPasswordChange).
+ Not covered: that the account cannot create or delete objects or reset passwords. Check those in
+ Active Directory (Delegation of Control / dsacls) if you need that proof.
+ Every check prints PASS or FAIL; any unexpected error stops the script with a non-zero exit code,
+ so "All checks passed." is only printed when every check ran. Nothing is left changed except the test user's password if you
+ use -TestPasswordChange. Run it from a machine that can reach the directory server.
+ Not tested by the author against a live directory.
+
+.EXAMPLE
+ .\Test-PortalLdaps.ps1 -Server dc1.example.local -BindUser svc-portal-test@example.local `
+ -TestOuDn "OU=PortalTest,OU=Organisation,DC=example,DC=local" `
+ -OutsideUserDn "CN=Some Person,OU=Elsewhere,DC=example,DC=local"
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$Server,
+ [Parameter(Mandatory = $true)][string]$BindUser,
+ [Parameter(Mandatory = $true)][string]$TestOuDn,
+ [string]$TestSam = 'ptest-user1',
+ [string]$OutsideUserDn,
+ [switch]$TestPasswordChange
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.DirectoryServices.Protocols
+$P = [System.DirectoryServices.Protocols.SearchScope]
+$failed = 0
+function Report([string]$Name, [bool]$Ok, [string]$Detail = '') {
+ if ($Ok) { Write-Host "PASS $Name" -ForegroundColor Green }
+ else { Write-Host "FAIL $Name $Detail" -ForegroundColor Red; $script:failed++ }
+}
+function Plain([securestring]$s) {
+ $b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)
+ try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) }
+}
+function Search([string]$Base, [string]$Filter, $Scope, [string[]]$Attrs) {
+ $req = New-Object System.DirectoryServices.Protocols.SearchRequest($Base, $Filter, $Scope, $Attrs)
+ return [System.DirectoryServices.Protocols.SearchResponse]($script:conn.SendRequest($req))
+}
+function Modify([string]$Dn, [string]$Attr, $Values) {
+ $m = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification
+ $m.Name = $Attr
+ $m.Operation = [System.DirectoryServices.Protocols.DirectoryAttributeOperation]::Replace
+ foreach ($v in $Values) { [void]$m.Add($v) }
+ $req = New-Object System.DirectoryServices.Protocols.ModifyRequest($Dn, $m)
+ [void]$script:conn.SendRequest($req)
+}
+function Denied([scriptblock]$Action) {
+ try { & $Action; return $false }
+ catch [System.DirectoryServices.Protocols.DirectoryOperationException] {
+ return ($_.Exception.Response.ResultCode -eq 'InsufficientAccessRights')
+ }
+}
+
+# 1. bind with certificate verification (the default uses this machine's trust store)
+$pw = Read-Host -AsSecureString "Password for $BindUser"
+$id = New-Object System.DirectoryServices.Protocols.LdapDirectoryIdentifier($Server, 636, $false, $false)
+$conn = New-Object System.DirectoryServices.Protocols.LdapConnection($id)
+$conn.SessionOptions.SecureSocketLayer = $true
+$conn.SessionOptions.ProtocolVersion = 3
+$conn.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
+$conn.Credential = New-Object System.Net.NetworkCredential($BindUser, (Plain $pw))
+try { $conn.Bind(); Report 'LDAPS bind with a trusted certificate' $true }
+catch { Report 'LDAPS bind with a trusted certificate' $false $_.Exception.Message; exit 1 }
+
+# 2. find the test user and read its state
+$allowed = @('givenName', 'sn', 'displayName', 'mail', 'telephoneNumber', 'mobile')
+try {
+ $root = Search '' '(objectClass=*)' $P::Base @('defaultNamingContext')
+ $domainDn = $root.Entries[0].Attributes['defaultNamingContext'][0]
+ $r = Search $TestOuDn "(sAMAccountName=$TestSam)" $P::Subtree (@('distinguishedName', 'pwdLastSet', 'userAccountControl', 'memberOf') + $allowed)
+ $found = $r.Entries.Count -eq 1
+ Report "find $TestSam in the test OU and read its state" ($found -and $r.Entries[0].Attributes.Contains('userAccountControl'))
+}
+catch { Report "find $TestSam in the test OU and read its state" $false $_.Exception.Message; exit 1 }
+if (-not $found) { Write-Host 'Cannot continue without the test user.'; exit 1 }
+$entry = $r.Entries[0]
+$userDn = $entry.DistinguishedName
+
+# 3. domain password policy
+try {
+ $pol = Search $domainDn '(objectClass=*)' $P::Base @('minPwdLength', 'pwdProperties', 'pwdHistoryLength')
+ Report 'read the domain password policy' ($pol.Entries[0].Attributes.Contains('minPwdLength'))
+}
+catch { Report 'read the domain password policy' $false $_.Exception.Message }
+
+# 4. every allowed attribute: write a marker, then restore the original value
+foreach ($attr in $allowed) {
+ $old = @(); if ($entry.Attributes.Contains($attr)) { $old = @([string]$entry.Attributes[$attr][0]) }
+ $marker = if ($attr -eq 'mail') { 'portal-test-marker@example.test' } else { 'PortalTest-marker' }
+ try {
+ Modify $userDn $attr @($marker)
+ Modify $userDn $attr $old
+ Report "write the allowed attribute $attr and restore it" $true
+ }
+ catch { Report "write the allowed attribute $attr and restore it" $false $_.Exception.Message }
+}
+
+# 5. not-allowed attribute must be refused
+Report 'writing a not-allowed attribute (description) is refused' (Denied { Modify $userDn 'description' @('portal test') })
+
+# 6. outside the test OU must be refused (no-change request, safe even if it were allowed)
+if ($OutsideUserDn) {
+ try {
+ $o = Search $OutsideUserDn '(objectClass=*)' $P::Base @('telephoneNumber')
+ $same = @(); if ($o.Entries[0].Attributes.Contains('telephoneNumber')) { $same = @([string]$o.Entries[0].Attributes['telephoneNumber'][0]) }
+ Report 'writing a user outside the test OU is refused' (Denied { Modify $OutsideUserDn 'telephoneNumber' $same })
+ }
+ catch { Report 'writing a user outside the test OU is refused' $false "cannot read -OutsideUserDn: $($_.Exception.Message)" }
+}
+else { Write-Host 'SKIP outside-OU check (pass -OutsideUserDn to run it)' -ForegroundColor Yellow }
+
+# 7. password change with the Change Password right (changes the test user's password!)
+if ($TestPasswordChange) {
+ $oldPw = Plain (Read-Host -AsSecureString "Current password of $TestSam")
+ $newPw = Plain (Read-Host -AsSecureString "New password for $TestSam (must meet the policy)")
+ $enc = [System.Text.Encoding]::Unicode
+ $del = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification
+ $del.Name = 'unicodePwd'; $del.Operation = 'Delete'; [void]$del.Add($enc.GetBytes('"' + $oldPw + '"'))
+ $add = New-Object System.DirectoryServices.Protocols.DirectoryAttributeModification
+ $add.Name = 'unicodePwd'; $add.Operation = 'Add'; [void]$add.Add($enc.GetBytes('"' + $newPw + '"'))
+ $ok = $true; $detail = ''
+ try { [void]$conn.SendRequest((New-Object System.DirectoryServices.Protocols.ModifyRequest($userDn, @($del, $add)))) }
+ catch { $ok = $false; $detail = $_.Exception.Message }
+ Report 'change a password with the Change Password right' $ok $detail
+}
+else { Write-Host 'SKIP password change (use -TestPasswordChange; it changes the test user password)' -ForegroundColor Yellow }
+
+Write-Host ''
+if ($failed -eq 0) { Write-Host 'All checks passed.' -ForegroundColor Green } else { Write-Host "$failed check(s) failed." -ForegroundColor Red; exit 1 }