Author SHA1 Message Date
kyleandClaude Opus 5.5 c5fe69a813 Add a Pester test suite for the collectors
84 tests in collectors/tests, run with Invoke-CollectorTests.ps1 (~15 s,
PowerShell 7.2+ and Pester 5.5+):

- Module.Tests: the shared module (property lookup, time format,
  config/lookback, sent-run state and pruning, dry runs, exit codes,
  401 detection)
- Veeam/Mars/Wsb/Vb365.Tests: each collector run in its own process
  against its mock and a stand-in ingest API, asserting the behaviours
  confirmed on real servers: VBR 13 live schedule, copy jobs, MARS UTC
  times and ErrorCode 0, WSB culture-formatted times and nested VMs,
  VB365 organization mapping, log prefixes and retries, state and
  resending, rejected keys
- Installers.Tests: config, files and scheduled task from each
  installer, with stand-ins for icacls / Task Scheduler / the key prompt

Collectors run under the test runner's PowerShell, or another shell via
-CollectorShell (e.g. powershell.exe, to test under Windows PowerShell
5.1 like the MARS, WSB and VB365 servers). The mocks take their "now"
from MOCK_NOW so expectations can't drift with the clock; the WSB mock
gains -RunningJobFinished for the running-to-finished case.

Each of 12 past bugs, put back one at a time, fails the test for its
behaviour (and the runner exits 1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 13:36:27 -06:00
kyleandClaude Opus 5.5 2fa9eea274 Replace basic auth with Microsoft 365 sign-in
nginx now checks every dashboard and API request with oauth2-proxy
(auth_request), which signs users in with Microsoft Entra ID. Who may
sign in is controlled in Entra: a single-tenant app registration with
"Assignment required", so only assigned users/groups get through.

- deploy/oauth2-proxy.cfg (entra-id provider, auth-only mode, secure
  cookie, trusted proxy = localhost) and deploy/oauth2-proxy.service
- nginx: pages redirect to Microsoft sign-in and back to the same URL;
  API calls get a plain 401 (the page already asks to reload);
  /api/v1/ingest stays key-only for collectors; /oauth2/auth is
  internal; the signed-in user is passed to the API in headers nginx
  sets (so browsers can't spoof them); dashboard files are served with
  Cache-Control: no-cache so updates show up at once
- GET /api/v1/me; the top bar shows the signed-in user and a Sign out
  that also signs out of Microsoft (otherwise SSO signs straight back in)
- README: Entra and server setup steps

Tested locally with oauth2-proxy 7.15.5 and nginx 1.31 running the
deploy configs (TLS, self-signed): redirects to the tenant's sign-in
with the return URL kept, 401s, ingest bypass, sign-out allow-list,
cookie flags, security headers. The signed-in path was tested with a
stand-in for oauth2-proxy's auth answer; a real Entra sign-in wasn't.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 11:31:35 -06:00
26 changed files with 1242 additions and 23 deletions
+61 -8
View File
@@ -5,8 +5,9 @@ Proof of concept backup dashboard, intended to display backup jobs from multiple
```
collectors ──POST /api/v1/ingest (API key)──▶ ┌──────────────┐
│ FastAPI + │
browser ──nginx (basic auth, TLS)──▶ /api/* ─▶│ SQLite │
└─ static files: public/ └──────────────┘
browser ──nginx (TLS)──▶ /api/* ─────────────▶│ SQLite │
│ └─ static files: public/ └──────────────┘
└─ auth_request ─▶ oauth2-proxy ─▶ Microsoft Entra ID (Microsoft 365 sign-in)
```
- `public/`: static frontend (no build step). Add `?mock=1` to the URL to use built-in sample data.
@@ -72,12 +73,58 @@ The OpenAPI schema is at `/api/v1/openapi.json`.
## Authentication
- **Dashboard users:** nginx basic auth over HTTPS (`deploy/nginx.conf`). Every user can see every tenant.
*Planned:* Microsoft 365 sign-in through [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/)
with the Entra ID provider and nginx `auth_request`. That's an nginx-only change. The frontend
already tells the user to reload when a session expires (HTTP 401).
- **Dashboard users:** Microsoft 365 sign-in. nginx checks every request with
[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/), which signs users in with Microsoft
Entra ID. Who may sign in is controlled in Entra (users or a group assigned to the app). Every
signed-in user can see every tenant. Sessions last 12 hours; signing in again is usually silent.
The dashboard shows who's signed in; **Sign out** also signs out of Microsoft in that browser,
otherwise the next visit would sign straight back in.
- **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored.
To revoke one, run `python manage.py revoke-key NAME`.
To revoke one, run `python manage.py revoke-key NAME`. `/api/v1/ingest` is the only path that
doesn't need Microsoft sign-in.
### Microsoft 365 sign-in setup
**In the Microsoft Entra admin center** (entra.microsoft.com), as an admin of your tenant:
1. *App registrations → New registration*. Name: `Backup Dashboard`. Supported account types:
**Accounts in this organizational directory only** (single tenant). Redirect URI: platform
**Web**, `https://<your dashboard host>/oauth2/callback`. Register.
2. On its *Overview*, note the **Application (client) ID** and **Directory (tenant) ID**.
3. *Certificates & secrets → New client secret*. Copy the secret's **Value** now (it's only shown
once). Note its expiry date: sign-in stops working when it expires, so set a reminder to
create a new one before then.
4. *Enterprise applications → Backup Dashboard → Properties*: set **Assignment required** to
**Yes**. Then *Users and groups → Add user/group* and add the people (or a group) who should
see the dashboard. Everyone else in the tenant is refused by Microsoft before reaching it.
No API permissions beyond the default `User.Read` are needed.
**On the dashboard server:**
1. Install oauth2-proxy 7.8 or newer from its
[releases](https://github.com/oauth2-proxy/oauth2-proxy/releases) as `/usr/local/bin/oauth2-proxy`,
and create its user: `sudo useradd --system --no-create-home oauth2-proxy`.
2. Install the config and the client secret, readable only by root and oauth2-proxy:
```bash
sudo install -d -m 750 -g oauth2-proxy /etc/oauth2-proxy
sudo install -m 640 -g oauth2-proxy deploy/oauth2-proxy.cfg /etc/oauth2-proxy/backup-dashboard.cfg
sudo sh -c 'umask 027; printf %s "<client secret value>" > /etc/oauth2-proxy/client-secret' && sudo chgrp oauth2-proxy /etc/oauth2-proxy/client-secret
```
In `/etc/oauth2-proxy/backup-dashboard.cfg`, fill in `<TENANT_ID>`, `<APPLICATION_CLIENT_ID>`,
`<DASHBOARD_HOST>`, and `<COOKIE_SECRET>`, generated with:
`openssl rand -base64 32 | tr -- '+/' '-_'`
3. Install `deploy/oauth2-proxy.service` into `/etc/systemd/system/`, then
`sudo systemctl daemon-reload && sudo systemctl enable --now oauth2-proxy`.
Check it with `journalctl -u oauth2-proxy -n 20`: it should say "OAuthProxy configured for
Microsoft Entra ID".
4. Install the new `deploy/nginx.conf` (keeping your server name and certificate paths), then
`sudo nginx -t && sudo systemctl reload nginx`. Basic auth and its htpasswd file are no longer
used.
**Troubleshooting:** sign-in problems show in `journalctl -u oauth2-proxy`. Microsoft's own
errors (wrong redirect URI, user not assigned, expired secret) appear on its sign-in page with an
`AADSTS…` code.
## Development
@@ -91,12 +138,18 @@ BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload
# → http://localhost:8000
```
Collector tests (PowerShell 7 + Pester; see [collectors/tests](collectors/tests/README.md)):
```powershell
./collectors/tests/Invoke-CollectorTests.ps1
```
## Deployment (Linux, nginx)
1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`.
2. Copy `deploy/backup-dashboard.env` to `/etc/backup-dashboard/backup-dashboard.env`. Both the service and `manage.py` read their settings from it.
3. Create a `backupdash` system user, then install `deploy/backup-dashboard-api.service` into `/etc/systemd/system/` and enable it. The database is created on first start.
4. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and create users with `htpasswd -B`.
4. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and set up Microsoft 365 sign-in (see [Microsoft 365 sign-in setup](#microsoft-365-sign-in-setup)).
5. Run `manage.py` as the service user. It finds the database through the env file:
```bash
cd /opt/backup-dashboard/backend
+2 -2
View File
@@ -1,7 +1,7 @@
"""Collector (machine) authentication.
Dashboard *users* are authenticated by nginx in front of the app (basic auth today,
oauth2-proxy + Microsoft Entra ID later), so nothing here deals with people.
Dashboard *users* are authenticated by nginx in front of the app (Microsoft 365 sign-in via
oauth2-proxy), so nothing here deals with people.
"""
import hashlib
+10
View File
@@ -25,6 +25,16 @@ def health(conn: sqlite3.Connection = Depends(db.get_db)):
return {"status": "ok"}
@router.get("/me")
def me(request: Request):
# Set by nginx from oauth2-proxy's sign-in check (nginx overwrites anything the browser sends).
# Display only: access control happens in nginx. Empty in dev or without Microsoft sign-in.
return {
"email": request.headers.get("x-forwarded-email") or None,
"name": request.headers.get("x-forwarded-preferred-username") or request.headers.get("x-forwarded-user") or None,
}
@router.get("/jobs")
def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)):
return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)}
+6
View File
@@ -122,3 +122,9 @@ def test_tenant_name_is_case_insensitive(client):
body["tenant"] = "ACME CORP"
client.post("/api/v1/ingest", json=body)
assert len(client.get("/api/v1/jobs").json()["jobs"]) == 1
def test_me_reports_signed_in_user_from_proxy_headers(client):
assert client.get("/api/v1/me").json() == {"email": None, "name": None}
r = client.get("/api/v1/me", headers={"X-Forwarded-Email": "[email protected]", "X-Forwarded-User": "abc-123"})
assert r.json() == {"email": "[email protected]", "name": "abc-123"}
+2 -1
View File
@@ -7,7 +7,8 @@
# ./Send-MarsBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
# Fixed to the top of the hour, and IDs derived from times, so repeated runs see the same jobs
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
$notSet = [datetime]::new(1601, 1, 1)
function New-StableGuid([string]$Seed) {
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
+139
View File
@@ -0,0 +1,139 @@
# The collector installers, with stand-ins for the Windows-only commands they call (icacls, Task
# Scheduler, Unblock-File, the API key prompt). Checks what each would write and register.
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$collectorsDir = Split-Path -Parent $PSScriptRoot
$shell = if ($env:COLLECTOR_TEST_SHELL) { $env:COLLECTOR_TEST_SHELL } else { (Get-Process -Id $PID).Path }
$pwshPath = (Get-Process -Id $PID).Path # stands in for an installed PowerShell 7
# Runs one installer in a fresh process and sandbox; returns what it wrote and registered
function Invoke-Installer {
param([string]$Collector, [string]$Arguments, [switch]$NoProductModule)
$sandbox = New-TestDir 'installer'
$src = Join-Path $sandbox 'collectors'
Copy-Item -Path $collectorsDir -Destination $src -Recurse
$installer = (Get-ChildItem (Join-Path $src $Collector) -Filter 'Install-*.ps1').FullName
# The test runner isn't elevated
(Get-Content $installer) -notmatch '^#Requires -RunAsAdministrator' | Set-Content $installer
foreach ($d in 'ProgramData', 'ProgramFiles', 'SystemRoot') { New-Item -ItemType Directory -Path (Join-Path $sandbox $d) | Out-Null }
$record = Join-Path $sandbox 'record.txt'
$stubs = @'
function global:Record($what) { Add-Content $env:REC_FILE $what }
function global:Read-Host { param([switch]$AsSecureString, $Prompt) ConvertTo-SecureString 'bdk_testkey' -AsPlainText -Force }
function global:icacls.exe { Record "icacls $($args -join ' ')"; $global:LASTEXITCODE = 0 }
function global:Unblock-File { param($Path) Record "unblock $(Split-Path -Leaf $Path)" }
function global:Get-Module { param([switch]$ListAvailable, $Name) if (-not $env:STUB_NO_MODULE) { [pscustomobject]@{ Name = $Name } } }
function global:New-ScheduledTaskAction { param($Execute, $Argument) Record "exe $Execute"; Record "args $Argument" }
function global:New-ScheduledTaskTrigger { param([switch]$Once, $At, $RepetitionInterval) Record "every $([int]$RepetitionInterval.TotalMinutes)" }
function global:New-ScheduledTaskPrincipal { param($UserId, $LogonType, $RunLevel) Record "principal $UserId $LogonType $RunLevel" }
function global:New-ScheduledTaskSettingsSet { param($ExecutionTimeLimit, $MultipleInstances, [switch]$StartWhenAvailable) Record "settings $MultipleInstances" }
function global:Register-ScheduledTask { param($TaskName, $Action, $Trigger, $Principal, $Settings, $Description, [switch]$Force) Record "task $TaskName" }
'@
$env:REC_FILE = $record
$env:STUB_NO_MODULE = if ($NoProductModule) { '1' } else { '' }
$saved = @{ ProgramData = $env:ProgramData; ProgramFiles = $env:ProgramFiles; SystemRoot = $env:SystemRoot }
try {
foreach ($d in $saved.Keys) { Set-Item "env:$d" (Join-Path $sandbox $d) }
Push-Location (Split-Path $installer)
$output = @(& $shell -NoProfile -NonInteractive -Command "$stubs; & '$installer' $Arguments" 2>&1 | ForEach-Object { "$_" })
} finally {
Pop-Location
foreach ($d in $saved.Keys) { Set-Item "env:$d" $saved[$d] }
}
$configFile = Get-ChildItem (Join-Path (Join-Path $sandbox 'ProgramData') 'BackupDashboard') -Filter '*-collector.json' -ErrorAction SilentlyContinue
$installDir = Join-Path (Join-Path $sandbox 'ProgramFiles') 'BackupDashboard'
return [pscustomobject]@{
Output = $output -join "`n"
Config = if ($configFile) { Get-Content $configFile.FullName -Raw | ConvertFrom-Json } else { $null }
Files = @(Get-ChildItem $installDir -ErrorAction SilentlyContinue | ForEach-Object Name | Sort-Object)
Record = @(if (Test-Path $record) { Get-Content $record })
Sandbox = $sandbox
}
}
}
Describe 'Collector installers' {
Context 'Veeam' {
BeforeAll { $i = Invoke-Installer veeam "-ApiUrl https://dash.example.com/ -Tenant 'Acme Corp' -PwshPath '$pwshPath'" }
It 'writes the config, including the API key from the prompt' {
$i.Config.ApiUrl | Should -Be 'https://dash.example.com'
$i.Config.ApiKey | Should -Be 'bdk_testkey'
$i.Config.Tenant | Should -Be 'Acme Corp'
$i.Config.SourceSystem | Should -Be 'Veeam B&R'
$i.Config.LookbackHours | Should -Be 48
}
It 'locks the config folder to SYSTEM and Administrators' {
$i.Record | Where-Object { $_ -like 'icacls*' } | Should -BeLike '*/inheritance:r /grant:r `*S-1-5-18:(OI)(CI)F `*S-1-5-32-544:(OI)(CI)F'
}
It 'installs the collector and the shared module side by side, unblocked' {
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-VeeamBackupStatus.ps1')
$i.Record | Should -Contain 'unblock BackupDashboard.Collector.psm1'
$i.Record | Should -Contain 'unblock Send-VeeamBackupStatus.ps1'
}
It 'registers an hourly SYSTEM task running the collector under PowerShell 7' {
$i.Record | Should -Contain "exe $pwshPath"
$i.Record | Where-Object { $_ -like 'args*' } | Should -BeLike '*-File "*Send-VeeamBackupStatus.ps1"'
$i.Record | Should -Contain 'every 60'
$i.Record | Should -Contain 'principal S-1-5-18 ServiceAccount Highest'
$i.Record | Should -Contain 'settings IgnoreNew'
$i.Record | Should -Contain 'task Backup Dashboard - Veeam collector'
}
It 'refuses a PowerShell 7 path that does not exist, before changing anything' {
$bad = Invoke-Installer veeam "-ApiUrl https://dash.example.com -Tenant Acme -PwshPath '/no/such/pwsh.exe'"
$bad.Output | Should -BeLike '*PowerShell 7 not found*'
$bad.Config | Should -BeNullOrEmpty
$bad.Files | Should -BeNullOrEmpty
}
}
Context 'MARS' {
It 'writes JobName, runs hourly under Windows PowerShell, and honours -IntervalMinutes' {
$i = Invoke-Installer mars "-ApiUrl https://dash.example.com -Tenant 'Acme Corp' -JobName 'File Server' -IntervalMinutes 30"
$i.Config.JobName | Should -Be 'File Server'
$i.Config.SourceSystem | Should -Be 'Azure Backup (MARS)'
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-MarsBackupStatus.ps1')
$i.Record | Where-Object { $_ -like 'exe*' } | Should -BeLike '*WindowsPowerShell*powershell.exe'
$i.Record | Should -Contain 'every 30'
$i.Record | Should -Contain 'task Backup Dashboard - MARS collector'
}
It 'refuses to install where the MARS agent is missing, before changing anything' {
$i = Invoke-Installer mars "-ApiUrl https://dash.example.com -Tenant Acme" -NoProductModule
$i.Output | Should -BeLike '*MARS agent*not installed*'
$i.Config | Should -BeNullOrEmpty
}
}
Context 'Windows Server Backup' {
It 'warns when the dashboard URL is not HTTPS' {
$i = Invoke-Installer wsb "-ApiUrl http://dash.example.com -Tenant 'Acme Corp'"
$i.Output | Should -BeLike '*not HTTPS*'
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-WsbBackupStatus.ps1')
$i.Record | Should -Contain 'task Backup Dashboard - WSB collector'
}
It 'refuses to install without the Windows Server Backup feature' {
$i = Invoke-Installer wsb "-ApiUrl https://dash.example.com -Tenant Acme" -NoProductModule
# (PowerShell wraps long error messages, so match a short part)
$i.Output | Should -BeLike '*WindowsServerBackup PowerShell module is not available*'
$i.Config | Should -BeNullOrEmpty
}
}
Context 'VB365' {
It 'writes the organization-to-company map, with no Tenant unless given' {
$i = Invoke-Installer vb365 "-ApiUrl https://dash.example.com -OrganizationTenants @{ 'acme.onmicrosoft.com' = 'Acme Corp'; 'blue.onmicrosoft.com' = 'Bluewater' }"
$i.Config.OrganizationTenants.'acme.onmicrosoft.com' | Should -Be 'Acme Corp'
$i.Config.OrganizationTenants.'blue.onmicrosoft.com' | Should -Be 'Bluewater'
$i.Config.PSObject.Properties.Name | Should -Not -Contain 'Tenant'
$i.Record | Where-Object { $_ -like 'exe*' } | Should -BeLike '*powershell.exe'
}
It 'uses PowerShell 7 when given -PwshPath' {
$i = Invoke-Installer vb365 "-ApiUrl https://dash.example.com -Tenant 'Solo Co' -PwshPath '$pwshPath'"
$i.Config.Tenant | Should -Be 'Solo Co'
$i.Record | Should -Contain "exe $pwshPath"
}
}
}
@@ -0,0 +1,62 @@
<#
.SYNOPSIS
Runs the collector test suite (Pester): the shared module, each collector against its mock,
and the installers.
.DESCRIPTION
Needs PowerShell 7.2+ and Pester 5.5+:
Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser
Each collector runs in its own process, by default under the same PowerShell running the
tests. On Windows, use -CollectorShell to run them under Windows PowerShell 5.1 instead, as
the MARS, WSB and VB365 servers do.
Exits non-zero if any test fails, so it can gate a commit or CI job.
.EXAMPLE
./collectors/tests/Invoke-CollectorTests.ps1
.EXAMPLE
./collectors/tests/Invoke-CollectorTests.ps1 -CollectorShell powershell.exe
.EXAMPLE
./collectors/tests/Invoke-CollectorTests.ps1 -Path ./collectors/tests/Wsb.Tests.ps1
#>
#Requires -Version 7.2
[CmdletBinding()]
param(
# powershell.exe / pwsh / a full path: the shell the collectors and installers run under
[string]$CollectorShell,
# Test files or folders to run; defaults to every *.Tests.ps1 here
[string[]]$Path = @($PSScriptRoot),
[ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')][string]$Output = 'Detailed'
)
$ErrorActionPreference = 'Stop'
$pester = Get-Module -ListAvailable -Name Pester | Where-Object Version -GE '5.5' |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $pester) {
throw 'Pester 5.5 or newer is needed: Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser'
}
Import-Module $pester
if ($CollectorShell) {
$resolved = Get-Command $CollectorShell -ErrorAction SilentlyContinue
if (-not $resolved) { throw "Shell not found: $CollectorShell" }
$env:COLLECTOR_TEST_SHELL = $resolved.Source
}
$shellName = if ($env:COLLECTOR_TEST_SHELL) { $env:COLLECTOR_TEST_SHELL } else { "this PowerShell ($($PSVersionTable.PSVersion))" }
Write-Host "Collectors run under: $shellName"
# All temp folders for this run, removed at the end
$env:COLLECTOR_TEST_TEMP = Join-Path ([IO.Path]::GetTempPath()) ("backup-dashboard-tests-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
try {
$config = New-PesterConfiguration
$config.Run.Path = $Path
$config.Run.PassThru = $true
$config.Output.Verbosity = $Output
$result = Invoke-Pester -Configuration $config
} finally {
Remove-Item -Recurse -Force $env:COLLECTOR_TEST_TEMP -ErrorAction SilentlyContinue
Remove-Item env:COLLECTOR_TEST_TEMP
}
exit $(if ($result.Result -eq 'Passed') { 0 } else { 1 })
+95
View File
@@ -0,0 +1,95 @@
# Azure Backup (MARS agent) collector, against collectors/mars/tests/MockMars.ps1 (shapes from a
# real agent, 2.0.9274: job times are UTC values with no UTC marking, ErrorInfo is always present).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
# MARS reports job times in UTC; the API should get them with +00:00
function Utc([datetime]$Local) { ConvertTo-ApiTime $Local.ToUniversalTime() }
}
Describe 'MARS collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector mars -Config $config -DryRun
$runs = @($r.Reports | Where-Object run | ForEach-Object run)
$byStart = @{}
foreach ($run in $runs) { $byStart[$run.started_at] = $run }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 4 runs, 0 failures'
}
It 'reports the server as one job, named after it' {
$r.Reports.job.external_id | Select-Object -Unique | Should -Be 'mars:srv01'
$r.Reports.job.name | Select-Object -Unique | Should -Be 'SRV01 - Azure Backup'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Azure Backup (MARS)'
}
It 'computes the next run from the schedule (daily at 21:00)' {
$next = (Get-Date).Date.AddHours(21)
if ((Get-Date) -ge $next) { $next = $next.AddDays(1) }
$r.Reports[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
}
It 'treats job times as UTC (MARS gives UTC values without marking them)' {
$byStart.Keys | Should -Contain (Utc $now.AddHours(-12))
$byStart[(Utc $now.AddHours(-12))].finished_at | Should -Be (Utc $now.AddHours(-11.5))
}
It 'reports a completed backup as success, despite its (always present) ErrorInfo with code 0' {
$run = $byStart[(Utc $now.AddHours(-12))]
$run.status | Should -Be 'success'
$run.message | Should -Be 'Backup completed successfully.'
$run.size_bytes | Should -Be 523407872
$run.details.datasources | Should -Be @('E:\')
}
It 'reports a completed backup with failed files as a warning, with the count and log' {
$run = $byStart[(Utc $now.AddHours(-36))]
$run.status | Should -Be 'warning'
$run.message | Should -BeLike '*E:\: 12 file(s) failed to back up*'
$run.message | Should -BeLike '*Failed file log: C:\Program Files\Microsoft Azure Recovery Services Agent\Temp\Errors.log*'
$run.details.failed_files | Should -Be 12
$run.details.failed_bytes | Should -Be 40MB
}
It 'reports a failed backup with its error code and parameters' {
$run = $byStart[(Utc $now.AddHours(-30))]
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*Error code 100050 (Volume=E:)*'
}
It 'reports a backup in progress as running, without a finish time' {
$run = $byStart[(Utc $now.AddMinutes(-10))]
$run.status | Should -Be 'running'
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
}
It 'ignores restores and backups outside the lookback window' {
$runs.Count | Should -Be 4
}
It 'uses JobName from the config when set' {
$named = Invoke-Collector mars -Config ($config + @{ JobName = 'File Server' }) -DryRun
$named.Reports.job.name | Select-Object -Unique | Should -Be 'File Server'
$named.Reports.job.external_id | Select-Object -Unique | Should -Be 'mars:srv01'
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url
$first = Invoke-Collector mars -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector mars -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every run on the first run' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 4
}
It 'on the next run, resends only the backup in progress' {
$first.State.Count | Should -Be 3
$secondRequests.Count | Should -Be 1
$secondRequests[0].Payload.run.status | Should -Be 'running'
}
}
}
+136
View File
@@ -0,0 +1,136 @@
# Tests for collectors/common/BackupDashboard.Collector.psm1, the code all collectors share.
BeforeAll {
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'common/BackupDashboard.Collector.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
function New-TempConfig([hashtable]$Values, [hashtable]$State) {
$dir = New-TestDir 'module'
$path = Join-Path $dir 'test-collector.json'
$Values | ConvertTo-Json | Set-Content -Path $path
if ($State) { $State | ConvertTo-Json | Set-Content -Path (Join-Path $dir 'test-collector.state.json') }
return $path
}
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
}
Describe 'Get-FirstValue' {
It 'returns the first property that has a value' {
Get-FirstValue ([pscustomobject]@{ A = $null; B = ''; C = 'yes' }) @('A', 'B', 'C') | Should -Be 'yes'
}
It 'treats an empty collection as no value' {
Get-FirstValue ([pscustomobject]@{ A = @(); B = 'yes' }) @('A', 'B') | Should -Be 'yes'
}
It 'returns a one-item list of objects (which [string] would turn into an empty string)' {
$item = [pscustomobject]@{ Name = 'E:\' }
$result = @(Get-FirstValue ([pscustomobject]@{ List = @($item) }) @('List'))
$result.Count | Should -Be 1
$result[0].Name | Should -Be 'E:\'
}
It 'returns $null for a missing property or object' {
Get-FirstValue ([pscustomobject]@{ A = 1 }) @('Nope') | Should -BeNullOrEmpty
Get-FirstValue $null @('A') | Should -BeNullOrEmpty
}
It 'keeps $false (a real value, not "missing")' {
Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled') | Should -BeFalse
$null -eq (Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled')) | Should -BeFalse
}
}
Describe 'ConvertTo-IsoTime' {
It 'gives local times their offset' {
$local = [datetime]::new(2026, 10, 8, 21, 0, 0, [DateTimeKind]::Local)
ConvertTo-IsoTime $local | Should -Be ([DateTimeOffset]$local).ToString("yyyy-MM-dd'T'HH:mm:sszzz")
}
It 'gives UTC times +00:00' {
ConvertTo-IsoTime ([datetime]::new(2026, 10, 8, 3, 0, 5, [DateTimeKind]::Utc)) | Should -Be '2026-10-08T03:00:05+00:00'
}
It 'treats "not set" values as no time' {
ConvertTo-IsoTime ([datetime]::MinValue) | Should -BeNullOrEmpty
ConvertTo-IsoTime ([datetime]::new(1601, 1, 1)) | Should -BeNullOrEmpty # MARS
ConvertTo-IsoTime ([datetime]::new(1900, 1, 1)) | Should -BeNullOrEmpty # Veeam
ConvertTo-IsoTime $null | Should -BeNullOrEmpty
}
}
Describe 'Initialize-Collector' {
It 'requires ApiUrl, ApiKey and Tenant by default' {
$path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' }
{ Initialize-Collector -Name 'test' -ConfigPath $path } | Should -Throw "*missing 'Tenant'*"
}
It 'accepts a custom set of required keys' {
$path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' }
{ Initialize-Collector -Name 'test' -ConfigPath $path -RequiredKeys 'ApiUrl', 'ApiKey' } | Should -Not -Throw
}
It 'resolves LookbackHours: parameter, then config, then 48' {
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig $config)).LookbackHours | Should -Be 48
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 }))).LookbackHours | Should -Be 720
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 })) -LookbackHours 12).LookbackHours | Should -Be 12
}
It 'loads runs already sent from the state file' {
$path = New-TempConfig $config @{ 'run-1' = [DateTimeOffset]::Now.ToUnixTimeSeconds() }
$null = Initialize-Collector -Name 'test' -ConfigPath $path
Test-RunSent 'run-1' | Should -BeTrue
Test-RunSent 'run-2' | Should -BeFalse
}
}
Describe 'Send-JobReport and Complete-Collector' {
BeforeEach {
$path = New-TempConfig $config @{
'recent' = [DateTimeOffset]::Now.AddHours(-10).ToUnixTimeSeconds()
'stale' = [DateTimeOffset]::Now.AddHours(-100).ToUnixTimeSeconds() # > 48h + 1 day ago
}
$statePath = Join-Path (Split-Path $path) 'test-collector.state.json'
$job = [ordered]@{ external_id = 'j1'; name = 'Job'; next_run_at = $null }
}
It 'records a finished run as sent, but not a running one' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
$null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'done' -StartedAt (Get-Date) 6>&1
$null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'running' }) -SentKey 'busy' -StartedAt (Get-Date) 6>&1
Test-RunSent 'done' | Should -BeTrue
Test-RunSent 'busy' | Should -BeFalse
}
It 'prints the payload in a dry run, with the job and run' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
$printed = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'x' -StartedAt (Get-Date) 6>&1
$payload = "$printed" | ConvertFrom-Json
$payload.tenant | Should -Be 'Acme'
$payload.source_system | Should -Be 'Test'
$payload.job.external_id | Should -Be 'j1'
$payload.run.status | Should -Be 'success'
}
It 'saves the state, dropping runs older than the lookback window plus a day' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path
$null = Complete-Collector -Failures 0 6>&1
$saved = Get-Content $statePath -Raw | ConvertFrom-Json
$saved.PSObject.Properties.Name | Should -Be @('recent')
}
It "doesn't touch the state in a dry run" {
$before = Get-Content $statePath -Raw
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
$null = Complete-Collector -Failures 0 6>&1
Get-Content $statePath -Raw | Should -Be $before
}
It 'returns exit code 1 when anything failed, 0 otherwise' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
Complete-Collector -Failures 0 6>$null | Should -Be 0
Complete-Collector -Failures 2 6>$null | Should -Be 1
}
It 'logs a summary with or without a job count' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
"$(Complete-Collector -JobCount 3 -Failures 0 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 3 jobs, 0 runs, 0 failures$'
"$(Complete-Collector -Failures 1 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 0 runs, 1 failures$'
}
}
Describe 'Test-AuthRejected' {
It 'is true only for HTTP 401' {
$error401 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 401 } } }
$error500 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 500 } } }
$noResponse = [pscustomobject]@{ Exception = [pscustomobject]@{ Message = 'timeout' } }
Test-AuthRejected $error401 | Should -BeTrue
Test-AuthRejected $error500 | Should -BeFalse
Test-AuthRejected $noResponse | Should -BeFalse
}
}
+43
View File
@@ -0,0 +1,43 @@
# Collector tests
Pester tests for the collectors, the shared module and the installers. Each collector runs against
its mock (`collectors/<name>/tests/Mock*.ps1`, built from real servers' diagnostic output) and a
stand-in for the ingest API, so no backup product or dashboard is needed.
```powershell
Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser # once
./collectors/tests/Invoke-CollectorTests.ps1 # everything, ~15 s
./collectors/tests/Invoke-CollectorTests.ps1 -Path ./collectors/tests/Wsb.Tests.ps1
```
Needs PowerShell 7.2+ to run the tests. Collectors run in their own process, under the same
PowerShell by default. On Windows, run them under Windows PowerShell 5.1, as the MARS, WSB and
VB365 servers do:
```powershell
./collectors/tests/Invoke-CollectorTests.ps1 -CollectorShell powershell.exe
```
The runner exits non-zero if anything fails.
## What's covered
| File | Checks |
|---|---|
| `Module.Tests.ps1` | `BackupDashboard.Collector.psm1`: property lookup, time format, config and lookback, state (recording sent runs, pruning), dry runs, exit codes, 401 detection |
| `Veeam.Tests.ps1` | Next run from the live schedule (VBR 13), none for copy/disabled jobs; copy-job worker sessions; statuses and messages; state and resending; rejected key; missing config |
| `Mars.Tests.ps1` | UTC job times; ErrorCode 0 isn't an error; failed files → warning; next run from the schedule; JobName; state |
| `Wsb.Tests.ps1` | Culture-formatted times (en-US and en-GB); VMs nested in VirtualMachineList; HRESULTs; failure log only when relevant; running → finished merging on start time |
| `Vb365.Tests.ps1` | Organization → company mapping; `[Warning]`/`[Failed]` log prefixes; retries; "187.2 MB" sizes; state |
| `Installers.Tests.ps1` | Config written (incl. the VB365 map), files copied (collector + module), task registered (shell, interval, SYSTEM), refusals before changing anything |
Each of these was a real bug or a real-server finding. To check the suite still catches them,
put one back (e.g. read `$Job.ScheduleOptions.NextRun` instead of `GetScheduleOptions()` in the
Veeam collector) and run the tests: they should fail.
## Adding a collector
1. Write `collectors/<name>/tests/Mock<Name>.ps1` from the product's diagnostic output, taking
its time from `$env:MOCK_NOW` like the others.
2. Add `<name>` to `Invoke-Collector`'s `ValidateSet` in `TestHelpers.psm1`.
3. Add `<Name>.Tests.ps1`, one `It` per behaviour confirmed on a real server.
+166
View File
@@ -0,0 +1,166 @@
<#
Helpers for the collector tests: run a collector against its mock in a separate PowerShell
process, and stand in for the dashboard's ingest API.
Each collector run gets its own process so the mocks' global functions and the collector's
`exit` don't leak between tests. The shell is the one running the tests, or
$env:COLLECTOR_TEST_SHELL (e.g. powershell.exe, to run the collectors under Windows
PowerShell 5.1 like most servers do).
#>
$script:CollectorsDir = Split-Path -Parent $PSScriptRoot
$script:Invariant = [Globalization.CultureInfo]::InvariantCulture
# Temp folders for this test run live under one root, which Invoke-CollectorTests.ps1 removes
function New-TestDir([string]$Prefix = 'test') {
$root = if ($env:COLLECTOR_TEST_TEMP) { $env:COLLECTOR_TEST_TEMP } else { Join-Path ([IO.Path]::GetTempPath()) 'backup-dashboard-tests' }
$dir = Join-Path $root ("$Prefix-" + [guid]::NewGuid().ToString('N').Substring(0, 12))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
return $dir
}
function Get-TestShell {
if ($env:COLLECTOR_TEST_SHELL) { return $env:COLLECTOR_TEST_SHELL }
return (Get-Process -Id $PID).Path
}
<#
Pins the time the mocks are relative to (the top of the current hour) for this test session,
so a test can't fail because the hour turned over between the run and the assertions.
Returns it as a local DateTime.
#>
function Set-MockNow {
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
$env:MOCK_NOW = $now.ToString('o', $script:Invariant)
return [datetime]$env:MOCK_NOW
}
# The API's time format: "2026-10-08T21:00:00-06:00"
function ConvertTo-ApiTime([datetime]$Value) {
return ([DateTimeOffset]$Value).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $script:Invariant)
}
# JSON with date-looking strings left as strings (PowerShell 7.5+ would otherwise make them DateTimes)
function ConvertFrom-JsonText([string]$Json) {
if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) {
return $Json | ConvertFrom-Json -DateKind String
}
return $Json | ConvertFrom-Json
}
function ConvertTo-PSLiteral([string]$Value) { return "'" + $Value.Replace("'", "''") + "'" }
<#
Runs one collector against its mock. Returns:
ExitCode the collector's exit code (0 when it ends normally)
Reports in -DryRun, the payloads it would have sent
Log its log lines (timestamp stripped), from the console
State the sent-runs state file, as a hashtable (empty if none)
WorkDir pass back in to run again with the same config and state
#>
function Invoke-Collector {
param(
[Parameter(Mandatory, Position = 0)][ValidateSet('veeam', 'mars', 'wsb', 'vb365')][string]$Name,
[Parameter(Mandatory)][hashtable]$Config,
[string]$WorkDir,
[switch]$DryRun,
[string]$MockArgs = '',
[string]$Culture = 'en-US',
[string]$ExtraArgs = '',
[string]$ComputerName = 'SRV01'
)
$dir = Join-Path $script:CollectorsDir $Name
$script = (Get-ChildItem (Join-Path $dir 'Send-*.ps1')).FullName
$mock = (Get-ChildItem (Join-Path (Join-Path $dir 'tests') 'Mock*.ps1')).FullName
if (-not $WorkDir) {
$WorkDir = New-TestDir $Name
}
$configPath = Join-Path $WorkDir "$Name-collector.json"
$Config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8
$command = @(
"[Globalization.CultureInfo]::CurrentCulture = '$Culture'"
". $(ConvertTo-PSLiteral $mock) $MockArgs"
"& $(ConvertTo-PSLiteral $script) -ConfigPath $(ConvertTo-PSLiteral $configPath) $(if ($DryRun) { '-DryRun' }) $ExtraArgs"
'exit $LASTEXITCODE'
) -join '; '
$savedComputer = $env:COMPUTERNAME
$env:COMPUTERNAME = $ComputerName
try {
$output = @(& (Get-TestShell) -NoProfile -NonInteractive -Command $command 2>&1 | ForEach-Object { "$_" })
$exitCode = $LASTEXITCODE
} finally {
$env:COMPUTERNAME = $savedComputer
}
$state = @{}
$statePath = Join-Path $WorkDir "$Name-collector.state.json"
if (Test-Path $statePath) {
$saved = ConvertFrom-JsonText (Get-Content $statePath -Raw)
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $state[$p.Name] = $p.Value } }
}
return [pscustomobject]@{
ExitCode = $exitCode
# Payloads are single-line JSON; a logged API error body spans several lines starting with '{'
Reports = @($output | Where-Object { $_ -match '^\{".*\}$' } | ForEach-Object { ConvertFrom-JsonText $_ })
Log = @($output | Where-Object { $_ -match '^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \[' } | ForEach-Object { $_.Substring(20) })
Output = $output
State = $state
WorkDir = $WorkDir
}
}
<#
A stand-in for the ingest API on a free localhost port. Records every request; answers 401
for API keys containing "bad", 200 otherwise. Stop it with Stop-ApiStub.
#>
function Start-ApiStub {
$probe = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
$probe.Start(); $port = $probe.LocalEndpoint.Port; $probe.Stop()
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add("http://localhost:$port/")
$listener.Start()
$requests = [Collections.Concurrent.ConcurrentQueue[object]]::new()
$job = Start-ThreadJob -ArgumentList $listener, $requests -ScriptBlock {
param($listener, $requests)
while ($listener.IsListening) {
try { $context = $listener.GetContext() } catch { break }
$body = [IO.StreamReader]::new($context.Request.InputStream, [Text.Encoding]::UTF8).ReadToEnd()
$auth = $context.Request.Headers['Authorization']
$requests.Enqueue([pscustomobject]@{
Path = $context.Request.Url.AbsolutePath; Auth = $auth
ContentType = $context.Request.ContentType; Body = $body
})
$rejected = $auth -match 'bad'
$reply = if ($rejected) { '{"detail":"Invalid or revoked API key"}' } else { '{"job_id":1,"run_id":1}' }
$bytes = [Text.Encoding]::UTF8.GetBytes($reply)
$context.Response.StatusCode = $(if ($rejected) { 401 } else { 200 })
$context.Response.ContentType = 'application/json'
$context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$context.Response.Close()
}
}
return [pscustomobject]@{ Url = "http://localhost:$port"; Listener = $listener; Requests = $requests; Job = $job }
}
# Takes the requests recorded since the last call; Body is the parsed JSON payload
function Get-StubRequests($Stub) {
$taken = @()
$item = $null
while ($Stub.Requests.TryDequeue([ref]$item)) {
$item | Add-Member -NotePropertyName Payload -NotePropertyValue (ConvertFrom-JsonText $item.Body) -Force
$taken += $item
}
return , $taken
}
function Stop-ApiStub($Stub) {
$Stub.Listener.Stop()
$Stub.Listener.Close()
$Stub.Job | Stop-Job -PassThru | Remove-Job -Force
}
Export-ModuleMember -Function New-TestDir, Set-MockNow, ConvertTo-ApiTime, ConvertFrom-JsonText, Invoke-Collector,
Start-ApiStub, Get-StubRequests, Stop-ApiStub
+116
View File
@@ -0,0 +1,116 @@
# Veeam Backup for Microsoft 365 collector, against collectors/vb365/tests/MockVb365.ps1 (shapes from
# a VB365 8.4 server: log items carry their status as a Title prefix, retries are separate sessions).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$base = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x' }
function Get-JobRuns($Reports, [string]$JobName) {
@($Reports | Where-Object { $_.job.name -eq $JobName -and $_.run } | ForEach-Object run)
}
function Get-Tenants($Reports) {
$map = @{}
foreach ($report in $Reports) { $map[$report.job.name] = $report.tenant }
return $map
}
}
Describe 'VB365 collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector vb365 -Config ($base + @{ Tenant = 'Default Co' }) -DryRun
$acme = Get-JobRuns $r.Reports 'Acme 365 Backup'
$blue = Get-JobRuns $r.Reports 'Bluewater 365 Backup'
}
It 'runs cleanly, reporting every job' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 3 jobs, 5 runs, 0 failures'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Veeam Backup for M365'
}
It "uses each job's NextRun, and none for a disabled job" {
$next = $now.Date.AddHours(17).AddDays($(if ($now.Hour -ge 17) { 1 } else { 0 }))
($r.Reports | Where-Object { $_.job.name -eq 'Acme 365 Backup' })[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
($r.Reports | Where-Object { $_.job.name -eq 'Bluewater Old Mailboxes' })[0].job.next_run_at | Should -BeNullOrEmpty
}
It 'reports a successful session with its size ("187.2 MB" in bytes) and objects' {
$run = $acme | Where-Object status -EQ 'success'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.Date.AddDays(-1).AddHours(17))
$run.size_bytes | Should -Be ([long](187.2 * 1MB))
$run.details.processed_objects | Should -Be 568
$run.details.organization | Should -Be 'acme.onmicrosoft.com'
$run.message | Should -Be 'Job completed successfully.'
}
It 'reports a session in progress as running, without a finish time' {
$run = $acme | Where-Object status -EQ 'running'
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.message | Should -Be ''
}
It 'builds the message from [Warning]/[Failed] log items, without the prefix' {
$run = $blue | Where-Object status -EQ 'warning'
$run.message | Should -BeLike 'Processing mailbox [email protected] finished with warning: Item "Q3.pst" exceeds the maximum size*'
# Literal check: in -like patterns, [Warning] is a character set
$run.message.Contains('[Warning]') | Should -BeFalse
}
It 'ignores [Success] log items, even ones mentioning "error"' {
($blue | Where-Object status -EQ 'warning').message | Should -Not -BeLike '*error-reports*'
}
It 'flags a session VB365 will retry, and links the retry back to it' {
$warning = $blue | Where-Object status -EQ 'warning'
$retry = $blue | Where-Object { $_.details.retry_of }
$warning.message | Should -BeLike '*Veeam will retry this job.'
$warning.details.will_retry | Should -BeTrue
$retry.status | Should -Be 'success'
$retry.details.retry_of | Should -Be $warning.external_id
}
It 'reports a failed session with its error' {
$run = $blue | Where-Object status -EQ 'failed'
$run.message | Should -BeLike '*(401) Unauthorized*'
}
It 'skips sessions outside the lookback window' {
Get-JobRuns $r.Reports 'Bluewater Old Mailboxes' | Should -BeNullOrEmpty
}
}
Context 'which company a job appears under' {
It 'uses OrganizationTenants (not case-sensitive), then Tenant for unmapped organizations' {
$r = Invoke-Collector vb365 -DryRun -Config ($base + @{
Tenant = 'Default Co'; OrganizationTenants = @{ 'ACME.onmicrosoft.com' = 'Acme Corp' } })
$tenants = Get-Tenants $r.Reports
$tenants['Acme 365 Backup'] | Should -Be 'Acme Corp'
$tenants['Bluewater 365 Backup'] | Should -Be 'Default Co'
}
It "falls back to the organization's name with no mapping and no Tenant" {
$r = Invoke-Collector vb365 -Config $base -DryRun
$r.ExitCode | Should -Be 0
$tenants = Get-Tenants $r.Reports
$tenants['Acme 365 Backup'] | Should -Be 'acme.onmicrosoft.com'
$tenants['Bluewater 365 Backup'] | Should -Be 'bluewater.onmicrosoft.com'
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $base + @{ Tenant = 'Default Co' }; $live.ApiUrl = $stub.Url
$first = Invoke-Collector vb365 -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector vb365 -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every session, remembering the finished ones' {
$first.ExitCode | Should -Be 0
@($firstRequests | Where-Object { $_.Payload.run }).Count | Should -Be 5
$first.State.Count | Should -Be 4
}
It 'on the next run, resends only the running session (and refreshes every job)' {
$resent = @($secondRequests | Where-Object { $_.Payload.run })
$resent.Count | Should -Be 1
$resent[0].Payload.run.status | Should -Be 'running'
$secondRequests.Count | Should -Be 3
}
}
}
+127
View File
@@ -0,0 +1,127 @@
# Veeam B&R collector, against collectors/veeam/tests/MockVeeam.ps1 (shapes from a VBR 13 server).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$dash = [char]0x2013 # job names use an en dash, as real ones often do
$fileServer = "File Server $dash Nightly"
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
function Get-Runs($Reports, [string]$JobName) {
@($Reports | Where-Object { $_.job.name -eq $JobName -and $_.run } | ForEach-Object run)
}
}
Describe 'Veeam collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector veeam -Config $config -DryRun
$jobs = @{}
foreach ($report in $r.Reports) { $jobs[$report.job.name] = $report.job }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 5 jobs, 6 runs, 0 failures'
}
It 'reports every job, under the configured tenant' {
$jobs.Keys | Sort-Object | Should -Be @('Cloud Copy', $fileServer, 'Offsite Copy', 'Old Exchange', "SQL01 $dash Logs")
$r.Reports.tenant | Select-Object -Unique | Should -Be 'Acme'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Veeam B&R'
}
It "reads the next run from the live schedule (VBR 13's ScheduleOptions property is empty)" {
$jobs[$fileServer].next_run_at | Should -Be (ConvertTo-ApiTime $now.Date.AddDays(1).AddMinutes(15))
$jobs["SQL01 $dash Logs"].next_run_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(40))
}
It 'gives no next run to immediate-mode copy jobs (their NextRun is stale), disabled or unscheduled jobs' {
$jobs['Cloud Copy'].next_run_at | Should -BeNullOrEmpty
$jobs['Old Exchange'].next_run_at | Should -BeNullOrEmpty
$jobs['Offsite Copy'].next_run_at | Should -BeNullOrEmpty
}
It 'reports a successful session with its times and size' {
$run = Get-Runs $r.Reports $fileServer | Where-Object status -EQ 'success'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddDays(-1).AddHours(-2))
$run.finished_at | Should -Be (ConvertTo-ApiTime $now.AddDays(-1).AddHours(-1))
$run.size_bytes | Should -Be 128GB
$run.message | Should -Be 'Job completed successfully.'
$run.details.objects | Should -Be 2
}
It 'reports a session in progress as running, without a finish time' {
$run = Get-Runs $r.Reports $fileServer | Where-Object status -EQ 'running'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(-20))
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.message | Should -Be ''
}
It 'names the object behind a warning' {
$run = Get-Runs $r.Reports "SQL01 $dash Logs"
$run.status | Should -Be 'warning'
$run.message | Should -BeLike 'SQL01: Warning - Failed to truncate transaction logs*'
$run.details.objects_warning | Should -Be 1
}
It "includes a failure's reason and failed log lines, but not successful ones" {
$run = Get-Runs $r.Reports 'Offsite Copy'
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*Unable to connect to repository NAS02*'
$run.message | Should -BeLike '*Network path not found (0x80070035)*'
$run.message | Should -Not -BeLike '*Preparing backup chain*'
}
It "files copy jobs' worker sessions under the copy job, naming the source" {
$runs = Get-Runs $r.Reports 'Cloud Copy'
$runs.status | Sort-Object | Should -Be @('success', 'warning')
$runs.details.copied_from | Select-Object -Unique | Should -Be $fileServer
}
It "skips sessions outside the lookback window and from jobs it doesn't know" {
@($r.Reports | Where-Object run).Count | Should -Be 6
Get-Runs $r.Reports 'Old Exchange' | Should -BeNullOrEmpty
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url; $live.ApiKey = 'bdk_good'
$first = Invoke-Collector veeam -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector veeam -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'posts each report to /api/v1/ingest with the API key, as UTF-8 JSON' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 7
$firstRequests.Path | Select-Object -Unique | Should -Be '/api/v1/ingest'
$firstRequests.Auth | Select-Object -Unique | Should -Be 'Bearer bdk_good'
$firstRequests.ContentType | Select-Object -Unique | Should -Be 'application/json; charset=utf-8'
}
It 'keeps non-ASCII job names intact' {
$firstRequests.Payload.job.name | Should -Contain $fileServer
}
It 'remembers finished sessions, but not running ones' {
$first.State.Count | Should -Be 5
}
It 'on the next run, resends only the running session (and refreshes every job)' {
$resent = @($secondRequests | Where-Object { $_.Payload.run })
$resent.Count | Should -Be 1
$resent[0].Payload.run.status | Should -Be 'running'
$secondRequests.Count | Should -Be 5
}
}
Context 'problems' {
It 'stops after the first job when the API key is rejected, exiting 1' {
$stub = Start-ApiStub
try {
$r = Invoke-Collector veeam -Config @{ ApiUrl = $stub.Url; ApiKey = 'bdk_bad'; Tenant = 'Acme' }
(Get-StubRequests $stub).Count | Should -Be 1
$r.ExitCode | Should -Be 1
$r.Log | Should -Contain '[ERROR] API key rejected; check ApiKey in the config. Stopping.'
} finally { Stop-ApiStub $stub }
}
It 'exits 2 when the config lacks a tenant' {
$r = Invoke-Collector veeam -Config @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x' } -DryRun
$r.ExitCode | Should -Be 2
$r.Log[0] | Should -BeLike "*is missing 'Tenant'*"
}
}
}
+108
View File
@@ -0,0 +1,108 @@
# Windows Server Backup collector, against collectors/wsb/tests/MockWsb.ps1 (shapes from a Server
# 2019 Hyper-V host: job times are culture-formatted local-time strings, HRESULTs are strings,
# VMs are nested under a VirtualMachineList item).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
$vms = @('AAOG-EDM-AD1', 'Host Component')
$hostComponentBytes = 447729
}
Describe 'Windows Server Backup collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector wsb -Config $config -DryRun
$runs = @($r.Reports | Where-Object run | ForEach-Object run)
$byStart = @{}
foreach ($run in $runs) { $byStart[$run.started_at] = $run }
function At([double]$HoursAgo) { $byStart[(ConvertTo-ApiTime $now.AddHours(-$HoursAgo))] }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 4 runs, 0 failures'
}
It 'reports the server as one job, with the next backup from Get-WBSummary' {
$r.Reports.job.external_id | Select-Object -Unique | Should -Be 'wsb:srv01'
$r.Reports.job.name | Select-Object -Unique | Should -Be 'SRV01 - Windows Server Backup'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Windows Server Backup'
$next = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
$r.Reports[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
}
It 'reads job times as local time, and sends no run ID (WSB jobs have none; the API keys on start time)' {
(At 12).finished_at | Should -Be (ConvertTo-ApiTime $now.AddHours(-11.6))
$runs | ForEach-Object { $_.PSObject.Properties.Name | Should -Not -Contain 'external_id' }
}
It 'reports a Hyper-V backup with its VMs (nested in VirtualMachineList) and size' {
$run = At 12
$run.status | Should -Be 'success'
$run.message | Should -Be 'Backup completed successfully.'
$run.details.items | Should -Be $vms
$run.size_bytes | Should -Be (124756706036 + $hostComponentBytes)
}
It "doesn't cite the failure log for a success (WSB fills it in regardless)" {
(At 12).message | Should -Not -BeLike '*Failure log*'
}
It 'reports a backup where one VM failed as a warning, naming the VM' {
$run = At 36
$run.status | Should -Be 'warning'
$run.message | Should -BeLike '*AAOG-EDM-AD1 - Access is denied.*'
$run.message | Should -BeLike '*Failure log: C:\Windows\Logs\WindowsServerBackup\Backup_Error.log*'
}
It 'reports a backup with an error HRESULT as failed, with the code in 0x8 form' {
$run = At 30
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*The backup storage location could not be found.*'
$run.message | Should -BeLike '*error 0x80042302*'
}
It 'reports a volume backup with its volumes and size' {
$run = At 20
$run.status | Should -Be 'success'
$run.details.items | Should -Be @('C:', 'D:')
$run.size_bytes | Should -Be (240GB)
}
It "ignores restores, old backups, and Get-WBJob's empty job when nothing is running" {
$runs.Count | Should -Be 4
}
It 'reads dd/MM dates correctly on servers with other regional settings' {
$gb = Invoke-Collector wsb -Config $config -DryRun -Culture 'en-GB'
($gb.Reports | ConvertTo-Json -Depth 6) | Should -Be ($r.Reports | ConvertTo-Json -Depth 6)
}
It 'reports a backup in progress as running, with no message' {
$running = Invoke-Collector wsb -Config $config -DryRun -MockArgs '-WithRunningJob'
$run = $running.Reports | Where-Object { $_.run.status -eq 'running' } | ForEach-Object run
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(-10))
$run.message | Should -Be ''
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.details.items | Should -Be $vms
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url
$first = Invoke-Collector wsb -Config $live -MockArgs '-WithRunningJob'
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector wsb -Config $live -WorkDir $first.WorkDir -MockArgs '-RunningJobFinished'
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every run, remembering the finished ones' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 5
$first.State.Count | Should -Be 4
}
It 'sends the finished backup once it ends, with the same start time, so the API merges it into the running one' {
$running = $firstRequests.Payload.run | Where-Object status -EQ 'running'
$secondRequests.Count | Should -Be 1
$finished = $secondRequests[0].Payload.run
$finished.status | Should -Be 'success'
$finished.started_at | Should -Be $running.started_at
$second.State.Count | Should -Be 5
}
}
}
+2 -1
View File
@@ -8,7 +8,8 @@
# ./Send-Vb365BackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same data
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
function New-StableGuid([string]$Seed) {
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
}
+2 -1
View File
@@ -6,7 +6,8 @@
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same
# jobs and sessions, like a real server
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
function New-StableGuid([string]$Seed) {
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
}
+7 -2
View File
@@ -6,11 +6,13 @@
#
# . ./tests/MockWsb.ps1 # nothing running
# . ./tests/MockWsb.ps1 -WithRunningJob # a backup in progress
# . ./tests/MockWsb.ps1 -RunningJobFinished # that same backup, now finished
# ./Send-WsbBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
param([switch]$WithRunningJob)
param([switch]$WithRunningJob, [switch]$RunningJobFinished)
# Fixed to the top of the hour so repeated runs see the same jobs
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { Get-Date -Minute 0 -Second 0 -Millisecond 0 }
# Same format as the real cmdlet: short date + short time in the current culture
function Format-WbTime($Value) {
@@ -83,6 +85,9 @@ $global:MockWsbPrevious = @(
(New-MockJob 'Recovery' $now.AddHours(-5) $now.AddHours(-4) 'Completed'), # a restore: must be ignored
(New-MockJob 'Backup' $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Completed') # outside 48h lookback
)
if ($RunningJobFinished) {
$global:MockWsbPrevious += New-MockJob 'Backup' $now.AddMinutes(-10) $now.AddMinutes(-2) 'Completed' -Items @(New-VmList 10GB)
}
$global:MockWsbSummary = [pscustomobject]@{
NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
+54 -8
View File
@@ -1,7 +1,7 @@
server {
listen 80;
server_name backups.example.com;
# Basic auth sends the password with every request — never serve this over plain HTTP
# Session cookies and API keys must never cross plain HTTP
return 301 https://$host$request_uri;
}
@@ -23,26 +23,72 @@ server {
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000" always;
# Dashboard users. Create with: htpasswd -B -c /etc/nginx/backup-dashboard.htpasswd <user>
# To move to Microsoft 365 sign-in later, replace these two lines with an auth_request to
# oauth2-proxy (provider=entra-id); the app itself doesn't need to change.
auth_basic "Backup Monitor";
auth_basic_user_file /etc/nginx/backup-dashboard.htpasswd;
# ---------- Microsoft 365 sign-in ----------
# oauth2-proxy (deploy/oauth2-proxy.cfg) runs on 127.0.0.1:4180 and does the sign-in with
# Entra ID. Every request below asks it "is this browser signed in?" via auth_request.
# Its own pages: /oauth2/start (go to Microsoft), /oauth2/callback, /oauth2/sign_out
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Where to return after signing in: the page that was asked for
proxy_set_header X-Auth-Request-Redirect $request_uri;
# Entra ID session cookies are large
proxy_buffer_size 16k;
proxy_buffers 8 16k;
}
# The sign-in check itself, only reachable as an auth_request subrequest
location = /oauth2/auth {
internal;
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
}
# ---------- Dashboard ----------
location / {
auth_request /oauth2/auth;
# Not signed in: straight to Microsoft's sign-in, then back to this page
error_page 401 = /oauth2/start;
# Make browsers check for a newer page/script on every load (a cheap 304 when unchanged),
# so an update shows up at once. (expires, not add_header: an add_header here would drop
# the security headers above.)
expires -1;
try_files $uri $uri/ /index.html;
}
location /api/ {
auth_request /oauth2/auth;
# No redirect here: API calls from the page get a plain 401, and the page asks the user to
# reload, which goes through sign-in above.
# Who's signed in, for the dashboard's "signed in as" (proxy_set_header replaces any value
# the browser sent, so these can't be spoofed)
auth_request_set $auth_email $upstream_http_x_auth_request_email;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_name $upstream_http_x_auth_request_preferred_username;
proxy_set_header X-Forwarded-Email $auth_email;
proxy_set_header X-Forwarded-User $auth_user;
proxy_set_header X-Forwarded-Preferred-Username $auth_name;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app
# Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app,
# so no Microsoft sign-in here
location = /api/v1/ingest {
auth_basic off;
client_max_body_size 256k;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
+46
View File
@@ -0,0 +1,46 @@
# oauth2-proxy config for Backup Dashboard's Microsoft 365 (Entra ID) sign-in.
# Install as /etc/oauth2-proxy/backup-dashboard.cfg, readable only by root and the oauth2-proxy
# user (it holds the cookie secret). Needs oauth2-proxy 7.8 or newer for the entra-id provider.
# Replace the four <...> values; see the README's "Microsoft 365 sign-in" section.
## Where it listens: only nginx talks to it
http_address = "127.0.0.1:4180"
reverse_proxy = true
# Only nginx (on this machine) may supply X-Forwarded-* headers
trusted_proxy_ips = ["127.0.0.1/32", "::1/128"]
## Entra ID app registration (single tenant)
provider = "entra-id"
oidc_issuer_url = "https://login.microsoftonline.com/<TENANT_ID>/v2.0"
client_id = "<APPLICATION_CLIENT_ID>"
# The client secret's *value*, in a file of its own, root:oauth2-proxy, mode 640
client_secret_file = "/etc/oauth2-proxy/client-secret"
redirect_url = "https://<DASHBOARD_HOST>/oauth2/callback"
## Who may sign in
# Anyone in the tenant that Entra lets through. Limit it in Entra itself: Enterprise
# applications -> this app -> Properties -> "Assignment required" = Yes, then assign users or a
# group. Entra then refuses everyone else before they ever reach the dashboard.
email_domains = ["*"]
# The user's sign-in name (UPN), which every work account has. The "email" claim is only present
# when the account has a mailbox address.
oidc_email_claim = "preferred_username"
## Session cookie
# Generate with: openssl rand -base64 32 | tr -- '+/' '-_'
cookie_secret = "<COOKIE_SECRET>"
cookie_name = "_backup_dashboard"
cookie_secure = true
cookie_samesite = "lax"
# Sign in again (usually silently, via the Microsoft session) after this long
cookie_expire = "12h"
## Behaviour with nginx's auth_request
# Auth-only: nginx serves the dashboard; oauth2-proxy just answers "signed in or not"
upstreams = ["static://202"]
# Put the user in X-Auth-Request-* headers on /oauth2/auth responses, for nginx to pass on
set_xauthrequest = true
# Go straight to Microsoft instead of showing a "Sign in with..." page first
skip_provider_button = true
# Allow "Sign out" to continue to Microsoft's sign-out page
whitelist_domains = ["login.microsoftonline.com"]
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=oauth2-proxy (Microsoft 365 sign-in for Backup Dashboard)
After=network-online.target
Wants=network-online.target
[Service]
User=oauth2-proxy
Group=oauth2-proxy
ExecStart=/usr/local/bin/oauth2-proxy --config=/etc/oauth2-proxy/backup-dashboard.cfg
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+6
View File
@@ -71,6 +71,12 @@ body {
}
.topbar__title { margin: 0; font-size: 1.25rem; }
.topbar__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; }
/* Only when shown: display:flex on the bare class would override the hidden attribute */
.topbar__user:not([hidden]) {
display: flex; align-items: center; gap: 0.75rem;
padding-left: 0.75rem; border-left: 1px solid var(--border);
}
a.btn { text-decoration: none; }
.container {
max-width: 1400px;
+5
View File
@@ -14,6 +14,11 @@
<div class="topbar__meta">
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
<button id="refresh-btn" class="btn" type="button">Refresh</button>
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
<span id="signed-in" class="topbar__user" hidden>
<span id="signed-in-email" class="muted"></span>
<a id="sign-out" class="btn" href="#">Sign out</a>
</span>
</div>
</header>
+4
View File
@@ -49,6 +49,10 @@ const httpApi = {
getRun(id) {
return request(`/runs/${encodeURIComponent(id)}`);
},
/** GET /me → { email, name } of the Microsoft 365 user, both null without sign-in */
getMe() {
return request('/me');
},
};
// Mock is loaded lazily so it never ships in the production request path.
+18
View File
@@ -56,6 +56,9 @@ const dom = {
empty: $('#empty-state'),
search: $('#search'),
refreshBtn: $('#refresh-btn'),
signedIn: $('#signed-in'),
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
lastUpdated: $('#last-updated'),
errorBanner: $('#error-banner'),
dialog: $('#detail-dialog'),
@@ -524,5 +527,20 @@ dom.search.addEventListener('input', () => {
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
// ---------- Signed-in user ----------
async function showSignedInUser() {
try {
const me = await api.getMe();
const who = me?.email || me?.name;
if (!who) return; // no Microsoft 365 sign-in in front of the dashboard (dev, mock)
dom.signedInEmail.textContent = who;
dom.signOut.href = CONFIG.signOutUrl;
dom.signedIn.hidden = false;
} catch (err) {
console.error(err); // not worth a banner: the jobs request will surface real problems
}
}
// ---------- Boot ----------
showSignedInUser();
refresh().then(schedulePoll);
+4
View File
@@ -12,4 +12,8 @@ export const CONFIG = Object.freeze({
historyDays: 30,
// ?mock=1 → use in-browser sample data, no backend needed
useMock: params.has('mock'),
// oauth2-proxy clears the dashboard session, then hands over to Microsoft's sign-out page.
// Signing out of the dashboard alone would be undone at once: the browser's Microsoft session
// would sign it straight back in.
signOutUrl: '/oauth2/sign_out?rd=' + encodeURIComponent('https://login.microsoftonline.com/common/oauth2/v2.0/logout'),
});
+3
View File
@@ -126,4 +126,7 @@ export const mockApi = {
}
throw new Error('Run not found');
},
async getMe() {
return { email: null, name: null }; // no sign-in in mock mode
},
};