diff --git a/CHANGELOG.md b/CHANGELOG.md index 93d8510..844b077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to IDBridge are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions use a calendar scheme `YY.M.D.build` (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning--releases)). +## [26.9.21.0] - 2026-09-21 + +### Added +- **`Data\LastRun.json` — an end-of-run summary file a reader on the box can consume.** + Written by `Write-IDBridgeRunSummary` from the `finally` block of `Invoke-IDBridge`, after + telemetry and before the PostRun plugins, on every run that is not a `-Preview`, replacing + the file whole. Versioned (`schemaVersion` 1) and grown additively only: module version, + run start/end and duration, `success`/`readOnly`/`testRun`, the enabled `directories`, the + applied counts (zeros in ReadOnly), `thresholdExceeded`, and `lastSuccessAt` — `runEnd` on + a successful run, carried forward from the previous file on a failed one, so a reader can + tell a sync that just broke from one that has been broken for a week. A failed run + contributes the exception class and throwing function name only, never the message; no + name, id, UPN, DN or group name is ever written (PRIVACY.md names the file and its keys). + Self-contained like telemetry: a write failure logs a `Warn` and never affects the run. + +### Removed +- **`Invoke-PluginPostRunBeacon` — the Beacon heartbeat PostRun plugin template**, its + descriptor in the config template and its tests. Beacon reads IDBridge from the box now: + its collector takes the `IDBridge Sync` scheduled task and the `Data\LastRun.json` summary + above, so no plugin, no ingest URL and no site key are needed. A district that copied the + template can delete `Plugins\Invoke-PluginPostRunBeacon.ps1` and its descriptor from the + config, and remove the `ApiKey-Beacon` vault secret if one was set. The config template + moves to `TemplateVersion: 3`. + ## [26.9.9.0] - 2026-09-09 ### Added diff --git a/PRIVACY.md b/PRIVACY.md index 5a299ef..5abf057 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -72,6 +72,26 @@ Telemetry is fire-and-forget with a 10-second timeout and no retries: an unreach endpoint (blocked egress, air-gapped network) is logged locally and never delays or fails a sync. +## Not telemetry: the run summary file + +At the end of every run that is not a `-Preview`, IDBridge writes +`\Data\LastRun.json` — a small local file describing how the run went, replaced +whole each time. It holds exactly these keys and nothing else: `schemaVersion`, +`moduleVersion`, `runStart`, `runEnd`, `durationSeconds`, `success`, `readOnly`, `testRun`, +`directories`, `managed`, `created`, `updated`, `deactivated`, `groupAdds`, `groupRemoves`, +`writeFailures`, `thresholdExceeded`, `errorType`, `errorFunction`, and `lastSuccessAt`. +That is counts, mode flags, timing and the module version; on a failed run the exception +**class** name and the throwing **function** name — never the exception message, which can +contain UPNs/DNs. No name, username, email address, person ID, distinguished name or group +name is ever written to it. + +IDBridge never sends this file anywhere: it is written to your own `Data` directory and +read by whatever you point at it. Where a district runs [Beacon](https://github.com/SCNetworksLLC/Beacon), +SC Networks' status dashboard, Beacon's collector on the same box reads it there — that +collector, not IDBridge, is what leaves the machine. The file is written regardless of the +telemetry tier (it isn't telemetry and sends nothing to SC Networks); `Preview` runs never +write it, and a write failure logs a `Warn` and never affects the run. + ## Not telemetry: the update check Separately from telemetry, `Invoke-IDBridge` queries the **PowerShell Gallery** diff --git a/docs/architecture.md b/docs/architecture.md index 83882a2..504bef6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,13 +124,17 @@ Invoke-IDBridge 13. Send-IDBridgeTelemetry (unless Telemetry.Tier = 'Off') → one anonymous usage event (self-contained try/catch, 10s timeout, no retries — can never affect the run; counts are APPLIED work so ReadOnly runs report zeros; see PRIVACY.md) + 13b. Write-IDBridgeRunSummary → Data\LastRun.json, the end-of-run summary a reader on the + box consumes (counts, mode flags, timing, lastSuccessAt carried forward across + failures, and on a failure the error class/function only — never the message; + self-contained try/catch — can never affect the run; see PRIVACY.md) 14. Invoke-PostRunPlugins → runs Type = 'PostRun' plugins with the RunResult (SecureStrings scrubbed first; per-plugin try/catch — can never affect the run; fires on failed and ReadOnly runs too; user list CSV exports live here in Invoke-PluginPostRunExport; see plugins.md) 15. Push-LogsToSheet (if Logging.GoogleSheetLoggingEnabled) → writes $script:Logs to sheet - Preview runs stay quiet: steps 13-15 are all skipped under -Preview (the file log - still writes). + Preview runs stay quiet: steps 13-15 (13b included) are all skipped under -Preview + (the file log still writes). ``` ## Data-object lifecycle diff --git a/docs/configuration.md b/docs/configuration.md index 4b82305..3bd808e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,7 +112,7 @@ unrecognized `Tier` value fails safe to `Off`. `-DisableTelemetry` silences a si Array of plugin descriptors. `Source`/`Override` entries are executed in order by `Invoke-SourcePlugins` at the start of the run; `PostRun` entries by `Invoke-PostRunPlugins` at the end of the run (in the `finally` block, after telemetry — they fire on failed and -ReadOnly runs too). The shipped config template lists all nine descriptors with +ReadOnly runs too). The shipped config template lists all eight descriptors with `Enabled = $false`; a configured site looks like: ```powershell @@ -124,7 +124,6 @@ ReadOnly runs too). The shipped config template lists all nine descriptors with @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunWebhook' } @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunExport' } @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunOrphanReport' } -@{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunBeacon' } ``` | Key | Effect | @@ -212,7 +211,7 @@ Derived from `-RootPath` (default `C:\IDBridge`); missing directories are create | `LogsRoot` | `\Logs` | `IDBridge.log` (rotated at 5 MB) | | `ExportsRoot` | `\Exports` | Run reports and `UserList-.csv` exports | | `PluginsRoot` | `\Plugins` | Plugin `.ps1` files | -| `DataRoot` | `\Data` | Plugin state (e.g. Skyward / Infinite Campus `LastSeen` CSVs) and name-mismatch approvals (`ApprovedNameMismatches.csv`) | +| `DataRoot` | `\Data` | Plugin state (e.g. Skyward / Infinite Campus `LastSeen` CSVs), name-mismatch approvals (`ApprovedNameMismatches.csv`) and `LastRun.json` (the end-of-run summary) | | `VaultRoot` | `\Vault` | Secret vault (`*.secret.json` envelope files) | | `LogFile` | `\IDBridge.log` | Active log file | diff --git a/docs/functions.md b/docs/functions.md index b1756e7..efd5d66 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -120,6 +120,19 @@ the run's per-write results), `-RunError` (ErrorRecord — Enhanced tier extract (missing = `Basic`, unrecognized = `Off`); logs the exact payload at Trace; sends with a 10 s timeout, no retries, all errors swallowed — can never affect the run. **Returns:** nothing. +### `Write-IDBridgeRunSummary` 🔒 +Writes `\LastRun.json`, the end-of-run summary a reader on the same box consumes +(SC Networks' Beacon collector where a district runs Beacon — see [PRIVACY.md](../PRIVACY.md)), +from the `finally` block of `Invoke-IDBridge` after telemetry and before the PostRun plugins, +on every run that is not a `-Preview`. **Params:** `-RunResult` (mandatory). Replaces the +file whole with a versioned, additively-grown schema (`schemaVersion` 1): module version, +run start/end and duration, `success`/`readOnly`/`testRun`, the enabled `directories`, the +APPLIED counts (zeros in ReadOnly), `thresholdExceeded`, and `lastSuccessAt` — `runEnd` on a +successful run, carried forward from the previous file on a failed one. A failed run adds the +exception *class* and throwing *function* name only, never the message; no name, id, UPN, DN +or group name is ever written. A write failure logs a `Warn` — it can never affect the run. +**Returns:** nothing. + ### `Invoke-PostRunPlugins` 🔒 🌐 Discovers\runs the `Type = 'PostRun'` plugins from `$IDConfig.Plugins`, called from the `finally` block of `Invoke-IDBridge` after telemetry and before the log push — on every diff --git a/docs/getting-started.md b/docs/getting-started.md index e353670..fdab288 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -215,12 +215,13 @@ Flip one brake at a time, with a read-only-style review (`-ReadOnly` or `-Previe OneRoster plugins in [plugins.md](plugins.md#invoke-pluginskywardsmsstudents--source-disabled-in-config) are worked examples). -- **Run reports, CSV exports, alerting:** the five PostRun templates consume each run's +- **Run reports, CSV exports, alerting:** the four PostRun templates consume each run's results — `Invoke-PluginPostRunReport` (JSON run summary), `Invoke-PluginPostRunExport` (user-list CSVs), and `Invoke-PluginPostRunOrphanReport` (enabled accounts no source feeds anymore) work as-is once their descriptors are enabled; - `Invoke-PluginPostRunWebhook` POSTs a compact summary to your own endpoint, and - `Invoke-PluginPostRunBeacon` sends a heartbeat to SC Networks' Beacon dashboard. + `Invoke-PluginPostRunWebhook` POSTs a compact summary to your own endpoint. See [plugins.md](plugins.md#the-postrun-contract-invoke-postrunplugins). + SC Networks' Beacon dashboard needs no plugin — its collector reads each run from the + box, out of the `Data\LastRun.json` summary every run writes. - **Run-history dashboard:** `Telemetry.Tier = 'Enhanced'`, then claim your SiteID (`Get-IDBridgeSiteID`) at [IDBridge Pulse](https://pulse.scnlabs.net). diff --git a/docs/plugins.md b/docs/plugins.md index f1c9944..00a2041 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -288,23 +288,6 @@ throws until the placeholder URL is edited. Fire-and-forget: 10 s timeout, send a `Warn` and never affect the run. The starting point for shipping your own telemetry or alerting ("tell me when the run breaks"). -### `Invoke-PluginPostRunBeacon` — PostRun *(disabled in config)* -File: `C:\IDBridge\Plugins\Invoke-PluginPostRunBeacon.ps1`. POSTs one heartbeat envelope to -[Beacon](https://github.com/SCNetworksLLC/Beacon), SC Networks' status dashboard, after every -run — failed and ReadOnly runs included: `sourceType` `automation`, `sourceId` -`idbridge-sync`, and a `data` block of `result` (`Success`; `Warning` when a write did not -stick; `Failed` when the run threw), a one-line `message` built from the counts -("412 managed: 3 created, 5 updated, 1 deactivated, 2 group changes, 0 write failures, 47s", -prefixed `ReadOnly:` or `test run:` in those modes), and the counts, flags, duration and -module version themselves. No per-user data. Beacon's staleness model does the rest: an -automation source is expected every ~12 hours, so a nightly sync that stops reporting goes -amber on its own and a `Failed` result goes red at once. Edit the ingest URL and the -district's Beacon site id in the file (it throws until you do), store the site key Beacon -minted for this automation with `Set-IDBridgeSecret -Name 'ApiKey-Beacon'`, then enable the -descriptor. Fire-and-forget: 30 s timeout, a rejection or a send failure logs a `Warn` and -never affects the run. The envelope builder (`ConvertTo-BeaconHeartbeat`, a helper in the -same file) is pure and tested under `tests\Templates\Plugins`. - ### `Invoke-PluginPostRunExport` — PostRun *(works as-is)* File: `C:\IDBridge\Plugins\Invoke-PluginPostRunExport.ps1`. Writes the user list CSVs defined in its `$exportFiles` map (file name → the `PersonTypeID`s it carries; one file diff --git a/src/IDBridge/IDBridge.psd1 b/src/IDBridge/IDBridge.psd1 index 6c64696..30137ee 100644 --- a/src/IDBridge/IDBridge.psd1 +++ b/src/IDBridge/IDBridge.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IDBridge.psm1' - ModuleVersion = '26.9.9.0' + ModuleVersion = '26.9.21.0' GUID = 'a0a0c664-888e-44e2-9c12-fc8647a520c0' Author = 'Sam Cattanach' CompanyName = 'SC Networks LLC' diff --git a/src/IDBridge/Private/Core/Write-IDBridgeRunSummary.ps1 b/src/IDBridge/Private/Core/Write-IDBridgeRunSummary.ps1 new file mode 100644 index 0000000..0af1de9 --- /dev/null +++ b/src/IDBridge/Private/Core/Write-IDBridgeRunSummary.ps1 @@ -0,0 +1,126 @@ +<# +.SYNOPSIS +Write the end-of-run summary file (Data\LastRun.json) a reader on the box can consume. + +.DESCRIPTION +Called from the finally block of Invoke-IDBridge after telemetry and before the PostRun +plugins, on every run that is not a Preview. Replaces \LastRun.json whole with +the run's counts, mode flags, timing and module version, so a monitor on the same box can +see how the last sync went without reading the log. + +The schema is versioned (schemaVersion 1) and grows additively only - a reader typed to +this schema refuses a key it does not know. Counts are APPLIED work, so a ReadOnly run +writes zeros alongside readOnly = true. lastSuccessAt is runEnd on a successful run and +CARRIED FORWARD from the previous file on a failed one (empty when there has never been +one) - the one field a single run cannot supply. A failed run contributes only the +exception CLASS name and the throwing FUNCTION name, never the message, which can contain +UPNs/DNs; no name, id, UPN, DN or group name is ever written. + +The reader is SC Networks' Beacon collector on the same box where a district runs Beacon; +nothing here leaves the machine by IDBridge's doing (see PRIVACY.md). The write is +self-contained: a failure logs a Warn and never affects the run. + +.PARAMETER RunResult +The run report built in the finally block of Invoke-IDBridge. + +.OUTPUTS +None. Side effects: one file write and log output. + +.EXAMPLE +Write-IDBridgeRunSummary -RunResult $runResult + +.NOTES + Created by: Sam Cattanach + Modified: 2026-09-21 +#> +function Write-IDBridgeRunSummary { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [pscustomobject]$RunResult + ) + + $IDConfig = Get-IDBridgeConfig + + $path = "$($IDConfig.Paths.DataRoot)\LastRun.json" + + #region Carry Forward + # A failed run has no success time of its own, so the previous file's survives it - a + # reader can tell a sync that just broke from one that has been broken for a week. No + # previous file, or one that won't parse, means no known success. + $previousSuccessAt = '' + if (Test-Path $path) { + try { + $previous = Get-Content -Path $path -Raw | ConvertFrom-Json + # ConvertFrom-Json turns the ISO-8601 stamp back into a [datetime]; it goes out + # again the way it was written. + $previousSuccessAt = if ($previous.lastSuccessAt -is [datetime]) { + $previous.lastSuccessAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") + } + else { "$($previous.lastSuccessAt)" } + } + catch { + Write-Log -Message "Run summary: Previous $path unreadable ($($_.Exception.GetType().Name)) - no carried-forward success time." -Level Trace + } + } + #endregion Carry Forward + + #region Build Payload + $directories = @( + if ($IDConfig.AD.enabled -eq $true) { 'AD' } + if ($IDConfig.Google.enabled -eq $true) { 'Google' } + ) -join '+' + if (-not $directories) { $directories = 'None' } + + $success = [bool]$RunResult.Success + $runEnd = $RunResult.RunEnd.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") + + $payload = [ordered]@{ + schemaVersion = 1 + moduleVersion = "$($MyInvocation.MyCommand.Module.Version)" + runStart = $RunResult.RunStart.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") + runEnd = $runEnd + durationSeconds = [int]$RunResult.DurationSeconds + success = $success + readOnly = [bool]$RunResult.ReadOnly + testRun = [bool]$RunResult.TestRun + directories = $directories + managed = [int]$RunResult.Counts.Managed + created = [int]$RunResult.Counts.Create + updated = [int]$RunResult.Counts.Update + deactivated = [int]$RunResult.Counts.Deactivate + groupAdds = [int]$RunResult.Counts.GroupAdd + groupRemoves = [int]$RunResult.Counts.GroupRemove + writeFailures = [int]$RunResult.Counts.Failed + thresholdExceeded = [bool](@($RunResult.ThresholdResults).Where({ $null -ne $_ -and $_.Exceeded }).Count -gt 0) + errorType = '' + errorFunction = '' + lastSuccessAt = if ($success) { $runEnd } else { $previousSuccessAt } + } + + if ($RunResult.RunError) { + # Exception CLASS and FUNCTION name only - message text can contain UPNs/DNs and never leaves the box. + $payload.errorType = $RunResult.RunError.Exception.GetType().Name + + $errorFunction = "$($RunResult.RunError.InvocationInfo.MyCommand)" + if (-not $errorFunction -and $RunResult.RunError.ScriptStackTrace) { + # A Throw statement has no MyCommand; take the function name from the first stack + # frame ("at Function-Name, : line N") and drop the file path after the comma. + $errorFunction = ($RunResult.RunError.ScriptStackTrace -split "`n")[0] -replace '^at ', '' -replace ',.*$', '' + } + $payload.errorFunction = $errorFunction + } + #endregion Build Payload + + #region Write + try { + # -ErrorAction Stop so an unwritable Data directory reaches the catch below instead + # of the error stream: the run is over and nothing here is worth failing it for. + $payload | ConvertTo-Json -Depth 3 | Set-Content -Path $path -Encoding utf8 -ErrorAction Stop + Write-Log -Message "Run summary: Wrote $path" -Level Trace + } + catch { + Write-Log -Message "Run summary: Write failed (non-fatal, run unaffected): $($_.Exception.GetType().Name)" -Level Warn + } + #endregion Write +} diff --git a/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 b/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 index ece0f82..1812e3d 100644 --- a/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 +++ b/src/IDBridge/Public/Core/Invoke-IDBridge.ps1 @@ -10,10 +10,11 @@ rows), matches person IDs to existing accounts, and computes every change list ( deactivations, updates/renames/moves, creates, and group membership) read-only. Before any writes it runs the change-volume safety guard (ChangeThreshold). It then executes the AD and Google changes only when the directory is enabled and Debug.readOnly is $false, and (in the -finally block) sends usage telemetry, runs the configured PostRun plugins with the RunResult -object (user list CSV exports live in the Invoke-PluginPostRunExport plugin), and pushes the -run log to a Google Sheet when configured. Per-user write errors are -logged and skipped; startup/OU-creation failures and a tripped change threshold abort the run. +finally block) sends usage telemetry, writes the run summary file (Data\LastRun.json), runs +the configured PostRun plugins with the RunResult object (user list CSV exports live in the +Invoke-PluginPostRunExport plugin), and pushes the run log to a Google Sheet when +configured. Per-user write errors are logged and skipped; startup/OU-creation failures and +a tripped change threshold abort the run. Only one run per RootPath executes at a time: a machine-wide mutex is taken before initialization and a second run aborts immediately, telling you a run is already in @@ -847,6 +848,20 @@ function Invoke-IDBridge { } #endregion Telemetry + #region Run Summary File + # The end-of-run summary a reader on the box consumes (Beacon's collector; see + # PRIVACY.md) - self-contained like telemetry, can never affect the run. + # Preview runs stay quiet. + try { + if ($runResult -and -not $Preview) { + Write-IDBridgeRunSummary -RunResult $runResult + } + } + catch { + Write-Log -Message "Run summary: Skipped ($($_.Exception.GetType().Name))." -Level Warn + } + #endregion Run Summary File + #region PostRun Plugins # SecureStrings (account keys, passphrase API secrets) are scrubbed from the report # before any plugin sees it - the run is over, nothing downstream needs them. Isolated diff --git a/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 b/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 index 3f1fd39..6c232f3 100644 --- a/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 +++ b/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 @@ -1,5 +1,5 @@ # IDBridgeConfig.psd1 — generated by Install-IDBridge -# TemplateVersion: 2 +# TemplateVersion: 3 # Every feature is disabled. Replace the placeholder values, then enable features one at a time. @{ @@ -93,6 +93,5 @@ @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunWebhook' } @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunExport' } @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunOrphanReport' } - @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunBeacon' } ) } diff --git a/src/IDBridge/Templates/Plugins/Invoke-PluginPostRunBeacon.ps1 b/src/IDBridge/Templates/Plugins/Invoke-PluginPostRunBeacon.ps1 deleted file mode 100644 index 23aa8f9..0000000 --- a/src/IDBridge/Templates/Plugins/Invoke-PluginPostRunBeacon.ps1 +++ /dev/null @@ -1,115 +0,0 @@ -# Beacon Heartbeat Plugin — IDBridge PostRun plugin template -# TemplateVersion: 1 -<# -Shipped with the IDBridge module and copied to \Plugins by Install-IDBridge. -Set the Beacon ingest URL and the site id below, store the automation's Beacon site key -in the vault (Set-IDBridgeSecret -Name 'ApiKey-Beacon'), then enable the plugin in -IDBridgeConfig.psd1 — it throws until the placeholders are edited. - -POSTs one heartbeat to Beacon (SC Networks' status dashboard) after every run, failed and -ReadOnly runs included: an envelope of sourceType 'automation' whose data carries result -(Success / Warning when a write failed / Failed when the run threw), a one-line message -built from the counts ("412 managed: 3 created, 5 updated, 1 deactivated, 2 group changes, -0 write failures, 47s"), and the counts and flags themselves. Beacon's staleness model does -the rest: an automation source is expected every ~12 hours, so a nightly run that stops -reporting goes amber on its own and a Failed result goes red at once. Counts and flags -only, no per-user data. - -The send is fire-and-forget: short timeout, failures are logged as a Warn and never affect -the run. The envelope is built by ConvertTo-BeaconHeartbeat below, a pure helper the tests -under tests\Templates\Plugins exercise. -#> - - -function Invoke-PluginPostRunBeacon { - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true)] - [pscustomobject]$RunResult - ) - - $ingestUrl = "https://YOUR-BEACON/api/ingest" # e.g. https://beacon.scnlabs.net/api/ingest - $siteId = "YOUR-SITE-ID" # the district's Beacon site id (Beacon Setup → Sites) - $sourceId = "idbridge-sync" # how this install shows on the dashboard; one per install - - if ($ingestUrl -eq "https://YOUR-BEACON/api/ingest" -or $siteId -eq "YOUR-SITE-ID") { - Throw "Invoke-PluginPostRunBeacon: this plugin template still has placeholder values. Edit $($PSCommandPath) for your district before enabling it." - } - - $envelope = ConvertTo-BeaconHeartbeat -RunResult $RunResult -SiteId $siteId -SourceId $sourceId - - try { - $apiKey = Get-IDBridgeSecret -Name 'ApiKey-Beacon' -AsPlainText - $response = Invoke-RestMethod -Uri $ingestUrl -Method Post -Headers @{ 'x-api-key' = $apiKey } ` - -Body ($envelope | ConvertTo-Json -Depth 5 -Compress) -ContentType 'application/json' -TimeoutSec 30 - if ($response.rejected -gt 0) { - Write-Log -Message "Plugin: Invoke-PluginPostRunBeacon: Beacon rejected the heartbeat (run unaffected): $($response.errors | ConvertTo-Json -Compress)" -Level Warn - } - else { - Write-Log -Message "Plugin: Invoke-PluginPostRunBeacon sent a $($envelope.data.result) heartbeat: $($envelope.data.message)" -Level Trace - } - } - catch { - Write-Log -Message "Plugin: Invoke-PluginPostRunBeacon send failed (run unaffected): $($_.Exception.GetType().Name)" -Level Warn - } -} - - -# The heartbeat envelope Beacon ingests, built from the RunResult alone. Pure: no config, -# no vault, no network — which is what makes it testable without a run. -function ConvertTo-BeaconHeartbeat { - [CmdletBinding()] - param ( - [Parameter(Mandatory = $true)] - [pscustomobject]$RunResult, - - [Parameter(Mandatory = $true)] - [string]$SiteId, - - [Parameter(Mandatory = $true)] - [string]$SourceId - ) - - $counts = $RunResult.Counts - $writeFailures = [int]$counts.Failed - - # Failed when the run threw (a threshold abort, a startup failure, an unhandled write - # error); Warning when the run finished but a write did not stick; Success otherwise — - # a ReadOnly run that computed cleanly is a Success, its mode named in the message. - if (-not $RunResult.Success) { - $result = 'Failed' - $error = $RunResult.RunError - $reason = if ($error) { "$($error.Exception.GetType().Name): $($error.Exception.Message)" } else { 'unknown error' } - if ($reason.Length -gt 160) { $reason = $reason.Substring(0, 157) + '...' } - $message = "run failed after $($RunResult.DurationSeconds)s -- $reason" - } - else { - $result = if ($writeFailures -gt 0) { 'Warning' } else { 'Success' } - $mode = if ($RunResult.ReadOnly) { 'ReadOnly: ' } elseif ($RunResult.TestRun) { 'test run: ' } else { '' } - $message = "$mode$($counts.Managed) managed: $($counts.Create) created, $($counts.Update) updated, " + - "$($counts.Deactivate) deactivated, $([int]$counts.GroupAdd + [int]$counts.GroupRemove) group changes, " + - "$writeFailures write failures, $($RunResult.DurationSeconds)s" - } - - return [ordered]@{ - siteId = $SiteId - sourceType = 'automation' - sourceId = $SourceId - collectedAt = $RunResult.RunEnd.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") - data = [ordered]@{ - result = $result - message = $message - moduleVersion = $RunResult.ModuleVersion - durationSeconds = $RunResult.DurationSeconds - readOnly = [bool]$RunResult.ReadOnly - testRun = [bool]$RunResult.TestRun - managed = [int]$counts.Managed - created = [int]$counts.Create - updated = [int]$counts.Update - deactivated = [int]$counts.Deactivate - groupAdds = [int]$counts.GroupAdd - groupRemoves = [int]$counts.GroupRemove - writeFailures = $writeFailures - } - } -} diff --git a/tests/Private/Core/Write-IDBridgeRunSummary.Tests.ps1 b/tests/Private/Core/Write-IDBridgeRunSummary.Tests.ps1 new file mode 100644 index 0000000..cecdbfd --- /dev/null +++ b/tests/Private/Core/Write-IDBridgeRunSummary.Tests.ps1 @@ -0,0 +1,210 @@ +<# +.SYNOPSIS +Unit tests for the end-of-run summary file (Write-IDBridgeRunSummary). + +.DESCRIPTION +The file is a contract with a reader on the box, so the two things that could break that +reader are pinned exactly: the key set (a stranger key is what could carry a person out of +the module) and lastSuccessAt, the one field a single run cannot supply - runEnd on a +success, carried forward from the previous file on a failure. The write itself is +best-effort: an unwritable Data directory logs a Warn and the run carries on. +#> + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..' '..' 'TestHelper.psm1') -Force + Import-IDBridgeForTest + + # The schema, in the order it is written. + $schemaKeys = @( + 'schemaVersion', 'moduleVersion', 'runStart', 'runEnd', 'durationSeconds', + 'success', 'readOnly', 'testRun', 'directories', + 'managed', 'created', 'updated', 'deactivated', 'groupAdds', 'groupRemoves', + 'writeFailures', 'thresholdExceeded', 'errorType', 'errorFunction', 'lastSuccessAt' + ) + + function New-TestRunResult { + param ( + [bool]$Success = $true, + $RunError = $null, + [bool]$ReadOnly = $false, + [bool]$TestRun = $false, + [int]$Failed = 0, + $ThresholdResults = @() + ) + [PSCustomObject]@{ + SchemaVersion = 1 + ModuleVersion = '26.9.1.3' + Success = $Success + RunError = $RunError + RunStart = [DateTime]::new(2026, 9, 9, 3, 0, 0, [DateTimeKind]::Utc) + RunEnd = [DateTime]::new(2026, 9, 9, 3, 0, 47, [DateTimeKind]::Utc) + DurationSeconds = 47 + ReadOnly = $ReadOnly + TestRun = $TestRun + Counts = [PSCustomObject]@{ + Managed = 412; Create = 3; Update = 5; Deactivate = 1; GroupAdd = 2; GroupRemove = 0; Failed = $Failed + } + ThresholdResults = $ThresholdResults + } + } + + function New-TestRunError { + param ([string]$Message) + try { throw [System.InvalidOperationException]::new($Message) } catch { return $_ } + } +} + +Describe 'Write-IDBridgeRunSummary' { + BeforeEach { + # A Data directory of its own per test, so the carry-forward cases control exactly + # which previous file is there. The function addresses the file the way every other + # Data-file reader does, so the test builds the same path. + $dataRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -Path $dataRoot -ItemType Directory | Out-Null + $summaryPath = "$dataRoot\LastRun.json" + } + + It 'writes exactly the schema keys, in order, for a successful run' { + $runResult = New-TestRunResult + + InModuleScope IDBridge -Parameters @{ runResult = $runResult; dataRoot = $dataRoot } { + Mock Write-Log {} + Mock Get-IDBridgeConfig { @{ Paths = @{ DataRoot = $dataRoot }; AD = @{ enabled = $true }; Google = @{ enabled = $true } } } + + Write-IDBridgeRunSummary -RunResult $runResult + } + + $text = Get-Content -Path $summaryPath -Raw + $summary = $text | ConvertFrom-Json + + @($summary.PSObject.Properties.Name) | Should -Be $schemaKeys + + $summary.schemaVersion | Should -Be 1 + $summary.durationSeconds | Should -Be 47 + $summary.success | Should -BeTrue + $summary.readOnly | Should -BeFalse + $summary.testRun | Should -BeFalse + $summary.directories | Should -Be 'AD+Google' + $summary.managed | Should -Be 412 + $summary.created | Should -Be 3 + $summary.updated | Should -Be 5 + $summary.deactivated | Should -Be 1 + $summary.groupAdds | Should -Be 2 + $summary.groupRemoves | Should -Be 0 + $summary.writeFailures | Should -Be 0 + $summary.thresholdExceeded | Should -BeFalse + $summary.errorType | Should -Be '' + $summary.errorFunction | Should -Be '' + + # Stamps go out ISO-8601 UTC (ConvertFrom-Json reads them back as [datetime], so the + # text is what pins the format), and a successful run IS the last success. + $text | Should -BeLike '*"runStart": "2026-09-09T03:00:00Z"*' + $text | Should -BeLike '*"runEnd": "2026-09-09T03:00:47Z"*' + $text | Should -BeLike '*"lastSuccessAt": "2026-09-09T03:00:47Z"*' + } + + It 'carries the previous success time forward through a failed run, without its message' { + $goodRun = New-TestRunResult + $failedRun = New-TestRunResult -Success $false -Failed 2 ` + -RunError (New-TestRunError -Message 'Change threshold exceeded: AD 31% (limit 25%) - tuser@example.org') + + InModuleScope IDBridge -Parameters @{ goodRun = $goodRun; failedRun = $failedRun; dataRoot = $dataRoot } { + Mock Write-Log {} + Mock Get-IDBridgeConfig { @{ Paths = @{ DataRoot = $dataRoot }; AD = @{ enabled = $true }; Google = @{ enabled = $true } } } + + # The good run first - its runEnd is the success time the failure inherits. + Write-IDBridgeRunSummary -RunResult $goodRun + Write-IDBridgeRunSummary -RunResult $failedRun + } + + $text = Get-Content -Path $summaryPath -Raw + $summary = $text | ConvertFrom-Json + + $summary.success | Should -BeFalse + $summary.writeFailures | Should -Be 2 + $summary.errorType | Should -Be 'InvalidOperationException' + $summary.errorFunction | Should -Not -BeNullOrEmpty + $text | Should -BeLike '*"lastSuccessAt": "2026-09-09T03:00:47Z"*' + + # The exception MESSAGE never reaches the file. + $text | Should -Not -BeLike '*Change threshold exceeded*' + $text | Should -Not -BeLike '*@example.org*' + } + + It 'has no success time to carry forward when there is no previous file' { + $failedRun = New-TestRunResult -Success $false -RunError (New-TestRunError -Message 'Change threshold exceeded: AD 31% (limit 25%).') + + InModuleScope IDBridge -Parameters @{ failedRun = $failedRun; dataRoot = $dataRoot } { + Mock Write-Log {} + Mock Get-IDBridgeConfig { @{ Paths = @{ DataRoot = $dataRoot }; AD = @{ enabled = $true }; Google = @{ enabled = $false } } } + + Write-IDBridgeRunSummary -RunResult $failedRun + } + + $summary = Get-Content -Path $summaryPath -Raw | ConvertFrom-Json + $summary.lastSuccessAt | Should -Be '' + $summary.directories | Should -Be 'AD' + } + + It 'reports thresholdExceeded when a directory tripped the guard' { + $thresholds = @( + [PSCustomObject]@{ Directory = 'AD'; Exceeded = $false; Skipped = $false; Percent = 3 } + [PSCustomObject]@{ Directory = 'Google'; Exceeded = $true; Skipped = $false; Percent = 31 } + ) + $runResult = New-TestRunResult -ThresholdResults $thresholds + + InModuleScope IDBridge -Parameters @{ runResult = $runResult; dataRoot = $dataRoot } { + Mock Write-Log {} + Mock Get-IDBridgeConfig { @{ Paths = @{ DataRoot = $dataRoot }; AD = @{ enabled = $true }; Google = @{ enabled = $true } } } + + Write-IDBridgeRunSummary -RunResult $runResult + } + + (Get-Content -Path $summaryPath -Raw | ConvertFrom-Json).thresholdExceeded | Should -BeTrue + } + + It 'flags a ReadOnly run and writes the counts it was given' { + $runResult = New-TestRunResult -ReadOnly $true + + InModuleScope IDBridge -Parameters @{ runResult = $runResult; dataRoot = $dataRoot } { + Mock Write-Log {} + Mock Get-IDBridgeConfig { @{ Paths = @{ DataRoot = $dataRoot }; AD = @{ enabled = $true }; Google = @{ enabled = $true } } } + + Write-IDBridgeRunSummary -RunResult $runResult + } + + $summary = Get-Content -Path $summaryPath -Raw | ConvertFrom-Json + $summary.readOnly | Should -BeTrue + $summary.success | Should -BeTrue + $summary.created | Should -Be 3 + } + + It 'treats a previous file that will not parse as no previous file' { + Set-Content -Path $summaryPath -Value '{ this is not json' + $failedRun = New-TestRunResult -Success $false -RunError (New-TestRunError -Message 'Change threshold exceeded: AD 31% (limit 25%).') + + InModuleScope IDBridge -Parameters @{ failedRun = $failedRun; dataRoot = $dataRoot } { + Mock Write-Log {} + Mock Get-IDBridgeConfig { @{ Paths = @{ DataRoot = $dataRoot }; AD = @{ enabled = $true }; Google = @{ enabled = $true } } } + + { Write-IDBridgeRunSummary -RunResult $failedRun } | Should -Not -Throw + } + + (Get-Content -Path $summaryPath -Raw | ConvertFrom-Json).lastSuccessAt | Should -Be '' + } + + It 'logs a Warn and does not throw when the file cannot be written' { + $runResult = New-TestRunResult + # A Data directory that does not exist - Set-Content has nowhere to write. + $missingRoot = Join-Path $dataRoot 'gone' + + InModuleScope IDBridge -Parameters @{ runResult = $runResult; dataRoot = $missingRoot } { + Mock Write-Log {} + Mock Get-IDBridgeConfig { @{ Paths = @{ DataRoot = $dataRoot }; AD = @{ enabled = $true }; Google = @{ enabled = $true } } } + + { Write-IDBridgeRunSummary -RunResult $runResult } | Should -Not -Throw + + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -eq 'Warn' -and $Message -like 'Run summary: Write failed*' } + } + } +} diff --git a/tests/Templates/Plugins/Invoke-PluginPostRunBeacon.Tests.ps1 b/tests/Templates/Plugins/Invoke-PluginPostRunBeacon.Tests.ps1 deleted file mode 100644 index 69a342b..0000000 --- a/tests/Templates/Plugins/Invoke-PluginPostRunBeacon.Tests.ps1 +++ /dev/null @@ -1,144 +0,0 @@ -<# -.SYNOPSIS -Unit tests for the shipped Invoke-PluginPostRunBeacon template: the heartbeat envelope and the send. - -.DESCRIPTION -The template is dot-sourced from src\IDBridge\Templates\Plugins the way Invoke-PostRunPlugins -dot-sources an installed copy. ConvertTo-BeaconHeartbeat is pure and gets the result/message -cases; the send is exercised with Get-IDBridgeSecret, Invoke-RestMethod and Write-Log mocked -(the template's own copy keeps its placeholders, so the plugin entry point is tested for the -throw those placeholders promise). -#> - -BeforeAll { - Import-Module (Join-Path $PSScriptRoot '..' '..' 'TestHelper.psm1') -Force - Import-IDBridgeForTest - . (Join-Path (Split-Path (Get-IDBridgeManifestPath) -Parent) 'Templates' 'Plugins' 'Invoke-PluginPostRunBeacon.ps1') - - function New-TestRunResult { - param ( - [bool]$Success = $true, - $RunError = $null, - [bool]$ReadOnly = $false, - [bool]$TestRun = $false, - [int]$Failed = 0 - ) - [PSCustomObject]@{ - SchemaVersion = 1 - ModuleVersion = '26.9.1.3' - Success = $Success - RunError = $RunError - RunStart = [DateTime]::new(2026, 9, 9, 3, 0, 0, [DateTimeKind]::Utc) - RunEnd = [DateTime]::new(2026, 9, 9, 3, 0, 47, [DateTimeKind]::Utc) - DurationSeconds = 47 - ReadOnly = $ReadOnly - TestRun = $TestRun - Counts = [PSCustomObject]@{ - Managed = 412; Create = 3; Update = 5; Deactivate = 1; GroupAdd = 2; GroupRemove = 0; Failed = $Failed - } - } - } -} - -Describe 'ConvertTo-BeaconHeartbeat' { - It 'builds a Success envelope with the counts in the message and the data' { - $envelope = ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult) -SiteId 'colby' -SourceId 'idbridge-sync' - - $envelope.siteId | Should -Be 'colby' - $envelope.sourceType | Should -Be 'automation' - $envelope.sourceId | Should -Be 'idbridge-sync' - $envelope.collectedAt | Should -Be '2026-09-09T03:00:47Z' - $envelope.data.result | Should -Be 'Success' - $envelope.data.message | Should -Be '412 managed: 3 created, 5 updated, 1 deactivated, 2 group changes, 0 write failures, 47s' - $envelope.data.managed | Should -Be 412 - $envelope.data.created | Should -Be 3 - $envelope.data.groupAdds | Should -Be 2 - $envelope.data.writeFailures | Should -Be 0 - $envelope.data.moduleVersion | Should -Be '26.9.1.3' - $envelope.data.readOnly | Should -BeFalse - } - - It 'is a Warning when a write did not stick' { - $envelope = ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -Failed 2) -SiteId 'colby' -SourceId 'idbridge-sync' - $envelope.data.result | Should -Be 'Warning' - $envelope.data.message | Should -BeLike '*2 write failures, 47s' - $envelope.data.writeFailures | Should -Be 2 - } - - It 'is Failed with the error class and message when the run threw' { - $runError = $null - try { throw [System.InvalidOperationException]::new('Change threshold exceeded: AD 31% (limit 25%).') } catch { $runError = $_ } - $envelope = ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -Success $false -RunError $runError) -SiteId 'colby' -SourceId 'idbridge-sync' - $envelope.data.result | Should -Be 'Failed' - $envelope.data.message | Should -Be 'run failed after 47s -- InvalidOperationException: Change threshold exceeded: AD 31% (limit 25%).' - } - - It 'names the mode of a ReadOnly or test run in the message, still a Success' { - (ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -ReadOnly $true) -SiteId 'colby' -SourceId 'idbridge-sync').data.message | - Should -BeLike 'ReadOnly: 412 managed:*' - (ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -TestRun $true) -SiteId 'colby' -SourceId 'idbridge-sync').data.message | - Should -BeLike 'test run: 412 managed:*' - (ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -ReadOnly $true) -SiteId 'colby' -SourceId 'idbridge-sync').data.result | - Should -Be 'Success' - } - - It 'serializes to the envelope Beacon ingests -- no per-user data, counts only' { - $json = ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult) -SiteId 'colby' -SourceId 'idbridge-sync' | ConvertTo-Json -Depth 5 -Compress - $json | Should -BeLike '{"siteId":"colby","sourceType":"automation","sourceId":"idbridge-sync","collectedAt":"2026-09-09T03:00:47Z","data":{"result":"Success","message":"412 managed:*' - $json | Should -Not -BeLike '*SourceData*' - $json | Should -Not -BeLike '*Applied*' - } -} - -Describe 'Invoke-PluginPostRunBeacon' { - It 'throws while the template still carries its placeholders' { - { Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) } | Should -Throw '*placeholder values*' - } - - Context 'with the placeholders edited' { - BeforeAll { - # An edited copy, as a district's Plugins folder holds it. - $template = Get-Content -Raw (Join-Path (Split-Path (Get-IDBridgeManifestPath) -Parent) 'Templates' 'Plugins' 'Invoke-PluginPostRunBeacon.ps1') - $edited = $template.Replace('"https://YOUR-BEACON/api/ingest" #', '"https://beacon.test/api/ingest" #').Replace('"YOUR-SITE-ID" #', '"colby" #') - $editedPath = Join-Path $TestDrive 'Invoke-PluginPostRunBeacon.ps1' - Set-Content -Path $editedPath -Value $edited - . $editedPath - } - - BeforeEach { - Mock Write-Log {} - Mock Get-IDBridgeSecret { 'bcn_test' } - } - - It 'POSTs the envelope to the ingest URL with the vault key and logs the send' { - Mock Invoke-RestMethod { [pscustomobject]@{ accepted = 1; rejected = 0 } } - Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) - Should -Invoke Get-IDBridgeSecret -Times 1 -Exactly -ParameterFilter { $Name -eq 'ApiKey-Beacon' -and $AsPlainText } - Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { - $Uri -eq 'https://beacon.test/api/ingest' -and $Method -eq 'Post' -and $Headers['x-api-key'] -eq 'bcn_test' -and - $ContentType -eq 'application/json' -and $TimeoutSec -eq 30 -and $Body -like '{"siteId":"colby","sourceType":"automation","sourceId":"idbridge-sync",*' - } - Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -eq 'Trace' -and $Message -like '*sent a Success heartbeat: 412 managed:*' } - } - - It 'logs a Warn and does not throw when Beacon rejects the heartbeat' { - Mock Invoke-RestMethod { [pscustomobject]@{ accepted = 0; rejected = 1; errors = @([pscustomobject]@{ index = 0; error = 'siteId unknown' }) } } - { Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) } | Should -Not -Throw - Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -eq 'Warn' -and $Message -like '*rejected the heartbeat*siteId unknown*' } - } - - It 'logs a Warn and does not throw when the send fails -- the run is unaffected' { - Mock Invoke-RestMethod { throw [System.Net.WebException]::new('The remote name could not be resolved') } - { Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) } | Should -Not -Throw - Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -eq 'Warn' -and $Message -like '*send failed (run unaffected): WebException' } - } - - It 'logs a Warn and does not throw when the vault has no key' { - Mock Get-IDBridgeSecret { throw [System.IO.FileNotFoundException]::new('ApiKey-Beacon') } - Mock Invoke-RestMethod { throw 'must not be reached' } - { Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) } | Should -Not -Throw - Should -Invoke Invoke-RestMethod -Times 0 -Exactly - Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -eq 'Warn' -and $Message -like '*send failed (run unaffected): FileNotFoundException' } - } - } -}