mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d95640c597 | |||
| b5e9f45a46 | |||
| b4dabcce94 | |||
| 6bbdcc0313 | |||
| 283c094986 | |||
| 47728fbcd4 | |||
| d5f885c4b4 | |||
| dd4975b73e | |||
| 18310b05aa | |||
| 40894ed5e7 | |||
| 6894e71668 | |||
| a28fdc874f | |||
| 7480a2e218 | |||
| 96fc88462d | |||
| 6b86549fd0 |
+1
-1
Submodule .claude/commands updated: d7b195b1ae...e593da2447
@@ -7,6 +7,14 @@ on:
|
||||
description: 'Build number override (leave empty to auto-increment from buildcounter.txt)'
|
||||
required: false
|
||||
type: string
|
||||
channel:
|
||||
description: 'release = versioned line (+ stable on cutover). dev = lab channel only (Agent-v2-dev.zip, version-v2-dev.json, Agent-dev.zip, version-dev.json); needs publish_as_stable=false and create_release=false'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- dev
|
||||
default: release
|
||||
upload_blob:
|
||||
description: 'Upload to versioned namespace (Agent-v2.zip + version-v2.json)'
|
||||
required: true
|
||||
@@ -60,6 +68,15 @@ env:
|
||||
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
|
||||
STABLE_ZIP: AutopilotMonitor-Agent.zip
|
||||
STABLE_MANIFEST: version.json
|
||||
# Lab channel (channel=dev): the names the dev renders of the bootstrap scripts read.
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
DEV_VERSIONED_ZIP: AutopilotMonitor-Agent-v2-dev.zip
|
||||
DEV_MANIFEST: version-v2-dev.json
|
||||
DEV_STABLE_ZIP: AutopilotMonitor-Agent-dev.zip
|
||||
DEV_STABLE_MANIFEST: version-dev.json
|
||||
# Signed catalog over every other file of the payload. Must equal
|
||||
# Constants.AgentPayloadCatalogFileName; CodeSigningParityTests keeps them in step.
|
||||
PAYLOAD_CATALOG: AutopilotMonitor.Agent.cat
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables, the
|
||||
# timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here.
|
||||
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step.
|
||||
@@ -106,6 +123,22 @@ jobs:
|
||||
}
|
||||
Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')"
|
||||
|
||||
# The dev channel is a lab channel: it never touches stable, AdminConfiguration, the
|
||||
# bootstrap scripts or a GitHub Release. Refused here, before a build number is burnt.
|
||||
- name: Check channel inputs
|
||||
shell: pwsh
|
||||
env:
|
||||
PUBLISH_AS_STABLE: ${{ inputs.publish_as_stable }}
|
||||
CREATE_RELEASE: ${{ inputs.create_release }}
|
||||
run: |
|
||||
if ($env:CHANNEL -notin @('release', 'dev')) {
|
||||
throw "Unknown channel '$($env:CHANNEL)'."
|
||||
}
|
||||
if ($env:CHANNEL -eq 'dev' -and ($env:PUBLISH_AS_STABLE -eq 'true' -or $env:CREATE_RELEASE -eq 'true')) {
|
||||
throw "channel=dev publishes the lab channel only: run it with publish_as_stable=false and create_release=false."
|
||||
}
|
||||
Write-Host "Channel: $env:CHANNEL"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
@@ -222,7 +255,8 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool
|
||||
# run here -- never a restore or build. The build output arrives as data.
|
||||
# run here -- never a restore or build. The build output arrives as data. The runner
|
||||
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
|
||||
#
|
||||
# No `environment:` yet: the code-signing credential is still bound to the ref, and a job
|
||||
# carries exactly one OIDC subject, so an environment here would break the signing login.
|
||||
@@ -353,8 +387,7 @@ jobs:
|
||||
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
|
||||
}
|
||||
|
||||
- name: Create release artifact
|
||||
id: artifact
|
||||
- name: Stage release payload
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = $env:AGENT_VERSION
|
||||
@@ -399,6 +432,98 @@ jobs:
|
||||
}
|
||||
Write-Host "Version check passed: EXE = manifest = reserved = $version"
|
||||
|
||||
# Third-party code enters the payload only with its vendor's own signature: every PE that
|
||||
# is not ours needs a valid EMBEDDED Authenticode signature from an allowed vendor. Embedded
|
||||
# only (signtool verify /pa, no /a): Get-AuthenticodeSignature prefers the runner's Windows
|
||||
# catalogs and reports "Microsoft Windows" for files Windows happens to ship as well. An
|
||||
# unsigned file or a new vendor stops the release until a person decides. This guards the
|
||||
# bytes we did not write; a build that alters our own code is D-346's remaining risk.
|
||||
- name: Check third-party signatures
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$allowedVendors = @('Microsoft Corporation', 'Json.NET (.NET Foundation)')
|
||||
|
||||
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Directory.Parent.Name -match '^\d+(\.\d+){1,3}$' } |
|
||||
Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $signtool) { throw "signtool.exe not found in the runner image's Windows SDK." }
|
||||
Write-Host "Using $($signtool.FullName)"
|
||||
|
||||
$failed = $false
|
||||
$checked = 0
|
||||
foreach ($file in Get-ChildItem -Path 'staging/agent' -Recurse -File) {
|
||||
# A PE by content (MZ header), not by extension.
|
||||
$head = [byte[]]::new(2)
|
||||
$stream = [System.IO.File]::OpenRead($file.FullName)
|
||||
try { $read = $stream.Read($head, 0, 2) } finally { $stream.Dispose() }
|
||||
if ($read -lt 2 -or $head[0] -ne 0x4D -or $head[1] -ne 0x5A) { continue }
|
||||
if ($file.Name -like 'AutopilotMonitor.*') { continue } # ours: the verify step pins them
|
||||
|
||||
$checked++
|
||||
$output = & $signtool.FullName verify /pa $file.FullName 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "::error file=$($file.Name)::No valid embedded vendor signature. $(($output | Out-String).Trim())"
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
$subject = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($file.FullName).Subject
|
||||
$vendor = if ($subject -match '(?:^|,\s*)O=("[^"]*"|[^,]*)') { $Matches[1].Trim('"') } else { '' }
|
||||
if ($vendor -notin $allowedVendors) {
|
||||
Write-Host "::error file=$($file.Name)::Signed by '$subject' -- vendor '$vendor' is not on the allowlist."
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
Write-Host "$($file.Name): embedded signature valid, vendor $vendor"
|
||||
}
|
||||
|
||||
if ($failed) { throw "Third-party signature check failed. Nothing was published." }
|
||||
Write-Host "All $checked third-party PE files carry a valid signature from an allowed vendor."
|
||||
|
||||
# The payload contract: one catalog, signed like our binaries, holding the SHA-256 of every
|
||||
# other file in the ZIP. Installer, OOBE script and self-updater accept a payload only when
|
||||
# this catalog is ours and matches the extracted files exactly. Created AFTER signing and
|
||||
# BEFORE the ZIP -- the same load-bearing position as the signing step above.
|
||||
- name: Create and sign payload catalog
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$stagingDir = 'staging/agent'
|
||||
$catalog = Join-Path $env:RUNNER_TEMP $env:PAYLOAD_CATALOG
|
||||
if (Test-Path (Join-Path $stagingDir $env:PAYLOAD_CATALOG)) {
|
||||
throw "$stagingDir already contains $env:PAYLOAD_CATALOG."
|
||||
}
|
||||
|
||||
New-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
|
||||
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
|
||||
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
|
||||
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description "Autopilot Monitor Agent" `
|
||||
--description-url "$env:WWW_BASE_URL" `
|
||||
$catalog
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "azuresigntool failed on the payload catalog with exit code $LASTEXITCODE -- aborting before anything is published."
|
||||
}
|
||||
|
||||
Copy-Item -Path $catalog -Destination $stagingDir
|
||||
$count = (Test-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed).CatalogItems.Count
|
||||
Write-Host "Payload catalog signed: $count files"
|
||||
|
||||
- name: Create release artifact
|
||||
id: artifact
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = $env:AGENT_VERSION
|
||||
$stagingDir = "staging/agent"
|
||||
$manifest = Get-Content "$env:AGENT_OUTPUT/$env:MANIFEST_NAME" -Raw | ConvertFrom-Json
|
||||
|
||||
$versionedZip = "AutopilotMonitor-Agent-v$version.zip"
|
||||
Compress-Archive -Path "$stagingDir/*" -DestinationPath $versionedZip
|
||||
Write-Host "Created GitHub-Release artifact: $versionedZip"
|
||||
@@ -488,6 +613,62 @@ jobs:
|
||||
}
|
||||
Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')"
|
||||
|
||||
# The devices' check, run on the FINAL ZIP rather than the staging folder: whatever the
|
||||
# archiver did (Compress-Archive skips hidden files) fails here instead of on a device.
|
||||
# Windows PowerShell 5.1, the edition installer and OOBE script verify with; the pin is
|
||||
# the installer's own literal, the value devices compare against.
|
||||
- name: Verify payload catalog (device check)
|
||||
shell: powershell
|
||||
env:
|
||||
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
$payload = Join-Path $env:RUNNER_TEMP 'verify-payload'
|
||||
if (Test-Path $payload) { throw "$payload already exists." }
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload)
|
||||
|
||||
$pinLine = Select-String -Path $env:BOOTSTRAP_SOURCE -Pattern '^\$ExpectedPublisher = "([^"]+)"' | Select-Object -First 1
|
||||
if (-not $pinLine) { throw "Could not read the publisher pin from $env:BOOTSTRAP_SOURCE." }
|
||||
$pin = $pinLine.Matches[0].Groups[1].Value
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $pin -ne $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
throw "Installer pin '$pin' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
}
|
||||
|
||||
$catalog = Join-Path $payload $env:PAYLOAD_CATALOG
|
||||
if (-not (Test-Path $catalog -PathType Leaf)) { throw "$env:PAYLOAD_CATALOG is missing from the ZIP." }
|
||||
$result = Test-FileCatalog -Path $payload -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed
|
||||
|
||||
$problems = @()
|
||||
if ($result.Status -ne 'Valid') {
|
||||
$expected = $result.CatalogItems
|
||||
$actual = $result.PathItems
|
||||
$missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) })
|
||||
$extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) })
|
||||
$changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] })
|
||||
$problems += "payload differs from the catalog (missing: $($missing -join ', '); extra: $($extra -join ', '); changed: $($changed -join ', '))"
|
||||
}
|
||||
$signature = $result.Signature
|
||||
if ($signature.Status -ne 'Valid') {
|
||||
$problems += "catalog signature status is '$($signature.Status)'"
|
||||
} elseif ($signature.SignerCertificate.Subject -notlike $pin) {
|
||||
$problems += "catalog signed by '$($signature.SignerCertificate.Subject)', expected '$pin'"
|
||||
}
|
||||
if (-not $signature.TimeStamperCertificate) {
|
||||
$problems += 'catalog signature carries no RFC-3161 timestamp -- it would expire with the certificate'
|
||||
}
|
||||
$exeSigner = (Get-AuthenticodeSignature (Join-Path $payload 'AutopilotMonitor.Agent.exe')).SignerCertificate
|
||||
if ($signature.SignerCertificate -and $exeSigner -and $signature.SignerCertificate.Thumbprint -ne $exeSigner.Thumbprint) {
|
||||
$problems += 'catalog and agent exe carry different signing certificates'
|
||||
}
|
||||
|
||||
if ($problems.Count -gt 0) {
|
||||
$problems | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Payload catalog verification failed. Nothing was published."
|
||||
}
|
||||
Write-Host "Payload catalog verified on the final ZIP: $($result.CatalogItems.Count) files, signer $($signature.SignerCertificate.Subject)"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
@@ -531,6 +712,18 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# Dev channel: lab blobs only, under the names the dev renders of the bootstrap scripts
|
||||
# read. Nothing else is touched -- no versioned or stable line, no AdminConfiguration, no
|
||||
# scripts; the channel check in `reserve` already refused publish_as_stable/create_release.
|
||||
if ($env:CHANNEL -eq 'dev') {
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_VERSIONED_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_MANIFEST -ContentType "application/json"
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_STABLE_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_STABLE_MANIFEST -ContentType "application/json"
|
||||
Write-Host "DEV CHANNEL: lab blobs written, nothing else touched."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Versioned namespace (always)
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:VERSIONED_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:MANIFEST_NAME -ContentType "application/json"
|
||||
@@ -636,7 +829,7 @@ jobs:
|
||||
- **Published as stable:** ${{ inputs.publish_as_stable }}
|
||||
|
||||
### Provenance
|
||||
The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
|
||||
The executables are Authenticode-signed and timestamped. Every file in the ZIP is listed in `AutopilotMonitor.Agent.cat`, a catalog signed with the same certificate, and third-party libraries carry their vendor's signature. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
|
||||
```
|
||||
gh attestation verify AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
|
||||
```
|
||||
|
||||
+1
-1
Submodule infra updated: 0af4689368...0085deab45
+1
-1
Submodule internal updated: 5fb01d6e4d...c9a13d6b47
+165
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// The bootstrap provenance in <c>agent_started</c>. Fixture lines are the scripts' real
|
||||
/// formats: installer <c>[ts] Bootstrap script version: vX.Y</c>
|
||||
/// (Install-AutopilotMonitor.ps1), loader <c>[ts] === Autopilot Monitor bootstrap loader vX.Y
|
||||
/// started (user=..., pid=...) ===</c> (Start-AutopilotMonitor.ps1).
|
||||
/// </summary>
|
||||
public sealed class BootstrapProvenanceReaderTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "apm-provenance-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public BootstrapProvenanceReaderTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private void Write(string name, params string[] lines) =>
|
||||
File.WriteAllText(Path.Combine(_dir, name), string.Join("\r\n", lines) + "\r\n", Encoding.ASCII);
|
||||
|
||||
private static string InstallerRun(string version) => $"[2026-10-13 09:00:00] Bootstrap script version: v{version}";
|
||||
|
||||
private static string LoaderRun(string version) =>
|
||||
$"[2026-10-13 08:59:58] === Autopilot Monitor bootstrap loader v{version} started (user=SECRETUSER, pid=4242) ===";
|
||||
|
||||
[Fact]
|
||||
public void Loader_path_reports_installer_and_loader_versions()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"), "[2026-10-13 08:59:59] Installer signature verified");
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor Bootstrap Started =====", InstallerRun("2.5"));
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.True(p.ScriptLogPresent);
|
||||
Assert.Equal("2.5", p.ScriptVersion);
|
||||
Assert.Equal("loader", p.LoaderLog);
|
||||
Assert.Equal("1.2", p.LoaderVersion);
|
||||
Assert.False(p.OobeLogPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_last_run_wins_when_the_log_grew_over_several_runs()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.3"), "x", InstallerRun("2.4"), "y", InstallerRun("2.5"), "z");
|
||||
|
||||
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_directly_assigned_installer_has_no_loader_log()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.4"));
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.Equal("2.4", p.ScriptVersion);
|
||||
Assert.Null(p.LoaderLog);
|
||||
Assert.Null(p.LoaderVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_installer_from_before_the_version_line_is_present_but_without_version()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-03-01 09:00:00] ===== Autopilot Monitor Bootstrap Started =====");
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.True(p.ScriptLogPresent);
|
||||
Assert.Null(p.ScriptVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_msi_loader_log_is_told_apart_from_the_intune_loader()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.1"));
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.Equal("msi", p.LoaderLog);
|
||||
Assert.Equal("1.1", p.LoaderVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_both_loader_logs_the_most_recently_written_one_counts()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.0"));
|
||||
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.2"));
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.LoaderLogName), DateTime.UtcNow.AddDays(-3));
|
||||
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.MsiLoaderLogName), DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
var p = BootstrapProvenanceReader.Read(_dir);
|
||||
|
||||
Assert.Equal("msi", p.LoaderLog);
|
||||
Assert.Equal("1.2", p.LoaderVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_oobe_go_script_is_recognised_by_its_log()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.OobeLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor OOBE Bootstrap =====");
|
||||
|
||||
Assert.True(BootstrapProvenanceReader.Read(_dir).OobeLogPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_the_tail_of_a_large_log_is_read()
|
||||
{
|
||||
var filler = Enumerable.Repeat(new string('.', 200), 600).ToArray(); // ~120 KB
|
||||
var lines = new List<string> { InstallerRun("2.1") };
|
||||
lines.AddRange(filler);
|
||||
lines.Add(InstallerRun("2.5"));
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, lines.ToArray());
|
||||
|
||||
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_directory_reads_as_unknown_and_never_throws()
|
||||
{
|
||||
var p = BootstrapProvenanceReader.Read(Path.Combine(_dir, "does-not-exist"));
|
||||
|
||||
Assert.False(p.ScriptLogPresent);
|
||||
Assert.Null(p.ScriptVersion);
|
||||
Assert.Null(p.LoaderLog);
|
||||
Assert.False(p.OobeLogPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_event_keys_carry_versions_only_never_the_account_name()
|
||||
{
|
||||
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"));
|
||||
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.5"));
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
BootstrapProvenanceReader.Read(_dir).AddTo(data);
|
||||
|
||||
Assert.Equal(true, data["bootstrapScriptLogPresent"]);
|
||||
Assert.Equal("2.5", data["bootstrapScriptVersion"]);
|
||||
Assert.Equal("loader", data["bootstrapLoaderLog"]);
|
||||
Assert.Equal("1.2", data["bootstrapLoaderVersion"]);
|
||||
Assert.Equal(false, data["oobeBootstrapLogPresent"]);
|
||||
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("SECRETUSER"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_that_were_not_found_are_left_out()
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
BootstrapProvenanceReader.Read(_dir).AddTo(data);
|
||||
|
||||
Assert.Equal(new[] { "bootstrapScriptLogPresent", "oobeBootstrapLogPresent" }, data.Keys.OrderBy(k => k).ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Step 5b of the self-update: the staged payload must match its signed catalog before a
|
||||
/// single file is swapped in. The verifier is replaced through the static hook; each test
|
||||
/// restores it, as in <see cref="SelfUpdaterGracefulShutdownTests"/>.
|
||||
/// </summary>
|
||||
public sealed class SelfUpdaterPayloadCatalogTests : IDisposable
|
||||
{
|
||||
private const string StagingDir = @"C:\ProgramData\AutopilotMonitor\Agent-Update";
|
||||
|
||||
private readonly Func<string, PayloadCatalogVerdict> _savedVerifier;
|
||||
private readonly List<string> _log = new List<string>();
|
||||
|
||||
public SelfUpdaterPayloadCatalogTests()
|
||||
{
|
||||
_savedVerifier = SelfUpdater.VerifyPayloadCatalog;
|
||||
}
|
||||
|
||||
public void Dispose() => SelfUpdater.VerifyPayloadCatalog = _savedVerifier;
|
||||
|
||||
[Fact]
|
||||
public void Production_default_is_the_real_verifier()
|
||||
{
|
||||
Assert.Equal(typeof(PayloadCatalogVerifier), _savedVerifier.Method.DeclaringType);
|
||||
Assert.Equal(nameof(PayloadCatalogVerifier.Verify), _savedVerifier.Method.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accepted_payload_lets_the_update_continue()
|
||||
{
|
||||
string? checkedDir = null;
|
||||
SelfUpdater.VerifyPayloadCatalog = dir => { checkedDir = dir; return PayloadCatalogVerdict.Accept(); };
|
||||
|
||||
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Null(reason);
|
||||
Assert.Equal(StagingDir, checkedDir);
|
||||
Assert.Contains(_log, line => line.Contains("payload catalog verified"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejected_payload_stops_the_update_and_hands_over_the_reason()
|
||||
{
|
||||
SelfUpdater.VerifyPayloadCatalog = _ =>
|
||||
PayloadCatalogVerdict.Reject("payload differs from catalog (missing: none; extra: evil.dll; changed: none)");
|
||||
|
||||
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Contains("extra: evil.dll", reason);
|
||||
Assert.Contains(_log, line => line.Contains("REJECTED"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_verifier_is_a_rejection_not_a_pass()
|
||||
{
|
||||
SelfUpdater.VerifyPayloadCatalog = _ => throw new InvalidOperationException("runspace broke");
|
||||
|
||||
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Contains("runspace broke", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_catalog_check_runs_before_any_file_is_swapped()
|
||||
{
|
||||
// Source-level pin: CheckAndApplyUpdateAsync has no seam for the download, so the
|
||||
// ordering that makes the check meaningful is pinned on the source itself.
|
||||
var source = File.ReadAllText(Path.Combine(FindRepoRoot(),
|
||||
"src", "Agent", "AutopilotMonitor.Agent.V2.Core", "Monitoring", "Runtime", "SelfUpdater.cs"));
|
||||
|
||||
var check = source.IndexOf("if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))", StringComparison.Ordinal);
|
||||
var swap = source.IndexOf("var swapOk = SwapFiles(agentDir, stagingDir, log);", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(check > 0, "step 5b not found in SelfUpdater.cs");
|
||||
Assert.True(swap > check, "SwapFiles must come after the catalog check");
|
||||
Assert.Contains("WriteSkipMarker(\"catalog_rejected\"", source);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir.FullName;
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -44,6 +44,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.Equal("Succeeded", data["remoteConfigOutcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitAgentStarted_CarriesTheBootstrapProvenance()
|
||||
{
|
||||
// Values depend on the machine's %ProgramData%\AutopilotMonitor\Logs (see
|
||||
// BootstrapProvenanceReaderTests); the two presence flags are always on the wire.
|
||||
using var rig = new EmitterRig();
|
||||
rig.RemoteConfig.SetFakeOutcome(RemoteConfigFetchOutcome.Succeeded, configVersion: 28);
|
||||
|
||||
LifecycleEmitters.EmitAgentStarted(
|
||||
rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger);
|
||||
|
||||
var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started");
|
||||
Assert.IsType<bool>(data["bootstrapScriptLogPresent"]);
|
||||
Assert.IsType<bool>(data["oobeBootstrapLogPresent"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitAgentStarted_OnFallbackToDefaults_FlagsRemoteConfigFalse()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The agent data directory keeps standard users from adding files where the SYSTEM agent
|
||||
/// starts from. Works on a temp folder the test owns; as owner it can always hand itself the
|
||||
/// rights back to clean up.
|
||||
/// </summary>
|
||||
public sealed class AgentDirectoryAclTests : IDisposable
|
||||
{
|
||||
private const FileSystemRights WriteRights =
|
||||
FileSystemRights.WriteData | FileSystemRights.AppendData | FileSystemRights.WriteAttributes
|
||||
| FileSystemRights.WriteExtendedAttributes | FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles
|
||||
| FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership;
|
||||
|
||||
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "apm-acl-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public AgentDirectoryAclTests() => Directory.CreateDirectory(_root);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
var me = WindowsIdentity.GetCurrent().User!;
|
||||
var directory = new DirectoryInfo(_root);
|
||||
var security = directory.GetAccessControl();
|
||||
security.AddAccessRule(new FileSystemAccessRule(me, FileSystemRights.FullControl,
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
directory.SetAccessControl(security);
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort: a leftover temp folder must not fail the run
|
||||
}
|
||||
}
|
||||
|
||||
private static FileSystemRights UsersRights(string path) =>
|
||||
new DirectoryInfo(path).GetAccessControl()
|
||||
.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
|
||||
.Cast<FileSystemAccessRule>()
|
||||
.Where(r => r.AccessControlType == AccessControlType.Allow && Users.Equals(r.IdentityReference))
|
||||
.Aggregate((FileSystemRights)0, (all, r) => all | r.FileSystemRights);
|
||||
|
||||
[Fact]
|
||||
public void The_protected_security_inherits_nothing_and_gives_users_read_only()
|
||||
{
|
||||
var security = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
|
||||
Assert.True(security.AreAccessRulesProtected);
|
||||
var users = security.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>()
|
||||
.Single(r => Users.Equals(r.IdentityReference));
|
||||
// An allow rule always carries Synchronize; .NET adds it on construction.
|
||||
Assert.Equal(FileSystemRights.ReadAndExecute, users.FileSystemRights & ~FileSystemRights.Synchronize);
|
||||
Assert.Equal((FileSystemRights)0, users.FileSystemRights & WriteRights);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_folder_is_protected_once_and_then_left_alone()
|
||||
{
|
||||
var first = AgentDirectoryAcl.EnsureProtected(_root);
|
||||
var second = AgentDirectoryAcl.EnsureProtected(_root);
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Applied, first.Outcome);
|
||||
Assert.Equal(AgentDirectoryAclOutcome.AlreadyProtected, second.Outcome);
|
||||
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access)));
|
||||
Assert.Equal((FileSystemRights)0, UsersRights(_root) & WriteRights);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_subfolders_take_over_the_protection()
|
||||
{
|
||||
// Install mode protects the base after Agent, Spool and Logs already exist.
|
||||
var agent = Directory.CreateDirectory(Path.Combine(_root, "Agent")).FullName;
|
||||
|
||||
AgentDirectoryAcl.EnsureProtected(_root);
|
||||
|
||||
Assert.Equal((FileSystemRights)0, UsersRights(agent) & WriteRights);
|
||||
Assert.NotEqual((FileSystemRights)0, UsersRights(agent) & FileSystemRights.ReadAndExecute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unprotected_or_widened_dacl_is_not_taken_as_protected()
|
||||
{
|
||||
var unprotected = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
unprotected.SetAccessRuleProtection(isProtected: false, preserveInheritance: true);
|
||||
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(unprotected));
|
||||
|
||||
var widened = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
widened.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.Modify,
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(widened));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_folder_is_reported_not_created()
|
||||
{
|
||||
var missing = Path.Combine(_root, "does-not-exist");
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Missing, AgentDirectoryAcl.EnsureProtected(missing).Outcome);
|
||||
Assert.False(Directory.Exists(missing));
|
||||
}
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The decision over what <c>Test-FileCatalog -Detailed</c> reports. The runspace itself is
|
||||
/// exercised on a lab device (plan agent-payload-catalog, V1/V3), never in the test host.
|
||||
/// </summary>
|
||||
public sealed class PayloadCatalogVerifierTests
|
||||
{
|
||||
private const string OurSubject = "CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach, S=Hessen, C=DE";
|
||||
|
||||
private static Dictionary<string, string> Files(params string[] namesAndHashes)
|
||||
{
|
||||
var map = new Dictionary<string, string>();
|
||||
for (var i = 0; i < namesAndHashes.Length; i += 2) map[namesAndHashes[i]] = namesAndHashes[i + 1];
|
||||
return map;
|
||||
}
|
||||
|
||||
private static PayloadCatalogFacts Facts(
|
||||
string status = "Valid", string? signatureStatus = "Valid", string? subject = OurSubject,
|
||||
Dictionary<string, string>? catalog = null, Dictionary<string, string>? path = null)
|
||||
=> new PayloadCatalogFacts(status, signatureStatus, subject,
|
||||
catalog ?? Files("a.dll", "AA"), path ?? Files("a.dll", "AA"));
|
||||
|
||||
private static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts)
|
||||
=> PayloadCatalogVerifier.Evaluate(facts, Constants.CodeSigningPublisherPattern);
|
||||
|
||||
[Fact]
|
||||
public void Signed_by_us_and_matching_is_accepted()
|
||||
{
|
||||
var verdict = Evaluate(Facts());
|
||||
|
||||
Assert.True(verdict.Accepted);
|
||||
Assert.Null(verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matching_hashes_under_an_unsigned_catalog_are_rejected()
|
||||
{
|
||||
// Status covers the hashes only: an unsigned catalog over matching files reports Valid.
|
||||
var verdict = Evaluate(Facts(signatureStatus: "NotSigned", subject: null));
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("catalog signature status NotSigned", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_validly_signed_catalog_from_another_publisher_is_rejected()
|
||||
{
|
||||
var verdict = Evaluate(Facts(subject: "CN=Contoso Ltd, O=Contoso Ltd, C=US"));
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("does not match", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_signature_details_are_a_rejection_not_a_pass()
|
||||
{
|
||||
Assert.False(Evaluate(Facts(signatureStatus: null, subject: null)).Accepted);
|
||||
Assert.False(Evaluate(Facts(subject: "")).Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_differing_payload_names_missing_extra_and_changed_files()
|
||||
{
|
||||
var verdict = Evaluate(Facts(
|
||||
status: "ValidationFailed",
|
||||
catalog: Files("a.dll", "AA", "b.dll", "BB", "c.config", "CC"),
|
||||
path: Files("a.dll", "AA", "b.dll", "XX", "evil.dll", "EE")));
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("missing: c.config", verdict.Reason);
|
||||
Assert.Contains("extra: evil.dll", verdict.Reason);
|
||||
Assert.Contains("changed: b.dll", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Long_difference_lists_are_capped()
|
||||
{
|
||||
var path = Files("a.dll", "AA");
|
||||
for (var i = 0; i < 12; i++) path[$"extra{i:00}.txt"] = "00";
|
||||
|
||||
var verdict = Evaluate(Facts(status: "ValidationFailed", path: path));
|
||||
|
||||
Assert.Contains("(+7 more)", verdict.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signature_and_payload_problems_are_both_reported()
|
||||
{
|
||||
var verdict = Evaluate(Facts(status: "ValidationFailed", signatureStatus: "HashMismatch", subject: null,
|
||||
path: Files("a.dll", "AA", "x.dll", "11")));
|
||||
|
||||
Assert.Contains("catalog signature status HashMismatch", verdict.Reason);
|
||||
Assert.Contains("extra: x.dll", verdict.Reason);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OurSubject, true)]
|
||||
[InlineData("cn=glueckkanja ag, o=glueckkanja ag, c=de", true)] // -like is case-insensitive
|
||||
[InlineData("CN=x, O=glueckkanja AG Holding, C=DE", true)] // the pattern is open-ended by design
|
||||
[InlineData("CN=glueckkanja AG, O=Contoso, C=DE", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void Publisher_pattern_behaves_like_powershell_like(string? subject, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, PayloadCatalogVerifier.MatchesPublisherPattern(subject, Constants.CodeSigningPublisherPattern));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regex_metacharacters_in_the_pattern_are_literal()
|
||||
{
|
||||
Assert.True(PayloadCatalogVerifier.MatchesPublisherPattern("O=Json.NET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
|
||||
Assert.False(PayloadCatalogVerifier.MatchesPublisherPattern("O=JsonXNET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_payload_without_catalog_is_rejected_before_any_runspace_starts()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "apm-catalog-test-" + System.Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "readme.txt"), "x");
|
||||
|
||||
var verdict = PayloadCatalogVerifier.Verify(dir);
|
||||
|
||||
Assert.False(verdict.Accepted);
|
||||
Assert.Contains("missing", verdict.Reason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,15 @@
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Management" />
|
||||
<!--
|
||||
Compile against the GAC copy, never ship it (Private=false). The runtime binds this
|
||||
strong-named assembly from the device's GAC before it probes the app folder, and every
|
||||
Windows 10/11 has it (Windows PowerShell 5.1 runs on it), so a shipped copy was never
|
||||
loaded. It was the build machine's file: unsigned on any other machine, and half the ZIP.
|
||||
-->
|
||||
<Reference Include="System.Management.Automation">
|
||||
<HintPath>$(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="System.ServiceProcess" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Which bootstrap installed this agent, read from the bootstrap scripts' own logs next to the
|
||||
/// agent log. The scripts cannot report it themselves (they run before any agent exists), and
|
||||
/// passing a version to <c>--install</c> would only ever cover installers released after that
|
||||
/// change -- the old installers still assigned in Intune are exactly the ones worth counting.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>bootstrap-script.log</c>: <c>Bootstrap script version: vX.Y</c>, written by every
|
||||
/// installer run since 2026-04-09. A log without that line means an older installer.</item>
|
||||
/// <item><c>bootstrap-loader.log</c> (Intune) / <c>bootstrap-msi.log</c> (MSI):
|
||||
/// <c>bootstrap loader vX.Y started</c>, written by every loader since v1.0.</item>
|
||||
/// <item><c>oobe_bootstrap.log</c>: the OOBE go-script (generated by the backend, no version).</item>
|
||||
/// </list>
|
||||
/// Only the version number is extracted; the loader line also names the account, which is
|
||||
/// never read out. Observational only, and it never throws: an unreadable log reads as absent.
|
||||
/// </summary>
|
||||
internal static class BootstrapProvenanceReader
|
||||
{
|
||||
internal const string InstallerLogName = "bootstrap-script.log";
|
||||
internal const string LoaderLogName = "bootstrap-loader.log";
|
||||
internal const string MsiLoaderLogName = "bootstrap-msi.log";
|
||||
internal const string OobeLogName = "oobe_bootstrap.log";
|
||||
|
||||
// The logs grow across runs; the last run's lines sit at the end. One run writes a few KB.
|
||||
private const int TailBytes = 64 * 1024;
|
||||
|
||||
private static readonly Regex InstallerVersionLine = new Regex(
|
||||
@"Bootstrap script version: v(?<v>\d+(?:\.\d+){0,3})\b", RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Regex LoaderStartLine = new Regex(
|
||||
@"bootstrap loader v(?<v>\d+(?:\.\d+){0,3}) started\b", RegexOptions.CultureInvariant);
|
||||
|
||||
public static BootstrapProvenance Read(string logDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var installerLog = ReadTail(Path.Combine(logDirectory, InstallerLogName));
|
||||
|
||||
// Intune loader and MSI loader write separate files; when a device has both, the
|
||||
// more recently written one belongs to the bootstrap that ran last.
|
||||
var loader = new[] { (Name: LoaderLogName, Channel: "loader"), (Name: MsiLoaderLogName, Channel: "msi") }
|
||||
.Select(l => (l.Channel, Path: Path.Combine(logDirectory, l.Name)))
|
||||
.Where(l => File.Exists(l.Path))
|
||||
.OrderByDescending(l => File.GetLastWriteTimeUtc(l.Path))
|
||||
.Select(l => (l.Channel, Version: LastVersion(ReadTail(l.Path), LoaderStartLine)))
|
||||
.FirstOrDefault();
|
||||
|
||||
return new BootstrapProvenance(
|
||||
scriptLogPresent: installerLog != null,
|
||||
scriptVersion: LastVersion(installerLog, InstallerVersionLine),
|
||||
loaderLog: loader.Channel,
|
||||
loaderVersion: loader.Version,
|
||||
oobeLogPresent: File.Exists(Path.Combine(logDirectory, OobeLogName)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return BootstrapProvenance.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LastVersion(string text, Regex line)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return null;
|
||||
string version = null;
|
||||
foreach (Match match in line.Matches(text))
|
||||
version = match.Groups["v"].Value;
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <summary>The last <see cref="TailBytes"/> of the file, or null when it is missing or unreadable.</summary>
|
||||
private static string ReadTail(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||
{
|
||||
var start = Math.Max(0, stream.Length - TailBytes);
|
||||
stream.Seek(start, SeekOrigin.Begin);
|
||||
var buffer = new byte[stream.Length - start];
|
||||
var read = 0;
|
||||
while (read < buffer.Length)
|
||||
{
|
||||
var n = stream.Read(buffer, read, buffer.Length - read);
|
||||
if (n == 0) break;
|
||||
read += n;
|
||||
}
|
||||
// The scripts write ASCII (their own pure-ASCII rule); UTF-8 decodes it either way.
|
||||
return Encoding.UTF8.GetString(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What <see cref="BootstrapProvenanceReader"/> found; null fields were not found.</summary>
|
||||
internal sealed class BootstrapProvenance
|
||||
{
|
||||
public static readonly BootstrapProvenance Unknown = new BootstrapProvenance(false, null, null, null, false);
|
||||
|
||||
public BootstrapProvenance(bool scriptLogPresent, string scriptVersion, string loaderLog, string loaderVersion, bool oobeLogPresent)
|
||||
{
|
||||
ScriptLogPresent = scriptLogPresent;
|
||||
ScriptVersion = scriptVersion;
|
||||
LoaderLog = loaderLog;
|
||||
LoaderVersion = loaderVersion;
|
||||
OobeLogPresent = oobeLogPresent;
|
||||
}
|
||||
|
||||
public bool ScriptLogPresent { get; }
|
||||
public string ScriptVersion { get; }
|
||||
public string LoaderLog { get; }
|
||||
public string LoaderVersion { get; }
|
||||
public bool OobeLogPresent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds the <c>agent_started</c> keys. Version and loader keys only when found, so a
|
||||
/// missing key always means "not found" and never an empty string.
|
||||
/// </summary>
|
||||
public void AddTo(IDictionary<string, object> data)
|
||||
{
|
||||
data["bootstrapScriptLogPresent"] = ScriptLogPresent;
|
||||
if (ScriptVersion != null) data["bootstrapScriptVersion"] = ScriptVersion;
|
||||
if (LoaderLog != null) data["bootstrapLoaderLog"] = LoaderLog;
|
||||
if (LoaderVersion != null) data["bootstrapLoaderVersion"] = LoaderVersion;
|
||||
data["oobeBootstrapLogPresent"] = OobeLogPresent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
internal const int GracefulExitFallbackMs = 15000;
|
||||
|
||||
/// <summary>
|
||||
/// The payload catalog check of step 5b. Production keeps the default,
|
||||
/// <see cref="PayloadCatalogVerifier.Verify"/>; tests swap it so the update path runs
|
||||
/// without a PowerShell runspace. Same static-hook idiom as <see cref="BackendExpectedSha256"/>.
|
||||
/// </summary>
|
||||
internal static Func<string, PayloadCatalogVerdict> VerifyPayloadCatalog { get; set; } = PayloadCatalogVerifier.Verify;
|
||||
|
||||
/// <summary>
|
||||
/// Checks for a newer agent version and applies the update if available.
|
||||
/// On success, restarts the process and never returns.
|
||||
@@ -381,6 +388,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5b: The payload must match its signed catalog -- the check the installer and
|
||||
// the OOBE script run before --install. Covers what the ZIP hash cannot: a hash
|
||||
// from the download host vouches for nothing if that host is compromised, and
|
||||
// without any hash there was no check at all.
|
||||
if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))
|
||||
{
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("catalog_rejected", currentVersion, latestVersion, catalogRejection, log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Swap files (rename locked → .old, copy new)
|
||||
var swapTimerSw = Stopwatch.StartNew();
|
||||
var swapOk = SwapFiles(agentDir, stagingDir, log);
|
||||
@@ -597,6 +616,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 5b. Fail-closed: a rejected payload is never swapped in, and the running agent --
|
||||
/// verified when it was installed -- stays. A verifier that throws counts as a rejection.
|
||||
/// </summary>
|
||||
internal static bool VerifyStagedPayload(string stagingDir, Action<string> log, out string rejectionReason)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
PayloadCatalogVerdict verdict;
|
||||
try
|
||||
{
|
||||
verdict = VerifyPayloadCatalog(stagingDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
verdict = PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})");
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
if (verdict.Accepted)
|
||||
{
|
||||
log($"Self-update: payload catalog verified ({sw.ElapsedMilliseconds}ms)");
|
||||
rejectionReason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
log($"Self-update: payload catalog REJECTED ({sw.ElapsedMilliseconds}ms) — {verdict.Reason}. Aborting update.");
|
||||
rejectionReason = verdict.Reason;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the ZIP to the staging directory. Returns false on failure.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Keeps <c>%ProgramData%\AutopilotMonitor</c> out of reach of standard users. Left alone the
|
||||
/// folder inherits ProgramData's "Users may create files and folders" entry, and the SYSTEM
|
||||
/// agent and its boot task start from the Agent subfolder: a user who can add a file there
|
||||
/// can plant code the next SYSTEM start loads. During the device phase of OOBE only SYSTEM is
|
||||
/// around, but the agent keeps running through the user phase and on the desktop until the
|
||||
/// enrollment ends, restarted by the boot task after every reboot; that is the window this
|
||||
/// closes. The payload catalog does not cover it: it is checked at install time, a file
|
||||
/// added later is never looked at.
|
||||
/// <para>
|
||||
/// The DACL is protected (nothing inherited from ProgramData): SYSTEM and Administrators full
|
||||
/// control, Users read and execute, all inherited by the subfolders. Explicit entries on a
|
||||
/// child stay. The summary dialog's temp folder lives outside this tree anyway.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AgentDirectoryAcl
|
||||
{
|
||||
private const InheritanceFlags Inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
|
||||
|
||||
private static readonly SecurityIdentifier LocalSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
private static readonly SecurityIdentifier Administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the protected DACL to <paramref name="path"/> unless it is already in place.
|
||||
/// Never throws: a folder the agent cannot protect must not stop the agent.
|
||||
/// </summary>
|
||||
public static AgentDirectoryAclResult EnsureProtected(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = new DirectoryInfo(path);
|
||||
if (!directory.Exists)
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Missing, null);
|
||||
|
||||
if (IsProtectedAsExpected(directory.GetAccessControl(AccessControlSections.Access)))
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.AlreadyProtected, null);
|
||||
|
||||
directory.SetAccessControl(BuildProtectedSecurity());
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Applied, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Failed, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
internal static DirectorySecurity BuildProtectedSecurity()
|
||||
{
|
||||
var security = new DirectorySecurity();
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
security.AddAccessRule(new FileSystemAccessRule(LocalSystem, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(Administrators, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.ReadAndExecute, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
return security;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the DACL is protected and holds exactly the three entries above. Windows adds
|
||||
/// <see cref="FileSystemRights.Synchronize"/> when it stores an entry, so that bit is ignored.
|
||||
/// </summary>
|
||||
internal static bool IsProtectedAsExpected(DirectorySecurity security)
|
||||
{
|
||||
if (!security.AreAccessRulesProtected)
|
||||
return false;
|
||||
|
||||
var rules = security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
|
||||
.Cast<FileSystemAccessRule>()
|
||||
.ToList();
|
||||
|
||||
return rules.Count == 3
|
||||
&& Has(rules, LocalSystem, FileSystemRights.FullControl)
|
||||
&& Has(rules, Administrators, FileSystemRights.FullControl)
|
||||
&& Has(rules, Users, FileSystemRights.ReadAndExecute);
|
||||
}
|
||||
|
||||
private static bool Has(IEnumerable<FileSystemAccessRule> rules, SecurityIdentifier sid, FileSystemRights rights) =>
|
||||
rules.Any(r => r.AccessControlType == AccessControlType.Allow
|
||||
&& !r.IsInherited
|
||||
&& sid.Equals(r.IdentityReference)
|
||||
&& (r.FileSystemRights & ~FileSystemRights.Synchronize) == (rights & ~FileSystemRights.Synchronize)
|
||||
&& r.InheritanceFlags == Inherit
|
||||
&& r.PropagationFlags == PropagationFlags.None);
|
||||
}
|
||||
|
||||
public enum AgentDirectoryAclOutcome
|
||||
{
|
||||
Applied,
|
||||
AlreadyProtected,
|
||||
Missing,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// <summary>Outcome of <see cref="AgentDirectoryAcl.EnsureProtected"/>; <see cref="Error"/> only on failure.</summary>
|
||||
public sealed class AgentDirectoryAclResult
|
||||
{
|
||||
public AgentDirectoryAclResult(AgentDirectoryAclOutcome outcome, string error)
|
||||
{
|
||||
Outcome = outcome;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public AgentDirectoryAclOutcome Outcome { get; }
|
||||
public string Error { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management.Automation;
|
||||
using System.Management.Automation.Runspaces;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies an extracted agent payload against its signed catalog
|
||||
/// (<see cref="Constants.AgentPayloadCatalogFileName"/>) with Windows PowerShell's own
|
||||
/// <c>Test-FileCatalog</c>, the cmdlet the bootstrap installer and the OOBE script run as well,
|
||||
/// so all three verifiers share one semantics: the catalog must carry a valid signature from
|
||||
/// <see cref="Constants.CodeSigningPublisherPattern"/>, and every file of the payload must match
|
||||
/// it, with nothing missing, changed or extra.
|
||||
/// <para>
|
||||
/// A reimplementation of the catalog format (WinVerifyTrust plus the CryptCAT APIs) was the
|
||||
/// alternative and was rejected: a second parser for the same contract would drift from the
|
||||
/// cmdlet the other two verifiers use. The runspace costs about a second, paid only on the
|
||||
/// rare update path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class PayloadCatalogVerifier
|
||||
{
|
||||
private const int InvokeTimeoutMs = 60000;
|
||||
private const int MaxNamesPerList = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Checks <paramref name="payloadDir"/>. Never throws: any failure to run the check is a
|
||||
/// rejection, because an unverifiable payload must not be installed.
|
||||
/// </summary>
|
||||
public static PayloadCatalogVerdict Verify(string payloadDir)
|
||||
{
|
||||
var catalogPath = Path.Combine(payloadDir, Constants.AgentPayloadCatalogFileName);
|
||||
if (!File.Exists(catalogPath))
|
||||
return PayloadCatalogVerdict.Reject($"catalog {Constants.AgentPayloadCatalogFileName} missing");
|
||||
|
||||
return RunCatalogCheck(payloadDir, catalogPath);
|
||||
}
|
||||
|
||||
// Kept out of Verify (and never inlined) so the PowerShell engine is loaded only when a
|
||||
// catalog is actually checked, not when the method that guards it is compiled.
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static PayloadCatalogVerdict RunCatalogCheck(string payloadDir, string catalogPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var runspace = RunspaceFactory.CreateRunspace())
|
||||
{
|
||||
runspace.Open();
|
||||
using (var ps = PowerShell.Create())
|
||||
{
|
||||
ps.Runspace = runspace;
|
||||
ps.AddCommand(@"Microsoft.PowerShell.Security\Test-FileCatalog")
|
||||
.AddParameter("Path", payloadDir)
|
||||
.AddParameter("CatalogFilePath", catalogPath)
|
||||
.AddParameter("FilesToSkip", new[] { Constants.AgentPayloadCatalogFileName })
|
||||
.AddParameter("Detailed");
|
||||
|
||||
var asyncResult = ps.BeginInvoke();
|
||||
if (!asyncResult.AsyncWaitHandle.WaitOne(InvokeTimeoutMs))
|
||||
{
|
||||
ps.Stop();
|
||||
return PayloadCatalogVerdict.Reject($"catalog check timed out after {InvokeTimeoutMs / 1000}s");
|
||||
}
|
||||
|
||||
var results = ps.EndInvoke(asyncResult);
|
||||
if (ps.HadErrors && ps.Streams.Error.Count > 0)
|
||||
return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ps.Streams.Error[0]})");
|
||||
|
||||
var info = results.FirstOrDefault();
|
||||
if (info == null)
|
||||
return PayloadCatalogVerdict.Reject("catalog check returned nothing");
|
||||
|
||||
var signature = info.Properties["Signature"]?.Value as Signature;
|
||||
return Evaluate(new PayloadCatalogFacts(
|
||||
status: info.Properties["Status"]?.Value?.ToString(),
|
||||
signatureStatus: signature?.Status.ToString(),
|
||||
signerSubject: signature?.SignerCertificate?.Subject,
|
||||
catalogItems: ToStringMap(info.Properties["CatalogItems"]?.Value),
|
||||
pathItems: ToStringMap(info.Properties["PathItems"]?.Value)),
|
||||
Constants.CodeSigningPublisherPattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The decision over the facts <c>Test-FileCatalog -Detailed</c> reports. Separate from the
|
||||
/// runspace so it is testable without PowerShell. <c>Status</c> covers the hashes only: an
|
||||
/// unsigned catalog over matching files reports <c>Valid</c>, which is why the signature is
|
||||
/// judged on its own.
|
||||
/// </summary>
|
||||
internal static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts, string publisherPattern)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
|
||||
if (!string.Equals(facts.SignatureStatus, "Valid", StringComparison.Ordinal))
|
||||
{
|
||||
reasons.Add($"catalog signature status {facts.SignatureStatus ?? "unknown"}");
|
||||
}
|
||||
else if (!MatchesPublisherPattern(facts.SignerSubject, publisherPattern))
|
||||
{
|
||||
reasons.Add($"catalog publisher '{facts.SignerSubject}' does not match {publisherPattern}");
|
||||
}
|
||||
|
||||
if (!string.Equals(facts.Status, "Valid", StringComparison.Ordinal))
|
||||
reasons.Add(DescribeDifference(facts.CatalogItems, facts.PathItems));
|
||||
|
||||
return reasons.Count == 0
|
||||
? PayloadCatalogVerdict.Accept()
|
||||
: PayloadCatalogVerdict.Reject(string.Join("; ", reasons));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PowerShell's case-insensitive <c>-like</c> for the two wildcards the pin uses (<c>*</c>,
|
||||
/// <c>?</c>), without loading the PowerShell engine. Bracket classes are not supported;
|
||||
/// <c>CodeSigningParityTests</c> pins the pattern's shape.
|
||||
/// </summary>
|
||||
internal static bool MatchesPublisherPattern(string subject, string pattern)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(pattern)) return false;
|
||||
var regex = "^" + Regex.Escape(pattern).Replace(@"\*", ".*").Replace(@"\?", ".") + "$";
|
||||
return Regex.IsMatch(subject, regex, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant);
|
||||
}
|
||||
|
||||
private static string DescribeDifference(
|
||||
IReadOnlyDictionary<string, string> expected, IReadOnlyDictionary<string, string> actual)
|
||||
{
|
||||
var missing = expected.Keys.Where(k => !actual.ContainsKey(k));
|
||||
var extra = actual.Keys.Where(k => !expected.ContainsKey(k));
|
||||
var changed = expected.Keys.Where(k => actual.TryGetValue(k, out var hash)
|
||||
&& !string.Equals(hash, expected[k], StringComparison.OrdinalIgnoreCase));
|
||||
return $"payload differs from catalog (missing: {Cap(missing)}; extra: {Cap(extra)}; changed: {Cap(changed)})";
|
||||
}
|
||||
|
||||
private static string Cap(IEnumerable<string> names)
|
||||
{
|
||||
var list = names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (list.Count == 0) return "none";
|
||||
var shown = string.Join(", ", list.Take(MaxNamesPerList));
|
||||
return list.Count > MaxNamesPerList ? $"{shown} (+{list.Count - MaxNamesPerList} more)" : shown;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ToStringMap(object value)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (value is IDictionary dictionary)
|
||||
{
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
map[Convert.ToString(entry.Key)] = Convert.ToString(entry.Value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What <c>Test-FileCatalog -Detailed</c> reported, reduced to the fields the decision needs.</summary>
|
||||
internal sealed class PayloadCatalogFacts
|
||||
{
|
||||
public PayloadCatalogFacts(
|
||||
string status, string signatureStatus, string signerSubject,
|
||||
IReadOnlyDictionary<string, string> catalogItems, IReadOnlyDictionary<string, string> pathItems)
|
||||
{
|
||||
Status = status;
|
||||
SignatureStatus = signatureStatus;
|
||||
SignerSubject = signerSubject;
|
||||
CatalogItems = catalogItems ?? new Dictionary<string, string>();
|
||||
PathItems = pathItems ?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public string Status { get; }
|
||||
public string SignatureStatus { get; }
|
||||
public string SignerSubject { get; }
|
||||
public IReadOnlyDictionary<string, string> CatalogItems { get; }
|
||||
public IReadOnlyDictionary<string, string> PathItems { get; }
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a payload catalog check; <see cref="Reason"/> is null when accepted.</summary>
|
||||
public sealed class PayloadCatalogVerdict
|
||||
{
|
||||
private PayloadCatalogVerdict(bool accepted, string reason)
|
||||
{
|
||||
Accepted = accepted;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public bool Accepted { get; }
|
||||
public string Reason { get; }
|
||||
|
||||
public static PayloadCatalogVerdict Accept() => new PayloadCatalogVerdict(true, null);
|
||||
|
||||
public static PayloadCatalogVerdict Reject(string reason) => new PayloadCatalogVerdict(false, reason);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,13 @@
|
||||
<InformationalVersion>$(Version)</InformationalVersion>
|
||||
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
|
||||
<!--
|
||||
Of the files that travel next to referenced assemblies, only PDBs ship: they put file and
|
||||
line into the stack traces the agent log records. XML doc files (AutopilotMonitor.Shared.xml)
|
||||
are IntelliSense data no device reads. This output folder is the release payload.
|
||||
-->
|
||||
<AllowedReferenceRelatedFileExtensions>.pdb</AllowedReferenceRelatedFileExtensions>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="PersistBuildCounter" BeforeTargets="BeforeBuild" Condition="'$(BUILD_NUMBER)' == ''">
|
||||
|
||||
@@ -57,6 +57,8 @@ namespace AutopilotMonitor.Agent.V2
|
||||
logger.Info("======================= Agent install mode (--install) =======================");
|
||||
|
||||
EnsureAgentDirectories(logger);
|
||||
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: true);
|
||||
|
||||
var sourceExePath = Assembly.GetExecutingAssembly().Location;
|
||||
var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
|
||||
@@ -273,6 +275,26 @@ namespace AutopilotMonitor.Agent.V2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the outcome of <see cref="AgentDirectoryAcl.EnsureProtected"/>. Install mode logs
|
||||
/// every outcome; the runtime start only a change or a failure, so a normal start stays quiet.
|
||||
/// </summary>
|
||||
internal static void LogAgentDirectoryAcl(AgentDirectoryAclResult result, AgentLogger logger, bool alwaysLog)
|
||||
{
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case AgentDirectoryAclOutcome.Applied:
|
||||
logger.Info("Agent data directory protected: SYSTEM and Administrators full control, Users read and execute, nothing inherited from ProgramData.");
|
||||
break;
|
||||
case AgentDirectoryAclOutcome.Failed:
|
||||
logger.Warning($"Could not protect the agent data directory: {result.Error}");
|
||||
break;
|
||||
default:
|
||||
if (alwaysLog) logger.Info($"Agent data directory ACL: {result.Outcome}.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureAgentDirectories(AgentLogger logger)
|
||||
{
|
||||
var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
|
||||
|
||||
@@ -168,6 +168,11 @@ namespace AutopilotMonitor.Agent.V2
|
||||
var formattedArgs = FormatArgsForLog(args);
|
||||
logger.Info($"Command line: {(string.IsNullOrEmpty(formattedArgs) ? "(no args)" : formattedArgs)}");
|
||||
|
||||
// Same protection install mode applies, re-checked on every start: it also reaches
|
||||
// agents that arrived by self-update over an install made before the hardening.
|
||||
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: false);
|
||||
|
||||
try
|
||||
{
|
||||
return RunAgent(args, logger, dataDirectory, logDirectory, consoleMode);
|
||||
|
||||
@@ -92,6 +92,13 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
if (recovered)
|
||||
data["remoteConfigRecovered"] = true;
|
||||
|
||||
// Which bootstrap installed this agent (installer and loader versions, MSI or
|
||||
// Intune loader, OOBE go-script), read from the scripts' own logs: makes the
|
||||
// fleet's share of old or directly assigned installers countable.
|
||||
BootstrapProvenanceReader
|
||||
.Read(Environment.ExpandEnvironmentVariables(SharedConstants.LogDirectory))
|
||||
.AddTo(data);
|
||||
|
||||
if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType))
|
||||
data["previousCrashException"] = previousExit.CrashExceptionType;
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<McpUserService> _mcpUserMock;
|
||||
private readonly Mock<TelegramNotificationService> _telegramMock;
|
||||
private readonly Mock<OpsEventService> _opsEventsMock;
|
||||
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
|
||||
@@ -71,7 +73,7 @@ public class AuthFunctionSideEffectTests
|
||||
adminRepo, cache, Mock.Of<ILogger<TenantAdminsService>>())
|
||||
{ CallBase = false };
|
||||
|
||||
var previewMock = new Mock<PreviewWhitelistService>(
|
||||
_previewMock = new Mock<PreviewWhitelistService>(
|
||||
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
@@ -87,7 +89,7 @@ public class AuthFunctionSideEffectTests
|
||||
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
|
||||
var mcpUserMock = new Mock<McpUserService>(
|
||||
_mcpUserMock = new Mock<McpUserService>(
|
||||
adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object,
|
||||
new TenantMemberRoleResolver(_tenantAdminsMock.Object, _tenantConfigMock.Object), _tenantConfigMock.Object)
|
||||
@@ -107,10 +109,10 @@ public class AuthFunctionSideEffectTests
|
||||
_tenantConfigMock.Object,
|
||||
_tenantAdminsMock.Object,
|
||||
_metricsRepoMock.Object,
|
||||
previewMock.Object,
|
||||
_previewMock.Object,
|
||||
_telegramMock.Object,
|
||||
_globalNotificationMock.Object,
|
||||
mcpUserMock.Object,
|
||||
_mcpUserMock.Object,
|
||||
_autoApproveEnqueuerMock.Object,
|
||||
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
|
||||
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
||||
@@ -169,6 +171,13 @@ public class AuthFunctionSideEffectTests
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn, string? tenantId = null)
|
||||
{
|
||||
var claims = new List<Claim> { new("preferred_username", callerUpn) };
|
||||
if (tenantId != null) claims.Add(new Claim("tid", tenantId));
|
||||
return BuildRequest(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")));
|
||||
}
|
||||
|
||||
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
@@ -177,10 +186,6 @@ public class AuthFunctionSideEffectTests
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var claims = new List<Claim> { new("preferred_username", callerUpn) };
|
||||
if (tenantId != null) claims.Add(new Claim("tid", tenantId));
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(
|
||||
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
|
||||
@@ -846,4 +851,86 @@ public class AuthFunctionSideEffectTests
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser end to end: an external identity never claims the tenant
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A validated token as the middleware hands it on: tid, oid and idp only under their mapped names. The
|
||||
/// member's idp names its own tenant, the guest's another one — the same principal shape otherwise.
|
||||
/// </summary>
|
||||
private static ClaimsPrincipal SignedIn(bool external) => new(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", TenantId),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", ObjectId),
|
||||
new Claim(ClaimTypes.Upn, Upn),
|
||||
new Claim("http://schemas.microsoft.com/identity/claims/identityprovider", external
|
||||
? "https://sts.windows.net/bbbbbbbb-1111-2222-3333-444444444444/"
|
||||
: $"https://sts.windows.net/{TenantId}/"),
|
||||
}, "test"));
|
||||
|
||||
private void ArrangeAuthMe(TenantConfiguration config, bool exists, bool approved)
|
||||
{
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((config, exists));
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(approved);
|
||||
_tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>());
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
|
||||
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen());
|
||||
}
|
||||
|
||||
private static async Task<string> ReadBodyAsync(HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
return await new StreamReader(response.Body).ReadToEndAsync();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task GetCurrentUser_FirstSignInOfANewTenant_OnlyAMemberOnboardsIt(bool external)
|
||||
{
|
||||
// The pointer read stays healthy on purpose: a throwing read would skip the seed for the guest too, and
|
||||
// the test would pass for the wrong reason.
|
||||
ArrangeAuthMe(DefaultConfig(), exists: false, approved: false);
|
||||
var (req, ctx) = BuildRequest(SignedIn(external));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
// Both wait at the activation gate; only the member's sign-in seeded the tenant and queued its activation.
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Contains("PendingActivation", await ReadBodyAsync(response));
|
||||
Assert.Equal(external ? 0 : 1, _store.Writes.Count);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), external ? Times.Never() : Times.Once());
|
||||
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), external ? Times.Never() : Times.Once());
|
||||
if (!external)
|
||||
Assert.Equal(Upn, _store.Row!.OnboardedBy);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task GetCurrentUser_ActivatedTenantWithoutAdmins_OnlyAMemberBecomesItsFirstAdmin(bool external)
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
ArrangeAuthMe(config, exists: true, approved: true);
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
var (req, ctx) = BuildRequest(SignedIn(external));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
_tenantAdminsMock.Verify(
|
||||
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
external ? Times.Never() : Times.Once());
|
||||
Assert.Contains(external ? "\"isTenantAdmin\":false" : "\"isTenantAdmin\":true", await ReadBodyAsync(response));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +414,43 @@ public class AuthFunctionTests
|
||||
Assert.Null((string?)body.role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoAdmin_ExternalIdentityFirstUser_IsNeverTheFirstAdmin()
|
||||
{
|
||||
// A guest signing in to an activated tenant that has no admins yet must not be made its Tenant Admin:
|
||||
// it is not a member of the tenant at all.
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: false,
|
||||
TenantId, Upn, DisplayName, ObjectId,
|
||||
isExternalIdentity: true);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.False((bool)body.isTenantAdmin);
|
||||
Assert.Null((string?)body.role);
|
||||
Assert.False((bool)body.canManageBootstrapTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalIdentity_KeepsARoleTheTenantGrantedExplicitly()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: AdminRole(), mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: true,
|
||||
TenantId, Upn, DisplayName, ObjectId,
|
||||
isExternalIdentity: true);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.True((bool)body.isTenantAdmin);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, (string)body.role);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Auto-Admin Logic
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
+37
-2
@@ -1,6 +1,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Xunit;
|
||||
@@ -34,12 +35,13 @@ public class AuthenticationMiddlewareTokenValidationTests
|
||||
string audience = ClientId,
|
||||
string tid = Tid,
|
||||
DateTime? notBefore = null,
|
||||
DateTime? expires = null)
|
||||
DateTime? expires = null,
|
||||
IEnumerable<Claim>? extraClaims = null)
|
||||
{
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer ?? V2Issuer,
|
||||
audience: audience,
|
||||
claims: new[] { new Claim("tid", tid) },
|
||||
claims: new[] { new Claim("tid", tid) }.Concat(extraClaims ?? Array.Empty<Claim>()),
|
||||
notBefore: notBefore ?? DateTime.UtcNow.AddMinutes(-5),
|
||||
expires: expires ?? DateTime.UtcNow.AddMinutes(30),
|
||||
signingCredentials: creds); // null creds → alg "none", unsigned
|
||||
@@ -136,6 +138,39 @@ public class AuthenticationMiddlewareTokenValidationTests
|
||||
Assert.ThrowsAny<SecurityTokenException>(() => Validate(forged));
|
||||
}
|
||||
|
||||
// ── External identity: the guest signals survive inbound claim mapping ──
|
||||
// A validated token carries tid and idp only under their mapped names, so a classifier reading the raw
|
||||
// names would call every member with an idp external, or every guest a member.
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://sts.windows.net/bbbbbbbb-1111-2222-3333-444444444444/", true)] // a guest of another Entra tenant
|
||||
[InlineData("live.com", true)] // a personal Microsoft account
|
||||
[InlineData("https://sts.windows.net/" + Tid + "/", false)] // a member: own tenant, v1 form under the v2 issuer
|
||||
public void IdpClaim_after_validation_decides_external_identity(string idp, bool external)
|
||||
{
|
||||
var principal = Validate(Mint(RsaCreds, extraClaims: new[] { new Claim("idp", idp) }));
|
||||
|
||||
Assert.Equal(external, principal.IsExternalIdentity());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", true)]
|
||||
[InlineData("0", false)]
|
||||
public void AcctClaim_after_validation_is_read_as_the_number_Entra_sends(string acct, bool external)
|
||||
{
|
||||
var principal = Validate(Mint(RsaCreds, extraClaims: new[] { new Claim("acct", acct, ClaimValueTypes.Integer32) }));
|
||||
|
||||
Assert.Equal(external, principal.IsExternalIdentity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MemberToken_without_guest_signals_is_not_external()
|
||||
{
|
||||
var principal = Validate(Mint(RsaCreds, extraClaims: new[] { new Claim("upn", "alice@contoso.example") }));
|
||||
|
||||
Assert.False(principal.IsExternalIdentity());
|
||||
}
|
||||
|
||||
// ── IsValidTenantId: pre-signature tid GUID gate ─────────────────────
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The publisher pin and the payload catalog name live in places that cannot share code: the C#
|
||||
/// constants (OOBE bootstrap template, agent self-updater), the bootstrap installer, the frozen
|
||||
/// loader and the release workflow. A drift would make one verifier refuse what the others
|
||||
/// accept, or the pipeline publish a catalog no device looks for, so each literal is pinned
|
||||
/// against its constant here.
|
||||
/// </summary>
|
||||
public class CodeSigningParityTests
|
||||
{
|
||||
private static readonly Regex ExpectedPublisherLine = new(
|
||||
"^\\$ExpectedPublisher = \"(?<pin>[^\"]+)\"\\r?$", RegexOptions.Multiline);
|
||||
|
||||
[Theory]
|
||||
[InlineData("Install-AutopilotMonitor.ps1")]
|
||||
[InlineData("Start-AutopilotMonitor.ps1")]
|
||||
public void Bootstrap_script_pin_matches_the_constant(string scriptName)
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", scriptName));
|
||||
|
||||
var matches = ExpectedPublisherLine.Matches(source);
|
||||
|
||||
Assert.Single(matches);
|
||||
Assert.Equal(Constants.CodeSigningPublisherPattern, matches[0].Groups["pin"].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pin_is_a_subject_pattern_not_a_thumbprint()
|
||||
{
|
||||
Assert.StartsWith("*O=", Constants.CodeSigningPublisherPattern);
|
||||
Assert.EndsWith("*", Constants.CodeSigningPublisherPattern);
|
||||
Assert.DoesNotMatch("^[0-9A-Fa-f]{40}$", Constants.CodeSigningPublisherPattern.Trim('*'));
|
||||
// The agent's matcher implements -like for * and ? only (PayloadCatalogVerifier).
|
||||
Assert.DoesNotContain("[", Constants.CodeSigningPublisherPattern);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_workflow_catalog_name_matches_the_constant()
|
||||
{
|
||||
var workflow = File.ReadAllText(Path.Combine(FindRepoRoot(), ".github", "workflows", "build-agent.yml"));
|
||||
|
||||
var match = Regex.Match(workflow, "^\\s+PAYLOAD_CATALOG: (?<name>\\S+)\\r?$", RegexOptions.Multiline);
|
||||
|
||||
Assert.True(match.Success, "build-agent.yml no longer declares PAYLOAD_CATALOG");
|
||||
Assert.Equal(Constants.AgentPayloadCatalogFileName, match.Groups["name"].Value);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ClaimsPrincipalExtensions.IsExternalIdentity"/>: a caller that is not a member of the token's tenant
|
||||
/// never claims it (no first-login seed, no first-user Tenant Admin, no activation promotion). Positive evidence
|
||||
/// only — every case expecting a member pins a false positive that would silently strand a genuine first sign-in.
|
||||
/// A validated token carries tid and idp only under their inbound-mapped names, so both forms are covered.
|
||||
/// </summary>
|
||||
public class ExternalIdentityTests
|
||||
{
|
||||
private const string Tid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string OtherTenant = "bbbbbbbb-1111-2222-3333-444444444444";
|
||||
private const string MappedTenantId = "http://schemas.microsoft.com/identity/claims/tenantid";
|
||||
private const string MappedIdp = "http://schemas.microsoft.com/identity/claims/identityprovider";
|
||||
|
||||
private static ClaimsPrincipal Principal(params Claim[] extra) => WithUpn("alice@contoso.example", extra);
|
||||
|
||||
private static ClaimsPrincipal WithUpn(string upn, params Claim[] extra)
|
||||
{
|
||||
var claims = new List<Claim> { new(MappedTenantId, Tid), new(ClaimTypes.Upn, upn) };
|
||||
claims.AddRange(extra);
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Member_WithoutAnySignal_IsNotExternal()
|
||||
=> Assert.False(Principal().IsExternalIdentity());
|
||||
|
||||
// ── acct: Entra's guest flag ─────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", true)]
|
||||
[InlineData("0", false)]
|
||||
public void AcctClaim_GuestFlagDecides(string acct, bool external)
|
||||
=> Assert.Equal(external, Principal(new Claim("acct", acct)).IsExternalIdentity());
|
||||
|
||||
// ── #EXT#: the resource-tenant UPN of an external account ───────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("upn")]
|
||||
[InlineData(ClaimTypes.Upn)]
|
||||
[InlineData("preferred_username")]
|
||||
[InlineData("unique_name")]
|
||||
[InlineData(ClaimTypes.Name)]
|
||||
public void ExternalUpnMarker_InAnyUserNameClaim_IsExternal(string claimType)
|
||||
{
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(MappedTenantId, Tid),
|
||||
new Claim(claimType, "alice_contoso.example#ext#@fabrikam.example"),
|
||||
}, "TestAuth"));
|
||||
|
||||
Assert.True(principal.IsExternalIdentity());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalUpnMarker_InTheDisplayName_IsNoEvidence()
|
||||
=> Assert.False(Principal(new Claim("name", "Alice #EXT# Example")).IsExternalIdentity());
|
||||
|
||||
[Fact]
|
||||
public void UnderscoreExtSuffix_IsNoEvidence_MemberNamingSchemesUseIt()
|
||||
=> Assert.False(WithUpn("m.muster_ext_@contoso.example").IsExternalIdentity());
|
||||
|
||||
// ── idp: where the account authenticated ────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://sts.windows.net/" + OtherTenant + "/", true)] // a guest of another Entra tenant
|
||||
[InlineData("https://login.microsoftonline.com/" + OtherTenant + "/v2.0", true)]
|
||||
[InlineData("https://sts.windows.net/9188040d-6c67-4c5b-b112-36a304b66dad/", true)] // the personal-account tenant
|
||||
[InlineData("live.com", true)] // a personal Microsoft account
|
||||
[InlineData("LIVE.COM", true)]
|
||||
[InlineData("mail", true)] // email one-time passcode
|
||||
[InlineData("google.com", true)]
|
||||
[InlineData("facebook.com", true)]
|
||||
[InlineData("https://sts.windows.net/" + Tid + "/", false)] // a member: its own tenant (v1 form, also under a v2 issuer)
|
||||
[InlineData("https://login.microsoftonline.com/" + Tid + "/v2.0", false)]
|
||||
[InlineData("http://adfs.contoso.example/adfs/services/trust", false)] // an unknown host: no evidence either way
|
||||
[InlineData("https://auth.idp.example/" + OtherTenant + "/as", false)] // a GUID segment on a foreign host: no evidence
|
||||
[InlineData("", false)]
|
||||
public void IdpClaim_NamesWhereTheAccountAuthenticated(string idp, bool external)
|
||||
=> Assert.Equal(external, Principal(new Claim("idp", idp)).IsExternalIdentity());
|
||||
|
||||
[Theory]
|
||||
[InlineData("idp")]
|
||||
[InlineData(MappedIdp)]
|
||||
public void IdpNamingAnotherTenant_IsExternal_InEitherClaimForm(string idpClaimType)
|
||||
=> Assert.True(Principal(new Claim(idpClaimType, $"https://sts.windows.net/{OtherTenant}/")).IsExternalIdentity());
|
||||
|
||||
[Theory]
|
||||
[InlineData("tid")]
|
||||
[InlineData(MappedTenantId)]
|
||||
public void IdpNamingTheOwnTenant_IsAMember_InEitherTenantClaimForm(string tenantClaimType)
|
||||
{
|
||||
// A validated token has no raw tid: comparing against it would call every member with an idp external.
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(tenantClaimType, Tid),
|
||||
new Claim(ClaimTypes.Upn, "alice@contoso.example"),
|
||||
new Claim(MappedIdp, $"https://sts.windows.net/{Tid}/"),
|
||||
}, "TestAuth"));
|
||||
|
||||
Assert.False(principal.IsExternalIdentity());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LogThrottle"/>: one line per key and interval, so a polled path (auth/me under the activation page,
|
||||
/// a delegated token on every request) shows up in App Insights without flooding it; bounded memory.
|
||||
/// </summary>
|
||||
public class LogThrottleTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 10, 13, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void FirstLine_Passes_ARepeatWithinTheInterval_DoesNot()
|
||||
{
|
||||
var throttle = new LogThrottle(TimeSpan.FromHours(1));
|
||||
|
||||
Assert.True(throttle.ShouldLog("k", T0));
|
||||
Assert.False(throttle.ShouldLog("k", T0.AddMinutes(59)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARepeat_PassesAgain_OnceTheIntervalHasElapsed()
|
||||
{
|
||||
var throttle = new LogThrottle(TimeSpan.FromHours(1));
|
||||
|
||||
Assert.True(throttle.ShouldLog("k", T0));
|
||||
Assert.True(throttle.ShouldLog("k", T0.AddHours(1)));
|
||||
Assert.False(throttle.ShouldLog("k", T0.AddHours(1).AddMinutes(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Keys_AreThrottledIndependently()
|
||||
{
|
||||
var throttle = new LogThrottle(TimeSpan.FromHours(1));
|
||||
|
||||
Assert.True(throttle.ShouldLog("a", T0));
|
||||
Assert.True(throttle.ShouldLog("b", T0));
|
||||
Assert.False(throttle.ShouldLog("a", T0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AtTheKeyCap_TheMemoryIsDropped_SoALineRepeatsEarlyInsteadOfGrowingWithoutBound()
|
||||
{
|
||||
var throttle = new LogThrottle(TimeSpan.FromHours(1), maxKeys: 2);
|
||||
|
||||
Assert.True(throttle.ShouldLog("a", T0));
|
||||
Assert.True(throttle.ShouldLog("b", T0));
|
||||
Assert.True(throttle.ShouldLog("c", T0));
|
||||
Assert.True(throttle.ShouldLog("a", T0));
|
||||
}
|
||||
}
|
||||
+25
-5
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// farewell mails. The route must admit roleless callers (first-touch signup) but only while the
|
||||
/// tenant has no member yet — afterwards a member role is required, so an ordinary employee with a
|
||||
/// valid JWT and no product role can no longer redirect or suppress the tenant's correspondence.
|
||||
/// The window is never an external identity's: it does not activate the tenant.
|
||||
/// </summary>
|
||||
public class PreviewNotificationEmailWriteGateTests
|
||||
{
|
||||
@@ -43,7 +44,7 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = AdminsWith(enabledMember: true);
|
||||
var ctx = new RequestContext { UserRole = role };
|
||||
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
|
||||
admins.Verify(a => a.GetTenantAdminsAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = AdminsWith(enabledMember: true);
|
||||
var ctx = new RequestContext { IsGlobalAdmin = true };
|
||||
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -62,7 +63,7 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = AdminsWith(enabledMember: false);
|
||||
var ctx = new RequestContext { UserRole = "Authenticated" };
|
||||
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -71,7 +72,7 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
var admins = AdminsWith(enabledMember: true);
|
||||
var ctx = new RequestContext { UserRole = "Authenticated" };
|
||||
|
||||
Assert.False(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
Assert.False(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -86,7 +87,26 @@ public class PreviewNotificationEmailWriteGateTests
|
||||
});
|
||||
var ctx = new RequestContext { UserRole = "Authenticated" };
|
||||
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task External_identity_never_writes_in_the_signup_window()
|
||||
{
|
||||
// The window exists for the activating user, and an external identity never activates a tenant.
|
||||
var admins = AdminsWith(enabledMember: false);
|
||||
var ctx = new RequestContext { UserRole = "Authenticated" };
|
||||
|
||||
Assert.False(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: true, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task External_identity_with_a_role_the_tenant_granted_may_write()
|
||||
{
|
||||
var admins = AdminsWith(enabledMember: true);
|
||||
var ctx = new RequestContext { UserRole = Constants.TenantRoles.Admin };
|
||||
|
||||
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: true, TenantId, admins.Object));
|
||||
}
|
||||
|
||||
private static Mock<TenantAdminsService> AdminsWith(bool enabledMember)
|
||||
|
||||
@@ -1,37 +1,29 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the static helpers <c>TenantApprovalService.PickRequesterUpn</c>
|
||||
/// and <c>TenantApprovalService.IsRealUserUpn</c>.
|
||||
/// Unit tests for <c>TenantApprovalService.IsPromotableUpn</c>, the shape guard on the stored onboarding UPN that
|
||||
/// activation promotes to TenantAdmin.
|
||||
/// <para>
|
||||
/// These guard the auto-promote-on-preview-approval path, which previously corrupted
|
||||
/// 10 tenants' TenantAdmins rows by writing the sentinel string
|
||||
/// <c>"System (Global Rate Limit Sync)"</c> as a TenantAdmin Upn. The fixes:
|
||||
/// <list type="number">
|
||||
/// <item>shape-based UPN validation (no enumeration of sentinel strings),</item>
|
||||
/// <item>prefer the immutable <see cref="TenantConfiguration.OnboardedBy"/> over the
|
||||
/// mutable <see cref="TenantConfiguration.UpdatedBy"/>.</item>
|
||||
/// </list>
|
||||
/// The auto-promote-on-approval path once corrupted 10 tenants' TenantAdmins rows by writing the sentinel string
|
||||
/// <c>"System (Global Rate Limit Sync)"</c> as a TenantAdmin Upn. The guard is therefore shape-based (no
|
||||
/// enumeration of sentinel strings), and it also refuses an external account's resource-tenant UPN. Which value is
|
||||
/// promoted at all (OnboardedBy only, never UpdatedBy) is pinned in <see cref="TenantApprovalServiceTests"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class PreviewWhitelistRequesterUpnTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// IsRealUserUpn — positive shape check, immune to new sentinel strings
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("user@contoso.com")]
|
||||
[InlineData("admin@tenant.onmicrosoft.com")]
|
||||
[InlineData("first.last@example.de")]
|
||||
[InlineData("cadm_user@contoso.onmicrosoft.com")]
|
||||
public void IsRealUserUpn_AcceptsRealUpnShapes(string upn)
|
||||
[InlineData("m.muster_ext_@contoso.example")] // a member naming scheme, not the external marker
|
||||
public void IsPromotableUpn_AcceptsRealUpnShapes(string upn)
|
||||
{
|
||||
Assert.True(TenantApprovalService.IsRealUserUpn(upn));
|
||||
Assert.True(TenantApprovalService.IsPromotableUpn(upn));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -41,12 +33,20 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("System (Global Rate Limit Sync)")]
|
||||
[InlineData("system (global rate limit sync)")]
|
||||
[InlineData("System (whatever-future-sentinel)")]
|
||||
public void IsRealUserUpn_RejectsAnySystemPrefixSentinel(string upn)
|
||||
public void IsPromotableUpn_RejectsAnySystemPrefixSentinel(string upn)
|
||||
{
|
||||
// The shape-based prefix check is exactly the defense against the corruption
|
||||
// pattern: any new "System (...)" sentinel introduced anywhere in the codebase
|
||||
// is rejected without anyone needing to remember to update this method.
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
|
||||
Assert.False(TenantApprovalService.IsPromotableUpn(upn));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("alice_fabrikam.example#EXT#@contoso.example")]
|
||||
[InlineData("alice_fabrikam.example#ext#@contoso.example")]
|
||||
public void IsPromotableUpn_RejectsAnExternalAccountsResourceTenantUpn(string upn)
|
||||
{
|
||||
Assert.False(TenantApprovalService.IsPromotableUpn(upn));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -55,80 +55,8 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData(" ")]
|
||||
[InlineData("nodomain")]
|
||||
[InlineData("just-a-string")]
|
||||
public void IsRealUserUpn_RejectsNullOrNonEmailShapes(string? upn)
|
||||
public void IsPromotableUpn_RejectsNullOrNonEmailShapes(string? upn)
|
||||
{
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PickRequesterUpn — prefer OnboardedBy, fall back to UpdatedBy
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PickRequesterUpn_PrefersOnboardedByEvenWhenUpdatedByIsClean()
|
||||
{
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
OnboardedBy = "onboarder@contoso.com",
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("onboarder@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickRequesterUpn_FallsBackToUpdatedByWhenOnboardedByNull()
|
||||
{
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
OnboardedBy = null,
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickRequesterUpn_FallsBackToUpdatedByWhenOnboardedByEmpty()
|
||||
{
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
OnboardedBy = "",
|
||||
UpdatedBy = "editor@contoso.com",
|
||||
};
|
||||
|
||||
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickRequesterUpn_ShieldsAgainstUpdatedByClobberWhenOnboardedBySet()
|
||||
{
|
||||
// Regression: this is the exact scenario that corrupted 10 tenants.
|
||||
// The global rate-limit sync clobbered UpdatedBy with the sentinel string;
|
||||
// OnboardedBy preserves the real requester so auto-promote still picks them.
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
OnboardedBy = "real.requester@contoso.com",
|
||||
UpdatedBy = "System (Global Rate Limit Sync)",
|
||||
};
|
||||
|
||||
Assert.Equal("real.requester@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
|
||||
Assert.True(TenantApprovalService.IsRealUserUpn(TenantApprovalService.PickRequesterUpn(cfg)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickRequesterUpn_LegacyTenant_NoOnboardedBy_SentinelInUpdatedBy_IsRejectedByGuard()
|
||||
{
|
||||
// Defense-in-depth: even without OnboardedBy (pre-fix tenants), the IsRealUserUpn
|
||||
// guard alone prevents the sentinel from leaking into TenantAdmins.
|
||||
var cfg = new TenantConfiguration
|
||||
{
|
||||
OnboardedBy = null,
|
||||
UpdatedBy = "System (Global Rate Limit Sync)",
|
||||
};
|
||||
|
||||
var picked = TenantApprovalService.PickRequesterUpn(cfg);
|
||||
Assert.Equal("System (Global Rate Limit Sync)", picked);
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(picked));
|
||||
Assert.False(TenantApprovalService.IsPromotableUpn(upn));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,10 @@ public class PushAlertProjectorTests
|
||||
Assert.Equal(8030, root.GetProperty("web_push").GetInt32());
|
||||
Assert.True(root.GetProperty("mutable").GetBoolean());
|
||||
var notification = root.GetProperty("notification");
|
||||
// Safari 18.4–26.1 reads "mutable" only inside "notification"; without it the worker never runs there.
|
||||
Assert.True(notification.GetProperty("mutable").GetBoolean());
|
||||
Assert.False(root.TryGetProperty("app_badge", out _));
|
||||
Assert.False(notification.TryGetProperty("app_badge", out _));
|
||||
Assert.Equal(Constants.PortalPushHistoryUrl("entry-9"), notification.GetProperty("navigate").GetString());
|
||||
var data = notification.GetProperty("data");
|
||||
Assert.Equal(1, data.GetProperty("v").GetInt32());
|
||||
|
||||
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Push;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
@@ -15,8 +16,8 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
/// Session watches ("notify my devices when this session ends"): the watch row lives in the
|
||||
/// SESSION's tenant, the devices it fires on are the watcher's OWN scopes (home tenant, plus the
|
||||
/// platform partition for a Global Admin), gated by an enabled Push channel and the role check at
|
||||
/// watch time and again at notify time. 48-hour lifetime, one-hour push TTL, consumed at the
|
||||
/// terminal status, fail-soft towards the ingest path.
|
||||
/// watch time and again at notify time. 48-hour lifetime (30 days on a sealed session), one-hour
|
||||
/// push TTL, consumed at the terminal status or the WhiteGlove seal, fail-soft towards the ingest path.
|
||||
/// </summary>
|
||||
public class PushSessionWatchServiceTests
|
||||
{
|
||||
@@ -40,7 +41,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.ChannelRequired, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -53,7 +54,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(Home, Admin.Upn, Admin.ObjectId, Admin.HomeTenantId)).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.NotEligible, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -68,7 +69,7 @@ public class PushSessionWatchServiceTests
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId, status);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.Conflict, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -80,7 +81,7 @@ public class PushSessionWatchServiceTests
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Operator, HomeTenantId);
|
||||
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
|
||||
Assert.Equal(PushOpError.Conflict, result.Error);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -93,7 +94,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
Assert.True(result.Ok);
|
||||
@@ -107,13 +108,29 @@ public class PushSessionWatchServiceTests
|
||||
Assert.Equal(row.ExpiresUtc, result.Value.ExpiresUtc!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Watch_on_a_sealed_session_lives_thirty_days_because_the_user_phase_starts_whenever_the_device_is_handed_out()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("d1", Admin, HomeTenantId);
|
||||
|
||||
var before = DateTime.UtcNow;
|
||||
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.Pending, Admin);
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
Assert.True(result.Ok);
|
||||
var row = h.Repo.Watches[(HomeTenantId, SessionId, Admin.ObjectId)];
|
||||
Assert.InRange(row.ExpiresUtc, before.AddDays(30), after.AddDays(30));
|
||||
Assert.Equal(row.ExpiresUtc, result.Value!.ExpiresUtc!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_admin_with_only_a_platform_device_watches_a_customer_session_from_the_platform_scope()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
// The row lives in the SESSION's tenant partition, not in the platform or the home partition.
|
||||
@@ -130,7 +147,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("h1", GlobalAdmin, HomeTenantId);
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal($"{Constants.Push.PlatformScope};{HomeTenantId}", h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes);
|
||||
@@ -144,7 +161,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false);
|
||||
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
|
||||
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal(Constants.Push.PlatformScope, h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes);
|
||||
@@ -263,7 +280,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("c2", CustomerAdmin, CustomerTenantId);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Equal(Endpoints(p1, p2), SentTo(h));
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -283,12 +300,40 @@ public class PushSessionWatchServiceTests
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Operator, HomeTenantId);
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: false, "DESKTOP-4711", null);
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Failed,"DESKTOP-4711", null);
|
||||
|
||||
Assert.Equal(Endpoints(d1, d2), SentTo(h));
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_at_the_seal_pushes_and_consumes_the_watch_so_the_user_phase_needs_a_new_one()
|
||||
{
|
||||
var h = new Harness();
|
||||
var device = h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Sealed, "DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Equal(device.Endpoint, Assert.Single(h.Http.Requests).RequestUri!.ToString());
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SessionWatchOutcome.Succeeded, "Enrollment finished", "DESKTOP-4711 (…7890) finished enrollment.", "success")]
|
||||
[InlineData(SessionWatchOutcome.Failed, "Enrollment failed", "DESKTOP-4711 (…7890) failed enrollment.", "error")]
|
||||
[InlineData(SessionWatchOutcome.Sealed, "Pre-provisioning finished", "DESKTOP-4711 (…7890) finished pre-provisioning.", "success")]
|
||||
public void Watch_message_names_the_outcome_with_the_device_and_the_masked_serial(SessionWatchOutcome outcome, string title, string body, string severity)
|
||||
{
|
||||
Assert.Equal((title, body, severity), PushSessionWatchService.WatchMessage(outcome, "DESKTOP-4711", "1234567890"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watch_message_without_a_device_name_or_serial_says_the_device()
|
||||
{
|
||||
Assert.Equal("The device finished pre-provisioning.", PushSessionWatchService.WatchMessage(SessionWatchOutcome.Sealed, null, null).Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notify_with_empty_owner_scopes_falls_back_to_the_session_tenant()
|
||||
{
|
||||
@@ -296,7 +341,7 @@ public class PushSessionWatchServiceTests
|
||||
var device = h.AddDevice("d1", Admin, HomeTenantId);
|
||||
h.AddWatch(HomeTenantId, SessionId, Admin, ownerScopes: string.Empty); // legacy row
|
||||
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: true, "DESKTOP-4711", null);
|
||||
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", null);
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal(device.Endpoint, request.RequestUri!.ToString());
|
||||
@@ -310,7 +355,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope, expiresUtc: DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: false, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Failed,"DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Empty(h.Repo.Watches);
|
||||
@@ -323,7 +368,7 @@ public class PushSessionWatchServiceTests
|
||||
repo.Setup(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId)).ReturnsAsync(new List<PushSessionWatch>());
|
||||
var h = new Harness(repo.Object);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", null);
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", null);
|
||||
|
||||
repo.Verify(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId), Times.Once);
|
||||
repo.VerifyNoOtherCalls();
|
||||
@@ -331,15 +376,16 @@ public class PushSessionWatchServiceTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Notify_push_carries_the_one_hour_ttl_and_high_urgency(bool success)
|
||||
[InlineData(SessionWatchOutcome.Succeeded)]
|
||||
[InlineData(SessionWatchOutcome.Failed)]
|
||||
[InlineData(SessionWatchOutcome.Sealed)]
|
||||
public async Task Notify_push_carries_the_one_hour_ttl_and_high_urgency(SessionWatchOutcome outcome)
|
||||
{
|
||||
var h = new Harness();
|
||||
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, outcome, "DESKTOP-4711", "1234567890");
|
||||
|
||||
var request = Assert.Single(h.Http.Requests);
|
||||
Assert.Equal("3600", Header(request, "TTL")); // WatchPushTtl: "Watch bis Session-Ende + 1 h"
|
||||
@@ -354,7 +400,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.IsEligibleAsync(NotificationScope.Platform, GlobalAdmin.Upn, GlobalAdmin.ObjectId, GlobalAdmin.HomeTenantId)).ReturnsAsync(false);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890");
|
||||
|
||||
Assert.Empty(h.Http.Requests);
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Repo.Devices[(Constants.Push.PlatformScope, "p1")].Status);
|
||||
@@ -369,7 +415,7 @@ public class PushSessionWatchServiceTests
|
||||
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
|
||||
h.Eligibility.Setup(e => e.IsOwnerFreshAsync(GlobalAdmin.HomeTenantId, GlobalAdmin.ObjectId, It.IsAny<DateTime>())).ReturnsAsync(false);
|
||||
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
|
||||
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890");
|
||||
|
||||
var row = h.Repo.Devices[(Constants.Push.PlatformScope, "p1")];
|
||||
Assert.Equal(Constants.Push.DeviceStatus.Paused, row.Status);
|
||||
@@ -387,7 +433,7 @@ public class PushSessionWatchServiceTests
|
||||
repo.Setup(r => r.GetWatchesForSessionAsync(It.IsAny<string>(), It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
var h = new Harness(repo.Object);
|
||||
|
||||
var exception = await Record.ExceptionAsync(() => h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890"));
|
||||
var exception = await Record.ExceptionAsync(() => h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890"));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Empty(h.Http.Requests);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The ingest gate that decides whether a batch fires the session watches: the terminal status on
|
||||
/// the enrollment alert's gate, else the first genuine WhiteGlove seal on the pre-provisioning
|
||||
/// alert's gate. A duplicate seal or a status that did not change fires nothing.
|
||||
/// </summary>
|
||||
public class SessionWatchOutcomeGateTests
|
||||
{
|
||||
private static EnrollmentEvent Event(string type) => new() { EventType = type };
|
||||
|
||||
[Fact]
|
||||
public void A_completion_that_changed_the_status_fires_succeeded()
|
||||
{
|
||||
var c = new EventClassification { CompletionEvent = Event("enrollment_complete") };
|
||||
|
||||
Assert.Equal(SessionWatchOutcome.Succeeded, EventIngestProcessor.WatchOutcomeFor(true, false, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_failure_with_a_failure_verdict_fires_failed()
|
||||
{
|
||||
var c = new EventClassification { FailureEvent = Event("enrollment_failed") };
|
||||
|
||||
Assert.Equal(SessionWatchOutcome.Failed, EventIngestProcessor.WatchOutcomeFor(true, false, c, "ESP timeout (0x800705b4)"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_agent_timeout_that_classified_without_a_failure_reason_fires_nothing()
|
||||
{
|
||||
var c = new EventClassification { FailureEvent = Event("enrollment_failed") };
|
||||
|
||||
Assert.Null(EventIngestProcessor.WatchOutcomeFor(true, false, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_terminal_event_without_a_status_change_fires_nothing()
|
||||
{
|
||||
var c = new EventClassification { CompletionEvent = Event("enrollment_complete") };
|
||||
|
||||
Assert.Null(EventIngestProcessor.WatchOutcomeFor(false, false, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_first_genuine_seal_fires_sealed()
|
||||
{
|
||||
var c = new EventClassification { WhiteGloveEvent = Event("whiteglove_complete") };
|
||||
|
||||
Assert.Equal(SessionWatchOutcome.Sealed, EventIngestProcessor.WatchOutcomeFor(false, true, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_duplicate_whiteglove_complete_fires_nothing()
|
||||
{
|
||||
// The agent emits whiteglove_complete twice; the second batch finds the session already
|
||||
// sealed, so whiteGloveStatusTransitioned stays false and the watch is not consumed again.
|
||||
var c = new EventClassification { WhiteGloveEvent = Event("whiteglove_complete") };
|
||||
|
||||
Assert.Null(EventIngestProcessor.WatchOutcomeFor(false, false, c, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_batch_without_terminal_or_seal_events_fires_nothing()
|
||||
{
|
||||
Assert.Null(EventIngestProcessor.WatchOutcomeFor(true, true, new EventClassification(), null));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public class TenantApprovalServiceTests
|
||||
|
||||
private readonly Mock<PreviewWhitelistService> _previewMock;
|
||||
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
|
||||
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
|
||||
private readonly Mock<IEmailService> _emailMock;
|
||||
private readonly List<OpsEventEntry> _opsEvents = new();
|
||||
private readonly TenantApprovalService _sut;
|
||||
@@ -53,12 +54,15 @@ public class TenantApprovalServiceTests
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
|
||||
// IsTenantAdminAsync is not virtual: it runs for real against the empty repository (no member yet).
|
||||
_tenantAdminsMock = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance);
|
||||
|
||||
_sut = new TenantApprovalService(
|
||||
NullLogger<TenantApprovalService>.Instance,
|
||||
_previewMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
|
||||
_tenantAdminsMock.Object,
|
||||
_emailMock.Object,
|
||||
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch));
|
||||
}
|
||||
@@ -95,8 +99,8 @@ public class TenantApprovalServiceTests
|
||||
{
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
||||
// Default config has no OnboardedBy/UpdatedBy UPN → auto-promote is skipped by the
|
||||
// shape guard; no notification email → no mail. Only the return value is under test.
|
||||
// Default config has no OnboardedBy → nothing to promote; no notification email → no
|
||||
// mail. Only the return value is under test.
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
|
||||
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
|
||||
|
||||
@@ -106,6 +110,55 @@ public class TenantApprovalServiceTests
|
||||
_previewMock.Verify(x => x.GetNotificationEmailAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
// --- Auto-promote on activation: only the onboarding member (OnboardedBy) becomes Tenant Admin.
|
||||
|
||||
private void ArrangeNewlyApproved(string? onboardedBy, string updatedBy = "System")
|
||||
{
|
||||
var config = ConfigWith(contactEmail: null);
|
||||
config.OnboardedBy = onboardedBy;
|
||||
config.UpdatedBy = updatedBy;
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
|
||||
}
|
||||
|
||||
private void VerifyNoPromotion() =>
|
||||
_tenantAdminsMock.Verify(
|
||||
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_PromotesTheOnboardingMember()
|
||||
{
|
||||
// A sync job clobbering UpdatedBy with its sentinel once leaked into TenantAdmins; OnboardedBy decides alone.
|
||||
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example", updatedBy: "System (Global Rate Limit Sync)");
|
||||
|
||||
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
_tenantAdminsMock.Verify(
|
||||
x => x.AddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_NeverPromotesAnExternalAccountsUpn()
|
||||
{
|
||||
ArrangeNewlyApproved(onboardedBy: "alice_fabrikam.example#EXT#@contoso.example");
|
||||
|
||||
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
VerifyNoPromotion();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_WithoutOnboardedBy_NeverFallsBackToTheLastWriter()
|
||||
{
|
||||
// UpdatedBy is whoever wrote the row last (any signed-in user can, e.g. by accepting the DPA), not the requester.
|
||||
ArrangeNewlyApproved(onboardedBy: null, updatedBy: "first.member@contoso.example");
|
||||
|
||||
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
VerifyNoPromotion();
|
||||
}
|
||||
|
||||
// --- TrySendWelcomeEmailAsync: the once-only send shared by the approval path and the
|
||||
// notification-email save path. Ordering contract under test: the sent-marker must be
|
||||
// consumed strictly AFTER the address check (a marker consumed with no address would
|
||||
|
||||
@@ -181,6 +181,6 @@ public class TenantAutoApproveHandlerTests
|
||||
{
|
||||
// The sentinel must never pass the promotable-user shape check — that is the
|
||||
// defense against writing a system string into the TenantAdmins table.
|
||||
Assert.False(TenantApprovalService.IsRealUserUpn(TenantAutoApproveHandler.AutoApprovedBy));
|
||||
Assert.False(TenantApprovalService.IsPromotableUpn(TenantAutoApproveHandler.AutoApprovedBy));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,75 @@ public static class ClaimsPrincipalExtensions
|
||||
public static bool IsCappedPrincipal(this ClaimsPrincipal principal)
|
||||
=> principal.IsApplicationPrincipal() || principal.IsClientCapped();
|
||||
|
||||
private const string ExternalUpnMarker = "#EXT#";
|
||||
private const string IdentityProviderClaimType = "http://schemas.microsoft.com/identity/claims/identityprovider";
|
||||
|
||||
// Raw and inbound-mapped names of the claims that carry a user name — never the display name ("name").
|
||||
private static readonly string[] UserNameClaimTypes =
|
||||
{ "upn", ClaimTypes.Upn, "preferred_username", "unique_name", ClaimTypes.Name };
|
||||
|
||||
// idp values that only an external account carries in a workforce tenant: a personal Microsoft account,
|
||||
// an email one-time passcode, the social sign-in providers.
|
||||
private static readonly HashSet<string> ExternalOnlyIdentityProviders =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "live.com", "mail", "google.com", "facebook.com" };
|
||||
|
||||
// The Entra token service: an idp URI on one of these hosts names the authenticating tenant first.
|
||||
private static readonly HashSet<string> EntraSignInHosts = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
new Uri(AutopilotMonitor.Shared.Constants.EntraLoginBaseUrl).Host, "sts.windows.net", "login.windows.net", "login.microsoft.com",
|
||||
};
|
||||
|
||||
private static readonly Guid PersonalMicrosoftAccountTenantId = new("9188040d-6c67-4c5b-b112-36a304b66dad");
|
||||
|
||||
/// <summary>
|
||||
/// True when the token shows that the caller is not a member of the token's tenant (<c>tid</c>): a B2B guest
|
||||
/// or another external identity. Such a caller keeps what the tenant grants it explicitly but never claims the
|
||||
/// tenant — no first-login seed, no first-user Tenant Admin, no activation promotion, no write in the roleless
|
||||
/// signup window. Positive evidence only; a token without any of these signals is a member's:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>acct = 1</c>, Entra's guest flag (an optional claim of the API app registration);</item>
|
||||
/// <item><c>#EXT#</c> in a user-name claim, the resource-tenant UPN Entra gives external accounts;</item>
|
||||
/// <item>an <c>idp</c> naming a personal Microsoft account, an external sign-in provider, or another tenant on
|
||||
/// an Entra sign-in host. A member's token carries no <c>idp</c> or one naming its own tenant. An <c>idp</c> on
|
||||
/// any other host is no evidence: a false positive would silently strand a genuine first sign-in.</item>
|
||||
/// </list>
|
||||
/// Inbound claim mapping renames <c>tid</c> and <c>idp</c>, so both are read in either form.
|
||||
/// </summary>
|
||||
public static bool IsExternalIdentity(this ClaimsPrincipal principal)
|
||||
{
|
||||
if (string.Equals(principal.FindFirst("acct")?.Value?.Trim(), "1", StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
if (UserNameClaimTypes.Any(type => IsExternalUpn(principal.FindFirst(type)?.Value)))
|
||||
return true;
|
||||
|
||||
var identityProvider = principal.FindFirst("idp")?.Value ?? principal.FindFirst(IdentityProviderClaimType)?.Value;
|
||||
return IsForeignIdentityProvider(identityProvider, principal.GetTenantId());
|
||||
}
|
||||
|
||||
/// <summary>True when a user name carries the <c>#EXT#</c> marker of an external account's resource-tenant UPN.</summary>
|
||||
internal static bool IsExternalUpn(string? userName)
|
||||
=> userName != null && userName.Contains(ExternalUpnMarker, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// True when an <c>idp</c> claim value shows that the account authenticated outside <paramref name="tenantId"/>.
|
||||
/// </summary>
|
||||
internal static bool IsForeignIdentityProvider(string? identityProvider, string? tenantId)
|
||||
{
|
||||
var idp = identityProvider?.Trim();
|
||||
if (string.IsNullOrEmpty(idp))
|
||||
return false;
|
||||
if (ExternalOnlyIdentityProviders.Contains(idp))
|
||||
return true;
|
||||
if (!Uri.TryCreate(idp, UriKind.Absolute, out var uri) || !EntraSignInHosts.Contains(uri.Host))
|
||||
return false;
|
||||
if (!Guid.TryParse(uri.AbsolutePath.Trim('/').Split('/')[0], out var authenticatingTenant))
|
||||
return false;
|
||||
return authenticatingTenant == PersonalMicrosoftAccountTenantId
|
||||
|| !Guid.TryParse(tenantId, out var tokenTenant)
|
||||
|| authenticatingTenant != tokenTenant;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The caller's rate-limit budget class (<see cref="Security.ThrottleSurface"/>), from signed claims
|
||||
/// only: <c>appidacr</c> (v1.0) / <c>azpacr</c> (v2.0) say how the CLIENT authenticated — <c>0</c> is a
|
||||
|
||||
@@ -22,6 +22,8 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
/// </summary>
|
||||
public class AuthFunction
|
||||
{
|
||||
private static readonly LogThrottle ExternalIdentityLog = new(TimeSpan.FromHours(1));
|
||||
|
||||
private readonly ILogger<AuthFunction> _logger;
|
||||
private readonly GlobalAdminService _globalAdminService;
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
@@ -162,8 +164,13 @@ public class AuthFunction
|
||||
// excluded, so a read-only MSP login does not phantom-onboard its home tenant (false signup, default
|
||||
// config row). A delegated user who legitimately participates in their own tenant (member/global)
|
||||
// still onboards normally; the handlers self-gate when the config already exists.
|
||||
// An external identity (a guest: this tid is not its home) never participates, whatever it was
|
||||
// granted: its home domain would become this tenant's DomainName, which resolves cross-tenant grants,
|
||||
// and its UPN the OnboardedBy that activation promotes to Tenant Admin.
|
||||
var isDelegated = delegatedTenantIds.Count > 0;
|
||||
var isHomeTenantParticipant = !isDelegated || isGlobalAdmin || isGlobalReader || memberRole != null;
|
||||
var isExternalIdentity = principal.IsExternalIdentity();
|
||||
var isHomeTenantParticipant = !isExternalIdentity
|
||||
&& (!isDelegated || isGlobalAdmin || isGlobalReader || memberRole != null);
|
||||
// Which app registration minted this login's token (dual app-reg window) — drives the
|
||||
// onboarding homing decision and the per-tenant last-seen provenance below.
|
||||
var tokenAudience = principal.GetAudience();
|
||||
@@ -176,6 +183,14 @@ public class AuthFunction
|
||||
await HandleAutoReEnableAsync(tenantConfig, tenantId);
|
||||
await HandleAuthClientIdTrackingAsync(tenantConfig, tenantId, tokenAudience);
|
||||
}
|
||||
else if (isExternalIdentity && string.IsNullOrEmpty(tenantConfig.DomainName)
|
||||
&& ExternalIdentityLog.ShouldLog($"{tenantId}|{objectId}"))
|
||||
{
|
||||
// The activation page polls auth/me, so this is throttled per caller.
|
||||
_logger.LogWarning(
|
||||
"External identity {Upn} signed in to tenant {TenantId}, which no member has onboarded yet; it does not onboard it",
|
||||
upn, tenantId);
|
||||
}
|
||||
|
||||
// --- Pure decision logic (tested by AuthFunctionTests) ---
|
||||
var decision = BuildAuthResult(
|
||||
@@ -186,7 +201,8 @@ public class AuthFunction
|
||||
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary",
|
||||
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
|
||||
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc,
|
||||
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false);
|
||||
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false,
|
||||
isExternalIdentity: isExternalIdentity);
|
||||
|
||||
if (!decision.IsSuccess)
|
||||
{
|
||||
@@ -723,7 +739,8 @@ public class AuthFunction
|
||||
string homedApp = "primary",
|
||||
DateTime? whatsNewSeenPlatformUtc = null,
|
||||
DateTime? whatsNewSeenAgentUtc = null,
|
||||
bool mcpClientRegistrationEnabled = false)
|
||||
bool mcpClientRegistrationEnabled = false,
|
||||
bool isExternalIdentity = false)
|
||||
{
|
||||
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
|
||||
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
|
||||
@@ -773,9 +790,10 @@ public class AuthFunction
|
||||
// to manage OTHER tenants and bypassed the preview gate; auto-admining their own (possibly
|
||||
// non-customer) home tenant would convert a read-only delegated assignment into write authority over
|
||||
// it. A delegated user who legitimately administers their own tenant has a TenantAdmins row (memberRole
|
||||
// != null), so this only suppresses the silent grant, never an existing right.
|
||||
// != null), so this only suppresses the silent grant, never an existing right. The same holds for an
|
||||
// external identity (a guest), which is not a member of this tenant at all.
|
||||
bool isTenantAdmin = memberRole?.Role == Constants.TenantRoles.Admin;
|
||||
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated;
|
||||
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated && !isExternalIdentity;
|
||||
if (needsAutoAdmin)
|
||||
{
|
||||
isTenantAdmin = true;
|
||||
|
||||
@@ -51,10 +51,11 @@ public class SessionWatchFunctions
|
||||
if (!PushFunctionSupport.IsPlausibleId(sessionId))
|
||||
return await req.NotFoundAsync("Session not found.");
|
||||
var (tenantId, caller) = await ScopeAsync(req, sessionId);
|
||||
if (await _sessionRepo.GetSessionAsync(tenantId, sessionId) == null)
|
||||
var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
|
||||
if (session == null)
|
||||
return await req.NotFoundAsync("Session not found.");
|
||||
|
||||
var result = await _watches.WatchAsync(tenantId, sessionId, caller);
|
||||
var result = await _watches.WatchAsync(tenantId, sessionId, session.Status, caller);
|
||||
return result.Ok
|
||||
? await req.OkAsync(result.Value!)
|
||||
: await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message);
|
||||
|
||||
@@ -169,16 +169,18 @@ public class PreviewWhitelistFunction
|
||||
var principal = context.GetUser();
|
||||
var tenantId = principal?.GetTenantId();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
if (principal == null || string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return await req.BadRequestAsync("Could not determine tenant");
|
||||
}
|
||||
|
||||
if (!await MayWriteNotificationEmailAsync(req.GetRequestContext(), tenantId, _tenantAdminsService))
|
||||
var externalIdentity = principal.IsExternalIdentity();
|
||||
if (!await MayWriteNotificationEmailAsync(req.GetRequestContext(), externalIdentity, tenantId, _tenantAdminsService))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Preview notification email write denied for roleless caller {Upn} — tenant {TenantId} already has members",
|
||||
principal?.GetUserPrincipalName() ?? "unknown", tenantId);
|
||||
"Preview notification email write denied for roleless caller {Upn} in tenant {TenantId}: {Reason}",
|
||||
principal.GetUserPrincipalName() ?? "unknown", tenantId,
|
||||
externalIdentity ? "external identity" : "the tenant already has members");
|
||||
return await req.ForbiddenAsync("A tenant member role is required to change the notification email");
|
||||
}
|
||||
|
||||
@@ -218,15 +220,18 @@ public class PreviewWhitelistFunction
|
||||
/// offboarding farewell, operator contact). A member role (or platform scope) always may write
|
||||
/// it. A roleless caller may write it ONLY while the tenant has no enabled member at all — the
|
||||
/// first-touch signup window, where the activating user is still roleless because the requester
|
||||
/// is auto-promoted to Admin only on approval. Once any member exists, that window is closed for
|
||||
/// good: an ordinary employee with a valid JWT but no product role must not be able to redirect
|
||||
/// or suppress the tenant's correspondence.
|
||||
/// is auto-promoted to Admin only on approval. The window is never an external identity's: it
|
||||
/// neither onboards a tenant nor is promoted on activation. Once any member exists, that window is
|
||||
/// closed for good: an ordinary employee with a valid JWT but no product role must not be able to
|
||||
/// redirect or suppress the tenant's correspondence.
|
||||
/// </summary>
|
||||
internal static async Task<bool> MayWriteNotificationEmailAsync(
|
||||
RequestContext ctx, string tenantId, TenantAdminsService tenantAdminsService)
|
||||
RequestContext ctx, bool externalIdentity, string tenantId, TenantAdminsService tenantAdminsService)
|
||||
{
|
||||
if (ctx.IsTenantMemberOrGlobalAdmin())
|
||||
return true;
|
||||
if (externalIdentity)
|
||||
return false;
|
||||
|
||||
var members = await tenantAdminsService.GetTenantAdminsAsync(tenantId);
|
||||
return !members.Any(m => m.IsEnabled);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Lets one log line per key through per interval, for request paths that would otherwise repeat it on every
|
||||
/// call (worker traces reach App Insights at Warning+ only, so a polled or hot path must not flood them).
|
||||
/// Process-local and bounded: past <c>maxKeys</c> the memory is dropped, which at worst repeats a line early.
|
||||
/// </summary>
|
||||
public sealed class LogThrottle
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DateTime> _lastLogged = new();
|
||||
private readonly TimeSpan _interval;
|
||||
private readonly int _maxKeys;
|
||||
|
||||
public LogThrottle(TimeSpan interval, int maxKeys = 1000)
|
||||
{
|
||||
_interval = interval;
|
||||
_maxKeys = maxKeys;
|
||||
}
|
||||
|
||||
/// <summary>True when the line for <paramref name="key"/> may be written now; records that it was.</summary>
|
||||
public bool ShouldLog(string key) => ShouldLog(key, DateTime.UtcNow);
|
||||
|
||||
internal bool ShouldLog(string key, DateTime nowUtc)
|
||||
{
|
||||
if (_lastLogged.TryGetValue(key, out var last) && nowUtc - last < _interval)
|
||||
return false;
|
||||
if (_lastLogged.Count >= _maxKeys)
|
||||
_lastLogged.Clear();
|
||||
_lastLogged[key] = nowUtc;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
@@ -21,12 +20,9 @@ namespace AutopilotMonitor.Functions.Middleware;
|
||||
/// </summary>
|
||||
public class ClientAppBindingMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
private static readonly TimeSpan LogInterval = TimeSpan.FromHours(1);
|
||||
private const int MaxLogKeys = 1000;
|
||||
|
||||
// Worker traces reach App Insights at Warning+ only; the request dimensions carry the per-request
|
||||
// measurement, this line makes a new (tenant, client, outcome) visible without flooding.
|
||||
private static readonly ConcurrentDictionary<string, DateTime> LastLogged = new();
|
||||
private static readonly LogThrottle OutcomeLog = new(TimeSpan.FromHours(1));
|
||||
|
||||
private readonly ILogger<ClientAppBindingMiddleware> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
@@ -118,13 +114,8 @@ public class ClientAppBindingMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
private void LogOncePerInterval(string? tenantId, string? clientAppId, string outcome, bool enforced)
|
||||
{
|
||||
var key = $"{tenantId}|{clientAppId}|{outcome}|{enforced}";
|
||||
var now = DateTime.UtcNow;
|
||||
if (LastLogged.TryGetValue(key, out var last) && now - last < LogInterval)
|
||||
if (!OutcomeLog.ShouldLog($"{tenantId}|{clientAppId}|{outcome}|{enforced}"))
|
||||
return;
|
||||
if (LastLogged.Count >= MaxLogKeys)
|
||||
LastLogged.Clear();
|
||||
LastLogged[key] = now;
|
||||
|
||||
_logger.LogWarning(
|
||||
"[ClientAppBinding] Delegated token from client app {ClientAppId} (tid={TenantId}): {Outcome}, enforced={Enforced}",
|
||||
|
||||
@@ -274,7 +274,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
|
||||
// roleless during first-touch signup, so the tier must admit non-members — but the address is
|
||||
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
|
||||
// resolved role: roleless callers may only write while the tenant has no member yet.
|
||||
// resolved role: roleless callers may only write while the tenant has no member yet, and never
|
||||
// as an external identity, which does not activate a tenant.
|
||||
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUserWithRole),
|
||||
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
|
||||
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Functions.Services.Activation
|
||||
public class TenantAutoApproveHandler
|
||||
{
|
||||
/// <summary>ApprovedBy sentinel. Deliberately without '@' so the shape-based
|
||||
/// <see cref="TenantApprovalService.IsRealUserUpn"/> guard can never treat it as a
|
||||
/// <see cref="TenantApprovalService.IsPromotableUpn"/> guard can never treat it as a
|
||||
/// promotable user.</summary>
|
||||
public const string AutoApprovedBy = "System (auto-approve)";
|
||||
|
||||
|
||||
+25
-4
@@ -2,6 +2,7 @@ using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Functions.Services.Push;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -27,11 +28,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var failureVerdictApplies = c.FailureEvent != null && failureReason != null;
|
||||
var reachedTerminalStatus = statusTransitioned && (c.CompletionEvent != null || failureVerdictApplies);
|
||||
|
||||
// Session watches ("notify me when this session ends") fire on the same terminal gate
|
||||
// as the enrollment alert, independent of any channel configuration. Fail-soft inside.
|
||||
if (reachedTerminalStatus && _pushWatchNotifier != null)
|
||||
// Session watches ("notify me when this session ends") fire on the same gates as the
|
||||
// enrollment and pre-provisioning alerts, independent of any channel configuration.
|
||||
// Fail-soft inside.
|
||||
var watchOutcome = WatchOutcomeFor(statusTransitioned, whiteGloveStatusTransitioned, c, failureReason);
|
||||
if (watchOutcome != null && _pushWatchNotifier != null)
|
||||
{
|
||||
await _pushWatchNotifier.NotifyAsync(request.TenantId, request.SessionId, c.CompletionEvent != null,
|
||||
await _pushWatchNotifier.NotifyAsync(request.TenantId, request.SessionId, watchOutcome.Value,
|
||||
updatedSession?.DeviceName, updatedSession?.SerialNumber).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -126,6 +129,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a session watch fires on for this batch, or null: the terminal status on the
|
||||
/// enrollment alert's gate, else the first genuine WhiteGlove seal on the pre-provisioning
|
||||
/// alert's gate (never the duplicate <c>whiteglove_complete</c>, which leaves
|
||||
/// <paramref name="whiteGloveStatusTransitioned"/> false). The seal consumes the watch.
|
||||
/// </summary>
|
||||
internal static SessionWatchOutcome? WatchOutcomeFor(
|
||||
bool statusTransitioned, bool whiteGloveStatusTransitioned, EventClassification c, string? failureReason)
|
||||
{
|
||||
if (statusTransitioned && c.CompletionEvent != null)
|
||||
return SessionWatchOutcome.Succeeded;
|
||||
if (statusTransitioned && c.FailureEvent != null && failureReason != null)
|
||||
return SessionWatchOutcome.Failed;
|
||||
if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null)
|
||||
return SessionWatchOutcome.Sealed;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands an alert to the notification-dispatch queue. The send used to be fire-and-forget
|
||||
/// after the HTTP response; the host does not count such work at shutdown, so a Flex
|
||||
|
||||
@@ -160,7 +160,10 @@ public static class PushAlertProjector
|
||||
/// <summary>
|
||||
/// Declarative Web Push JSON (<c>web_push: 8030</c>): Safari ≥ 18.4 shows it without the
|
||||
/// service worker when the worker fails; <c>mutable</c> wakes the worker so it can store the
|
||||
/// history entry; every other browser hands the same object to the worker's push event.
|
||||
/// history entry and set the badge; every other browser hands the same object to the worker's
|
||||
/// push event. <c>mutable</c> stands twice: Safari 18.4–26.1 reads it only inside
|
||||
/// <c>notification</c>, 26.2+ at the top level. No <c>app_badge</c>: the count is the
|
||||
/// device's own unread history, which only the worker knows.
|
||||
/// Over budget, facts are dropped from the end, then the body is shortened.
|
||||
/// </summary>
|
||||
public static byte[] ToJsonBytes(PushPayload payload)
|
||||
@@ -190,6 +193,7 @@ public static class PushAlertProjector
|
||||
w.WriteNumber("web_push", 8030);
|
||||
w.WriteBoolean("mutable", true);
|
||||
w.WriteStartObject("notification");
|
||||
w.WriteBoolean("mutable", true);
|
||||
w.WriteString("title", p.Title);
|
||||
w.WriteString("body", body);
|
||||
w.WriteString("navigate", Constants.PortalPushHistoryUrl(p.Id));
|
||||
|
||||
@@ -6,24 +6,38 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services.Push;
|
||||
|
||||
/// <summary>Terminal-status hook for the ingest path: fires the pushes of everyone watching the session.</summary>
|
||||
/// <summary>What a watch fires on: the session's terminal status, or the WhiteGlove seal as an intermediate goal.</summary>
|
||||
public enum SessionWatchOutcome
|
||||
{
|
||||
Succeeded,
|
||||
Failed,
|
||||
/// <summary>Pre-provisioning (WhiteGlove part 1) sealed the device; the user phase follows later.</summary>
|
||||
Sealed,
|
||||
}
|
||||
|
||||
/// <summary>Ingest-path hook: fires the pushes of everyone watching the session.</summary>
|
||||
public interface IPushSessionWatchNotifier
|
||||
{
|
||||
Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber);
|
||||
Task NotifyAsync(string tenantId, string sessionId, SessionWatchOutcome outcome, string? deviceName, string? serialNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Notify my devices when this session ends" (plan use case 3): one watch per (session, person),
|
||||
/// consumed at the terminal status. The watch row lives in the SESSION's tenant (tenant isolation
|
||||
/// of the session), the devices it fires on are the watcher's OWN: their home-tenant scope and,
|
||||
/// for a Global Admin, the platform scope — so an operator can watch a customer's session from
|
||||
/// their own paired phone. Admin and Operator only (F14), only in a scope with an enabled Push
|
||||
/// channel (K4), and the fan-out runs through the same role and freshness filter as any alert.
|
||||
/// Watches expire after 48 hours (the agent's emergency break) and die with the session's tenant.
|
||||
/// consumed at the terminal status — or at the WhiteGlove seal, the moment the technician at the
|
||||
/// bench is waiting for; watching the user phase takes a second click. The watch row lives in the
|
||||
/// SESSION's tenant (tenant isolation of the session), the devices it fires on are the watcher's
|
||||
/// OWN: their home-tenant scope and, for a Global Admin, the platform scope — so an operator can
|
||||
/// watch a customer's session from their own paired phone. Admin and Operator only (F14), only in
|
||||
/// a scope with an enabled Push channel (K4), and the fan-out runs through the same role and
|
||||
/// freshness filter as any alert. Watches expire after 48 hours (the agent's emergency break), a
|
||||
/// watch on a sealed session after 30 days (the user phase starts whenever the device is handed
|
||||
/// out), and die with the session's tenant.
|
||||
/// </summary>
|
||||
public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
{
|
||||
public static readonly TimeSpan WatchLifetime = TimeSpan.FromHours(48);
|
||||
/// <summary>A sealed (Pending) session resumes only when the device reaches its user; matches the owner inactivity window.</summary>
|
||||
public static readonly TimeSpan WatchLifetimeSealed = TimeSpan.FromDays(30);
|
||||
/// <summary>The terminal push waits for an offline phone longer than a system message: plan "Watch bis Session-Ende + 1 h".</summary>
|
||||
public static readonly TimeSpan WatchPushTtl = TimeSpan.FromHours(1);
|
||||
private const char ScopeSeparator = ';';
|
||||
@@ -56,7 +70,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public async Task<PushOpResult<SessionWatchResponse>> WatchAsync(string tenantId, string sessionId, PushCaller caller)
|
||||
public async Task<PushOpResult<SessionWatchResponse>> WatchAsync(string tenantId, string sessionId, SessionStatus sessionStatus, PushCaller caller)
|
||||
{
|
||||
var anyChannel = false;
|
||||
var anyEligible = false;
|
||||
@@ -95,7 +109,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
OwnerUpn = caller.Upn,
|
||||
OwnerScopes = string.Join(ScopeSeparator, usable),
|
||||
CreatedUtc = now,
|
||||
ExpiresUtc = now + WatchLifetime,
|
||||
ExpiresUtc = now + (sessionStatus == SessionStatus.Pending ? WatchLifetimeSealed : WatchLifetime),
|
||||
};
|
||||
await _repo.UpsertWatchAsync(watch).ConfigureAwait(false);
|
||||
return PushOpResult<SessionWatchResponse>.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc, Available = true });
|
||||
@@ -137,7 +151,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
}
|
||||
|
||||
/// <summary>Fail-soft: a watch push must never cost the ingest path anything.</summary>
|
||||
public async Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber)
|
||||
public async Task NotifyAsync(string tenantId, string sessionId, SessionWatchOutcome outcome, string? deviceName, string? serialNumber)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -146,10 +160,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var device = PushAlertProjector.Clean(deviceName, 48);
|
||||
var serial = PushAlertProjector.MaskSerial(serialNumber);
|
||||
var body = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty)
|
||||
+ (success ? " finished enrollment." : " failed enrollment.");
|
||||
var (title, body, severity) = WatchMessage(outcome, deviceName, serialNumber);
|
||||
|
||||
foreach (var watch in watches)
|
||||
{
|
||||
@@ -161,9 +172,8 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
|
||||
foreach (var scope in ScopesOf(watch, tenantId))
|
||||
{
|
||||
var payload = PushAlertProjector.SystemMessage("session_watch",
|
||||
success ? "Enrollment finished" : "Enrollment failed",
|
||||
body, success ? "success" : "error", scope, now, Constants.PortalSessionUrl(sessionId));
|
||||
var payload = PushAlertProjector.SystemMessage("session_watch", title, body, severity, scope, now,
|
||||
Constants.PortalSessionUrl(sessionId));
|
||||
await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -176,6 +186,20 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The watch push's lock-screen text: device name and masked serial only (D-328).</summary>
|
||||
internal static (string Title, string Body, string Severity) WatchMessage(SessionWatchOutcome outcome, string? deviceName, string? serialNumber)
|
||||
{
|
||||
var device = PushAlertProjector.Clean(deviceName, 48);
|
||||
var serial = PushAlertProjector.MaskSerial(serialNumber);
|
||||
var subject = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty);
|
||||
return outcome switch
|
||||
{
|
||||
SessionWatchOutcome.Succeeded => ("Enrollment finished", subject + " finished enrollment.", "success"),
|
||||
SessionWatchOutcome.Sealed => ("Pre-provisioning finished", subject + " finished pre-provisioning.", "success"),
|
||||
_ => ("Enrollment failed", subject + " failed enrollment.", "error"),
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<NotificationScope> ScopesOf(PushSessionWatch watch, string sessionTenantId)
|
||||
{
|
||||
var keys = (watch.OwnerScopes ?? string.Empty).Split(ScopeSeparator, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
@@ -54,22 +54,23 @@ public class TenantApprovalService
|
||||
|
||||
_logger.LogInformation("Tenant activated: {TenantId} by {ApprovedBy}", tenantId, approvedBy);
|
||||
|
||||
// Auto-promote the tenant requester (first user who triggered tenant config creation)
|
||||
// as TenantAdmin if they are not already one.
|
||||
// Auto-promote the onboarding member (OnboardedBy, written once by the first member login and
|
||||
// never by an external identity) as TenantAdmin if they are not already one.
|
||||
// This ensures whoever signed up doesn't need manual admin assignment after activation.
|
||||
// Never UpdatedBy: that is the last writer, not the requester, and any signed-in user sets it.
|
||||
try
|
||||
{
|
||||
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
|
||||
var requesterUpn = PickRequesterUpn(tenantConfig);
|
||||
var requesterUpn = tenantConfig.OnboardedBy;
|
||||
|
||||
// Positive UPN-shape validation: real Azure AD UPNs always contain '@', while
|
||||
// every system-written sentinel ("System", "System (auto-re-enable)",
|
||||
// "System (Global Rate Limit Sync)", …) does not. An equality list against
|
||||
// known sentinels previously missed a third sentinel and corrupted 10 tenants'
|
||||
// TenantAdmins rows — keep this check shape-based, not enumeration-based.
|
||||
if (IsRealUserUpn(requesterUpn))
|
||||
if (IsPromotableUpn(requesterUpn))
|
||||
{
|
||||
// IsRealUserUpn returned true → requesterUpn is non-null, non-empty, contains '@'
|
||||
// IsPromotableUpn returned true → requesterUpn is non-null, non-empty, contains '@'
|
||||
var validUpn = requesterUpn!;
|
||||
var isAlreadyAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, validUpn);
|
||||
if (!isAlreadyAdmin)
|
||||
@@ -89,8 +90,8 @@ public class TenantApprovalService
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"No valid tenant requester UPN found in TenantConfiguration for tenant {TenantId} (OnboardedBy: '{OnboardedBy}', UpdatedBy: '{UpdatedBy}') — skipping auto-promote",
|
||||
tenantId, tenantConfig.OnboardedBy ?? "<null>", tenantConfig.UpdatedBy ?? "<null>");
|
||||
"No promotable onboarding UPN in TenantConfiguration for tenant {TenantId} (OnboardedBy: '{OnboardedBy}') — skipping auto-promote",
|
||||
tenantId, tenantConfig.OnboardedBy ?? "<null>");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -213,32 +214,15 @@ public class TenantApprovalService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the UPN of the user to auto-promote as TenantAdmin on activation.
|
||||
/// Prefers <see cref="TenantConfiguration.OnboardedBy"/> (immutable, set once on first
|
||||
/// user login) over <see cref="TenantConfiguration.UpdatedBy"/> (mutable, can be
|
||||
/// clobbered by background jobs such as the global rate-limit sync). Fall back to
|
||||
/// <see cref="TenantConfiguration.UpdatedBy"/> for tenants onboarded before
|
||||
/// <see cref="TenantConfiguration.OnboardedBy"/> existed — callers still guard the
|
||||
/// result with <see cref="IsRealUserUpn"/> so a sentinel value cannot leak into
|
||||
/// the TenantAdmins table.
|
||||
/// True when a stored onboarding UPN may be promoted to TenantAdmin: shaped like a real user
|
||||
/// principal name (contains '@', no "System" sentinel prefix) and not an external account's
|
||||
/// resource-tenant UPN (<c>#EXT#</c>), which no member login writes.
|
||||
/// </summary>
|
||||
internal static string? PickRequesterUpn(TenantConfiguration config)
|
||||
{
|
||||
if (config == null) return null;
|
||||
return !string.IsNullOrWhiteSpace(config.OnboardedBy)
|
||||
? config.OnboardedBy
|
||||
: config.UpdatedBy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the value looks like a real Azure AD user principal name
|
||||
/// (contains '@' and does not start with the "System" sentinel prefix
|
||||
/// used by background jobs that touch <see cref="TenantConfiguration.UpdatedBy"/>).
|
||||
/// </summary>
|
||||
internal static bool IsRealUserUpn(string? upn)
|
||||
internal static bool IsPromotableUpn(string? upn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upn)) return false;
|
||||
if (upn.StartsWith("System", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (ClaimsPrincipalExtensions.IsExternalUpn(upn)) return false;
|
||||
return upn.Contains('@');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Caller-sized input must not decide what one call costs: one MCP process serves every tenant, so a
|
||||
* single request may cost no more than a small multiple of its event volume. Pins the compiled concept
|
||||
* matcher against the per-keyword rule it replaced (seeded differential test), its keyword-count-free
|
||||
* per-event cost, the cooperative scoring loop, the keyword and error-code caps, and the schema bounds
|
||||
* on query, keywords, section and continuation.
|
||||
*
|
||||
* Pure unit tests: fetch is stubbed and the registered tool handlers are invoked directly.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { registerTools } from '../tools.js';
|
||||
import { buildQuery, runWithCaller } from '../client.js';
|
||||
import {
|
||||
compileConcepts,
|
||||
dedupeSynonymConcepts,
|
||||
extractErrorCodeNeedles,
|
||||
KEYWORD_SYNONYM_GROUPS,
|
||||
matchConcepts,
|
||||
MAX_ERROR_CODE_NEEDLES,
|
||||
MAX_SCORED_KEYWORDS,
|
||||
MAX_SEARCH_KEYWORD_CHARS,
|
||||
MAX_SEARCH_KEYWORDS,
|
||||
MAX_SEARCH_QUERY_CHARS,
|
||||
SCORED_FIELDS,
|
||||
scoreEvent,
|
||||
scoreEvents,
|
||||
} from '../tools/search.js';
|
||||
import type { SearchProvider } from '../search-provider.js';
|
||||
|
||||
// ── Deterministic inputs ────────────────────────────────────────────────
|
||||
|
||||
/** mulberry32: seeded, so every run checks the same cases. */
|
||||
function prng(seed: number): () => number {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
type Ev = { eventType?: string; severity?: string; source?: string; message?: string; data?: Record<string, unknown> };
|
||||
|
||||
const VOCAB = ['install', 'app', 'download', 'policy', 'device', 'enrollment', 'phase', 'script', 'detection', 'exit',
|
||||
'code', 'started', 'completed', 'certificate', 'network', 'agent', 'registry', 'value', 'result', 'status'];
|
||||
|
||||
/** Production-sized events: a ~60-character message and ~600 characters of data, every field filled. */
|
||||
function makeEvents(n: number, seed = 1): Ev[] {
|
||||
const rnd = prng(seed);
|
||||
const words = (len: number): string => {
|
||||
let s = '';
|
||||
while (s.length < len) s += (s ? ' ' : '') + VOCAB[Math.floor(rnd() * VOCAB.length)];
|
||||
return s;
|
||||
};
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
eventType: `${VOCAB[i % VOCAB.length]}_${VOCAB[(i * 7) % VOCAB.length]}`,
|
||||
severity: ['Info', 'Warning', 'Error'][i % 3],
|
||||
source: 'ImeLogTracker',
|
||||
message: words(60),
|
||||
data: Object.fromEntries(Array.from({ length: 8 }, (_, k) => [`${VOCAB[k]}Key${k}`, words(60)])),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Distinct keywords that occur in no event. */
|
||||
const absent = (n: number): string[] => Array.from({ length: n }, (_, i) => `zq${i.toString(36).padStart(5, '0')}`);
|
||||
|
||||
// ── The per-keyword rule the compiled matcher replaced, written out as the oracle ──
|
||||
|
||||
const GROUP_OF = new Map(KEYWORD_SYNONYM_GROUPS.flatMap((group) => group.map((term) => [term, group] as const)));
|
||||
const WEIGHT = { eventType: 3, message: 2, source: 1.5, severity: 1, data: 0.5 } as const;
|
||||
|
||||
function oracleWordPrefix(text: string, term: string): boolean {
|
||||
if (term.length < 4) return false;
|
||||
return text.split(/[\s_\-.:,/]+/).some((word) => {
|
||||
if (word.length < 4) return false;
|
||||
const p = Math.min(word.length, term.length, 6);
|
||||
return word.slice(0, p) === term.slice(0, p);
|
||||
});
|
||||
}
|
||||
|
||||
/** Keyword as substring or word prefix; its synonyms at word level only. */
|
||||
function oracleFieldMatch(text: string, keyword: string): boolean {
|
||||
if (text.includes(keyword) || oracleWordPrefix(text, keyword)) return true;
|
||||
return (GROUP_OF.get(keyword) ?? []).some((term) => term !== keyword && oracleWordPrefix(text, term));
|
||||
}
|
||||
|
||||
/** Per concept: the heaviest field it matches, or null. */
|
||||
function oracleBestFields(e: Ev, concepts: readonly string[]): Array<string | null> {
|
||||
const texts: Array<[keyof typeof WEIGHT, string]> = [
|
||||
['eventType', (e.eventType ?? '').toLowerCase()],
|
||||
['message', (e.message ?? '').toLowerCase()],
|
||||
['source', (e.source ?? '').toLowerCase()],
|
||||
['severity', (e.severity ?? '').toLowerCase()],
|
||||
['data', e.data ? JSON.stringify(e.data).toLowerCase() : ''],
|
||||
];
|
||||
return concepts.map((keyword) => {
|
||||
let best: keyof typeof WEIGHT | null = null;
|
||||
for (const [name, text] of texts) {
|
||||
if (text && oracleFieldMatch(text, keyword) && (best === null || WEIGHT[name] > WEIGHT[best])) best = name;
|
||||
}
|
||||
return best;
|
||||
});
|
||||
}
|
||||
|
||||
// Stems with prefix collisions at every length around the 4-6 character comparison window, synonyms
|
||||
// and the words they hide in ("in[stall]", "c[hang]e"), short keywords below the prefix minimum.
|
||||
const STEMS = ['abcd', 'abcde', 'abcdef', 'abcdefg', 'abcdx', 'abc', 'ab', 'abce', 'abcdeq', 'xyzw', 'xyz', 'install',
|
||||
'installer', 'installation', 'setup', 'setups', 'reboot', 'rebooting', 'restarts', 'reset', 'resetting', 'stuck',
|
||||
'stuckness', 'hang', 'hangs', 'change', 'hung', 'timeout', 'timedout', 'stall', 'stalled', 'download', 'downloads',
|
||||
'transfer', 'hello', 'hel', 'whfb', 'failed', 'failure', 'error', 'esp', 'msi', 'auth', 'authentication', 'app', 'do', 'id'];
|
||||
const ch = String.fromCharCode;
|
||||
// The old split's delimiters, Unicode whitespace it also splits on, characters it does not split on,
|
||||
// and nothing at all (two stems glued together).
|
||||
const SEPARATORS = [' ', '_', '-', '.', ':', ',', '/', ch(9), ch(10), ' ', '__', ' - ', ch(0xa0), ch(0x2028), ch(0x3000),
|
||||
ch(0xfeff), '(', ')', '"', '=', ''];
|
||||
|
||||
function randomText(rnd: () => number, maxWords: number): string {
|
||||
const n = Math.floor(rnd() * maxWords);
|
||||
let s = '';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const stem = STEMS[Math.floor(rnd() * STEMS.length)];
|
||||
s += (i ? SEPARATORS[Math.floor(rnd() * SEPARATORS.length)] : '') + (rnd() < 0.2 ? stem.toUpperCase() : stem);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function randomKeywords(rnd: () => number): string[] {
|
||||
const pick = (): string => STEMS[Math.floor(rnd() * STEMS.length)];
|
||||
const out = new Set<string>();
|
||||
const n = 1 + Math.floor(rnd() * 8);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const r = rnd();
|
||||
const keyword = r < 0.6 ? pick()
|
||||
: r < 0.75 ? pick().slice(0, 2 + Math.floor(rnd() * 6))
|
||||
: r < 0.9 ? `${pick()} ${pick()}`
|
||||
: pick() + ['ing', 's', 'ed', 'x'][Math.floor(rnd() * 4)];
|
||||
if (keyword) out.add(keyword);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
// ── Registered tools ────────────────────────────────────────────────────
|
||||
|
||||
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{ content: Array<{ text: string }> }>;
|
||||
type ParseableSchema = { safeParse(value: unknown): { success: boolean } };
|
||||
type RegisteredTool = { handler: ToolHandler; inputSchema: ParseableSchema & { shape: Record<string, ParseableSchema> } };
|
||||
|
||||
const stubDocs: SearchProvider = { name: 'stub', semanticCapable: true, size: 1, index: async () => {}, search: async () => [] };
|
||||
|
||||
function registeredTools(): Record<string, RegisteredTool> {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, { vector: stubDocs, sections: ['trust'] }, true, true, false);
|
||||
return (server as unknown as { _registeredTools: Record<string, RegisteredTool> })._registeredTools;
|
||||
}
|
||||
|
||||
const SESSION = 'e259c121-1234-4abc-9def-0123456789ab';
|
||||
const GA = { token: 'ga', isGlobalAdmin: true };
|
||||
const extra = { signal: new AbortController().signal };
|
||||
|
||||
function stubSessionEvents(events: Ev[]): void {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
||||
const body = { success: true, sessionId: SESSION, count: events.length, events };
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response;
|
||||
}));
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('compiled concept matcher', () => {
|
||||
it('lists the scored fields strongest first, so the first field a concept matches is its strongest', () => {
|
||||
expect(SCORED_FIELDS.map((f) => f.name)).toEqual(['eventType', 'message', 'source', 'severity', 'data']);
|
||||
const weights = SCORED_FIELDS.map((f) => f.weight);
|
||||
expect(weights).toEqual([...weights].sort((a, b) => b - a));
|
||||
});
|
||||
|
||||
it('matches every concept in the same strongest field as the per-keyword rule it replaced', () => {
|
||||
const rnd = prng(7);
|
||||
const names = SCORED_FIELDS.map((f) => f.name);
|
||||
let compared = 0;
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
const keywords = randomKeywords(rnd);
|
||||
const e: Ev = {
|
||||
eventType: randomText(rnd, 4),
|
||||
message: randomText(rnd, 12),
|
||||
source: randomText(rnd, 3),
|
||||
severity: ['Info', 'Error', 'Warning', '', 'Debug'][Math.floor(rnd() * 5)],
|
||||
data: rnd() < 0.7 ? { a: randomText(rnd, 10), b: randomText(rnd, 6), n: Math.floor(rnd() * 100) } : undefined,
|
||||
};
|
||||
const matcher = compileConcepts(keywords);
|
||||
expect(matcher.concepts).toEqual(dedupeSynonymConcepts(keywords));
|
||||
const got = matchConcepts(e, matcher).map((f) => (f < 0 ? null : names[f]));
|
||||
expect(got, JSON.stringify({ keywords, e })).toEqual(oracleBestFields(e, matcher.concepts));
|
||||
compared += got.length;
|
||||
}
|
||||
expect(compared).toBeGreaterThan(20_000);
|
||||
});
|
||||
|
||||
it('splits each field once per event, however many keywords the query carries', () => {
|
||||
const events = makeEvents(200);
|
||||
const splitsFor = (keywords: string[]): number => {
|
||||
const matcher = compileConcepts(keywords);
|
||||
const split = vi.spyOn(String.prototype, 'split');
|
||||
try {
|
||||
for (const e of events) scoreEvent(e, keywords, false, undefined, undefined, matcher);
|
||||
return split.mock.calls.length;
|
||||
} finally {
|
||||
split.mockRestore();
|
||||
}
|
||||
};
|
||||
const one = splitsFor(absent(1));
|
||||
expect(one).toBe(events.length * SCORED_FIELDS.length);
|
||||
expect(splitsFor(absent(MAX_SCORED_KEYWORDS))).toBe(one);
|
||||
});
|
||||
|
||||
it('scores a maximal request in well under a second', async () => {
|
||||
const events = makeEvents(3000);
|
||||
const t0 = performance.now();
|
||||
await scoreEvents(events, absent(MAX_SCORED_KEYWORDS), false, new Map(), new Map(), 0.05);
|
||||
expect(performance.now() - t0).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoreEvents', () => {
|
||||
it('returns exactly what the per-event scorer returns', async () => {
|
||||
const events = makeEvents(700, 3);
|
||||
const keywords = ['install', 'certificate', 'stuck', 'agent'];
|
||||
const scored = await scoreEvents(events, keywords, true, new Map(), new Map(), 0.05);
|
||||
const expected = events.flatMap((e, index) => {
|
||||
const r = scoreEvent(e, keywords, true, new Map(), new Map());
|
||||
return r && r.score >= 0.05 ? [{ ...r, index }] : [];
|
||||
});
|
||||
expect(scored.length).toBeGreaterThan(0);
|
||||
expect(scored.map(({ index, score, matchedKeywords, bestFields }) => ({ index, score, matchedKeywords, bestFields })))
|
||||
.toEqual(expected);
|
||||
});
|
||||
|
||||
it('yields to the event loop while it scores, so other requests keep running', async () => {
|
||||
let otherRequestRan = false;
|
||||
setImmediate(() => { otherRequestRan = true; });
|
||||
await scoreEvents(makeEvents(600), absent(1), false, new Map(), new Map(), 0);
|
||||
expect(otherRequestRan).toBe(true);
|
||||
});
|
||||
|
||||
it('stops once the caller has gone', async () => {
|
||||
const gone = new AbortController();
|
||||
gone.abort();
|
||||
await expect(runWithCaller({ token: 't', isGlobalAdmin: false, signal: gone.signal },
|
||||
() => scoreEvents(makeEvents(600), absent(1), false, new Map(), new Map(), 0))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_events keyword cap', () => {
|
||||
const tools = registeredTools();
|
||||
const run = async (args: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
||||
stubSessionEvents(makeEvents(5));
|
||||
const res = await runWithCaller(GA, () => tools.search_events.handler(
|
||||
{ sessionId: SESSION, depth: 'fast', guaranteedTopRanked: 3, ...args }, extra));
|
||||
return JSON.parse(res.content[0].text) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
it(`scores the first ${MAX_SCORED_KEYWORDS} keywords, caller keywords first, and reports the rest`, async () => {
|
||||
const query = Array.from({ length: 40 }, (_, i) => `queryword${i}`).join(' ');
|
||||
const body = await run({ query, keywords: ['callerword'] });
|
||||
const used = body.keywordsUsed as string[];
|
||||
expect(used).toHaveLength(MAX_SCORED_KEYWORDS);
|
||||
expect(used[0]).toBe('callerword');
|
||||
expect(body.keywordsDropped).toBe(41 - MAX_SCORED_KEYWORDS);
|
||||
expect(body.keywordNote).toContain(String(MAX_SCORED_KEYWORDS));
|
||||
});
|
||||
|
||||
it('reports nothing dropped for an ordinary query', async () => {
|
||||
const body = await run({ query: 'app install failed with certificate error' });
|
||||
expect(body.keywordsDropped).toBeUndefined();
|
||||
expect(body.keywordNote).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error-code needles', () => {
|
||||
it(`takes at most ${MAX_ERROR_CODE_NEEDLES} codes, 0x-prefixed ones first`, () => {
|
||||
const bare = Array.from({ length: 12 }, (_, i) => `abcd${(i + 16).toString(16)}`).join(' ');
|
||||
const needles = extractErrorCodeNeedles(`${bare} 0x80070002 0x87D1041C`);
|
||||
expect(needles).toHaveLength(MAX_ERROR_CODE_NEEDLES);
|
||||
expect(needles.slice(0, 2)).toEqual(['80070002', '87d1041c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema bounds', () => {
|
||||
const tools = registeredTools();
|
||||
const accepts = (tool: string, args: Record<string, unknown>): boolean => tools[tool].inputSchema.safeParse(args).success;
|
||||
|
||||
it('bounds the query of all three search tools', () => {
|
||||
for (const tool of ['search_events', 'search_knowledge', 'search_docs']) {
|
||||
expect(accepts(tool, { query: 'x'.repeat(MAX_SEARCH_QUERY_CHARS) }), tool).toBe(true);
|
||||
expect(accepts(tool, { query: 'x'.repeat(MAX_SEARCH_QUERY_CHARS + 1) }), tool).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds the caller keywords of search_events in number and length', () => {
|
||||
const atLimit = Array.from({ length: MAX_SEARCH_KEYWORDS }, () => 'k'.repeat(MAX_SEARCH_KEYWORD_CHARS));
|
||||
expect(accepts('search_events', { query: 'q', keywords: atLimit })).toBe(true);
|
||||
expect(accepts('search_events', { query: 'q', keywords: [...atLimit, 'k'] })).toBe(false);
|
||||
expect(accepts('search_events', { query: 'q', keywords: ['k'.repeat(MAX_SEARCH_KEYWORD_CHARS + 1)] })).toBe(false);
|
||||
});
|
||||
|
||||
it('bounds the search_docs section', () => {
|
||||
expect(accepts('search_docs', { query: 'q', section: 'trust' })).toBe(true);
|
||||
expect(accepts('search_docs', { query: 'q', section: 's'.repeat(101) })).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a real nextLink on every paginated tool and refuses a forged one', () => {
|
||||
const nextLink = `/api/search/sessions?pageSize=50&tenantId=11111111-1111-4111-8111-111111111111&continuation=${'A'.repeat(800)}`;
|
||||
const forged = `/api/search/sessions?${'fields=&'.repeat(36_000)}`;
|
||||
const paginated = Object.entries(tools).filter(([, tool]) => 'continuation' in tool.inputSchema.shape);
|
||||
expect(paginated.map(([name]) => name)).toEqual(expect.arrayContaining(['search_sessions', 'get_session_events', 'get_audit_logs']));
|
||||
for (const [name, tool] of paginated) {
|
||||
expect(tool.inputSchema.shape.continuation.safeParse(nextLink).success, name).toBe(true);
|
||||
expect(tool.inputSchema.shape.continuation.safeParse(forged).success, name).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildQuery', () => {
|
||||
it('builds the same query string as before, skipping empty values', () => {
|
||||
expect(buildQuery({ a: '1', b: 'x y', c: undefined, d: '', e: 0, f: null })).toBe('?a=1&b=x+y&e=0');
|
||||
expect(buildQuery({})).toBe('');
|
||||
});
|
||||
|
||||
it('stays linear for a caller-sized key set', () => {
|
||||
// Far more keys than a request body carries, so the complexity class is unmistakable:
|
||||
// measured ~0.1 s linear against ~9 s for a per-key search of the whole list.
|
||||
const many = Object.fromEntries(Array.from({ length: 64_000 }, (_, i) => [`prop.os_info.k${i}`, 'v']));
|
||||
const t0 = performance.now();
|
||||
const q = buildQuery(many);
|
||||
expect(performance.now() - t0).toBeLessThan(2000);
|
||||
expect(q.split('&')).toHaveLength(64_000);
|
||||
});
|
||||
});
|
||||
@@ -523,8 +523,10 @@ async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise
|
||||
|
||||
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
|
||||
const p = new URLSearchParams();
|
||||
// append, not set: object keys are unique, and set() searches the whole list on every call —
|
||||
// quadratic for a caller-sized key set such as search_sessions' deviceProperties.
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') p.set(k, String(v));
|
||||
if (v != null && v !== '') p.append(k, String(v));
|
||||
}
|
||||
const s = p.toString();
|
||||
return s ? `?${s}` : '';
|
||||
|
||||
@@ -4961,13 +4961,13 @@ export interface TenantConfigPatchOutcomeResponse {
|
||||
export interface TenantConfiguration {
|
||||
/** Tenant ID (PartitionKey in Table Storage) */
|
||||
tenantId: string;
|
||||
/** Domain name extracted from the first user's UPN Used for display purposes (e.g., contoso.com) */
|
||||
/** Domain of the onboarding member's UPN (e.g. contoso.com), seeded once by the first member login and never from an external identity. Tenant identity, not only display: transactional mails render it, and cross-tenant grants resolve a never-signed-in UPN's home tenant through it. */
|
||||
domainName: string;
|
||||
/** When the configuration was last updated */
|
||||
lastUpdated: string;
|
||||
/** Updated by (user email or system) */
|
||||
updatedBy: string;
|
||||
/** UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard. */
|
||||
/** UPN of the member whose first login onboarded this tenant. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten; an external identity (guest) never sets it. Activation promotes this UPN, and only this one, to Tenant Admin, so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel or a last writer into the TenantAdmins table. Null on rows that pre-date the field: such a tenant gets no promotion on activation. */
|
||||
onboardedBy?: string | null;
|
||||
/** Version of the data processing agreement this tenant accepted — CurrentDpaVersion at that moment. Written once: with the onboarding when the first sign-in came through the get-started tick, otherwise when the portal's acceptance dialog is confirmed (DpaAcceptancePending). Never client-writable. Null on tenants onboarded before the field existed. */
|
||||
dpaVersion?: string | null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { apiFetch, buildQuery, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, getCallerUpnDomain, getDelegatedTenantIds, getHomeTenantId, pageSizeForCall, pickGlobalOrTenantPath, scanWithTimeoutFallback, jsonBody } from '../client.js';
|
||||
import { withToolTelemetry } from '../telemetry.js';
|
||||
import { getResourceContent, withEventTypeNote, RESOURCE_NAMES } from '../resource-catalog.js';
|
||||
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
|
||||
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { API_BASE_URL } from '../config.js';
|
||||
import { collectDeploymentState } from '../deployment-state.js';
|
||||
@@ -430,7 +430,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'),
|
||||
pageSize: z.coerce.number().int().min(1).max(2000).optional().default(50)
|
||||
.describe(pageSizeDescription(50, 2000)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -512,7 +512,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
.describe(daysDescription(30, 365)),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional().default(50)
|
||||
.describe(pageSizeDescription(50)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -770,7 +770,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
.describe(daysDescription(7, 365, 'Applies to the stats and the session list.')),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).optional()
|
||||
.describe(pageSizeDescription(FLEET_OVERVIEW_PAGE_SIZE, 200)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -835,7 +835,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'included; default: all safe fields. Applied server-side; unknown/secret keys are ignored.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(100, 1000, 'Ignored in filter mode (query/tenantId).')),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION + ' Ignored in filter mode.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -939,7 +939,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
entityId: z.string().optional().describe('Exact-match filter on the affected entity id (e.g. a tenantId, deviceId, or report id).'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -995,7 +995,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the window.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -1082,7 +1082,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -1125,7 +1125,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
.describe('One effective status. "published" lists the approved submissions whose rule is live in the global catalog.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -1400,7 +1400,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — exclusive upper bound on the last-updated time.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -1496,7 +1496,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
.describe('Keep-list of column names; PartitionKey + RowKey always kept. Columns: PartitionKey, RowKey, Timestamp (storage write time), OccurredUtc (event time), EventId, SessionId, TenantId, EventType, Severity (int), Source, Phase (int), Message, Sequence, DataJson (raw string), ReceivedAt, SentAt, OriginalTimestamp, TimestampClamped, CausedByTransitionStepIndex, CausedBySignalOrdinal. Default: all but DataJson on an unfiltered read; the full row on a filtered read (eventType/severity/source).'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Index rows walked per call. When the projection includes DataJson the ceiling is ' + RAW_EVENTS_PAYLOAD_PAGE_MAX + ' (default ' + RAW_EVENTS_PAYLOAD_PAGE_DEFAULT + '); a larger value is refused.')),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -1579,7 +1579,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
fields: z.string().optional().describe('Keep-list of literal column names (case-insensitive, PascalCase, e.g. "Status,OsEdition,ImeAgentVersion,GeoCity"); PartitionKey + RowKey are always kept. Omit for the full raw row.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -1654,7 +1654,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'client-side. PartitionKey and RowKey are always kept.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -2195,7 +2195,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
...inventoryScopeShape,
|
||||
pageSize: z.coerce.number().int().min(1).max(500).optional().default(100)
|
||||
.describe(pageSizeDescription(100, 500)),
|
||||
continuation: z.string().optional()
|
||||
continuation: ContinuationSchema.optional()
|
||||
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { z } from 'zod';
|
||||
import { apiFetch, buildQuery, enforceDelegatedTenant, followNextLink, pickGlobalOrTenantPath } from '../client.js';
|
||||
import { apiFetch, buildQuery, enforceDelegatedTenant, followNextLink, getCallerSignal, pickGlobalOrTenantPath } from '../client.js';
|
||||
import { withToolTelemetry, logSearchZeroHit } from '../telemetry.js';
|
||||
import type { SearchProvider, DocsSearchBundle } from '../search-provider.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription } from './shared.js';
|
||||
@@ -160,6 +160,26 @@ const DEPTH_PRESETS = {
|
||||
deep: { budget: DEEP_BUDGET, defaultTopK: 20, defaultMinScore: 0.05 },
|
||||
} as const;
|
||||
|
||||
// ── Input bounds ────────────────────────────────────────────────────────
|
||||
//
|
||||
// One process serves every tenant, and every fetched event is scored against every query keyword,
|
||||
// so the keyword count multiplies the work of a call. These bounds keep one request's cost a small
|
||||
// multiple of its event volume; real queries stay far below them.
|
||||
|
||||
/** Longest `query` the three search tools accept. */
|
||||
export const MAX_SEARCH_QUERY_CHARS = 1000;
|
||||
/** Most caller-supplied `keywords` per search_events call. */
|
||||
export const MAX_SEARCH_KEYWORDS = 20;
|
||||
/** Longest caller-supplied keyword. */
|
||||
export const MAX_SEARCH_KEYWORD_CHARS = 100;
|
||||
/** Distinct keywords one search scores (caller keywords first, then the query's words); the rest is reported as dropped. */
|
||||
export const MAX_SCORED_KEYWORDS = 32;
|
||||
const KEYWORDS_DROPPED_NOTE =
|
||||
`Only the first ${MAX_SCORED_KEYWORDS} distinct keywords were scored (your keywords first, then words from the ` +
|
||||
'query); shorten the query to choose which ones count.';
|
||||
/** Longest search_docs `section` (a top-level folder of the docs bundle). */
|
||||
const MAX_DOCS_SECTION_CHARS = 100;
|
||||
|
||||
/**
|
||||
* EventSeverity enum (C# `EnrollmentEvent.EventSeverity`) → name. The raw endpoint stores
|
||||
* Severity as the underlying Int32; the enriched endpoints emit the name string. We map back
|
||||
@@ -561,6 +581,9 @@ export function resolveQueryKeywords(query: string, callerKeywords?: string[]):
|
||||
|
||||
// ── Error-code fallback ─────────────────────────────────────────────────
|
||||
|
||||
/** Error codes one query contributes to the literal fallback. */
|
||||
export const MAX_ERROR_CODE_NEEDLES = 10;
|
||||
|
||||
/**
|
||||
* Pull opaque error-code tokens (HRESULT / Win32 / NTSTATUS hex) out of a query. These embed
|
||||
* poorly — to a sentence-transformer "0x87D1041C" is near-random noise — so a semantic search can
|
||||
@@ -572,15 +595,22 @@ export function resolveQueryKeywords(query: string, callerKeywords?: string[]):
|
||||
* Bare (un-prefixed) hex must contain at least one a-f letter and be 6-8 chars, so plain decimal
|
||||
* numbers — dates, counts, build numbers — never trigger the fallback. `0x`-prefixed tokens are
|
||||
* always codes, so those accept 4-8 digits (covers short Win32 HRESULTs like 0x801c03ed).
|
||||
*
|
||||
* At most MAX_ERROR_CODE_NEEDLES distinct codes, `0x`-prefixed ones first: every needle is scanned
|
||||
* against every document of the corpus.
|
||||
*/
|
||||
export function extractErrorCodeNeedles(query: string): string[] {
|
||||
const needles = new Set<string>();
|
||||
// 0x-prefixed hex (0x80070002, 0x87D1041C). The `x` is not in [0-9a-f], so the bare-hex pass
|
||||
// below can never re-match the same digits — no double counting.
|
||||
for (const m of query.matchAll(/\b0x([0-9a-f]{4,8})\b/gi)) needles.add(m[1].toLowerCase());
|
||||
for (const m of query.matchAll(/\b0x([0-9a-f]{4,8})\b/gi)) {
|
||||
if (needles.size >= MAX_ERROR_CODE_NEEDLES) return [...needles];
|
||||
needles.add(m[1].toLowerCase());
|
||||
}
|
||||
// Bare HRESULT-shaped hex: 6-8 chars with at least one a-f letter (87D1041C) — the letter
|
||||
// requirement excludes pure decimals that aren't error codes.
|
||||
for (const m of query.matchAll(/\b([0-9a-f]{6,8})\b/gi)) {
|
||||
if (needles.size >= MAX_ERROR_CODE_NEEDLES) break;
|
||||
if (/[a-f]/i.test(m[1])) needles.add(m[1].toLowerCase());
|
||||
}
|
||||
return [...needles];
|
||||
@@ -613,6 +643,8 @@ export function queryHasProblemIntent(keywords: string[]): boolean {
|
||||
}
|
||||
|
||||
const MIN_PREFIX_LEN = 4;
|
||||
/** A prefix comparison looks at no more than this many leading characters. */
|
||||
const MAX_PREFIX_LEN = MIN_PREFIX_LEN + 2;
|
||||
|
||||
/** Split text into word tokens for prefix matching (same delimiters everywhere). */
|
||||
function splitWords(text: string): string[] {
|
||||
@@ -624,7 +656,7 @@ function sharesWordPrefix(words: string[], keyword: string): boolean {
|
||||
if (keyword.length < MIN_PREFIX_LEN) return false;
|
||||
for (const word of words) {
|
||||
if (word.length < MIN_PREFIX_LEN) continue;
|
||||
const prefixLen = Math.min(word.length, keyword.length, MIN_PREFIX_LEN + 2);
|
||||
const prefixLen = Math.min(word.length, keyword.length, MAX_PREFIX_LEN);
|
||||
if (word.slice(0, prefixLen) === keyword.slice(0, prefixLen)) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -730,7 +762,7 @@ export function orderBySelectivity(types: string[], keywords: string[], specific
|
||||
* non-canonical words (e.g. "restarted" for system_reboot_detected, "stuck" for a timeout).
|
||||
* Groups are symmetric — every term maps to the whole group.
|
||||
*/
|
||||
const KEYWORD_SYNONYM_GROUPS: readonly string[][] = [
|
||||
export const KEYWORD_SYNONYM_GROUPS: readonly string[][] = [
|
||||
['reboot', 'rebooted', 'restart', 'restarted', 'reset'],
|
||||
['timeout', 'timedout', 'stall', 'stalled', 'stuck', 'hang', 'hung'],
|
||||
['hello', 'whfb', 'passwordless'],
|
||||
@@ -750,7 +782,7 @@ const SYNONYM_LOOKUP: ReadonlyMap<string, readonly string[]> = (() => {
|
||||
/**
|
||||
* Collapse query keywords that fall in the SAME synonym group down to one representative concept
|
||||
* (first occurrence kept), preserving order and keeping non-synonym keywords as-is. Because
|
||||
* keywordMatchesField already expands a single keyword to its whole group, one representative per
|
||||
* compileConcepts already expands a single keyword to its whole group, one representative per
|
||||
* group is sufficient — and it stops a query that spells the same concept two ways (e.g. "stuck"
|
||||
* AND "timeout") from counting as two matched keywords, which inflated coverage for off-topic types.
|
||||
* A no-op for queries with no intra-group duplicates.
|
||||
@@ -769,37 +801,6 @@ export function dedupeSynonymConcepts(keywords: string[]): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a SYNONYM at word granularity (whole word, or a shared word-prefix of >= MIN_PREFIX_LEN),
|
||||
* never as a mid-word substring. Synonyms are short (stall/hang/reset/…) and the loose substring
|
||||
* check used for literal keywords would mis-fire — e.g. "stall" sits inside "in[stall]", "hang"
|
||||
* inside "c[hang]e" — making a "timeout" query spuriously match every app_install_* event. Word-
|
||||
* level matching keeps the useful equivalences (reboot↔restart, stuck↔timeout) without those
|
||||
* false positives.
|
||||
*/
|
||||
function synonymMatchesWord(text: string, term: string): boolean {
|
||||
if (term.length < MIN_PREFIX_LEN) return false;
|
||||
for (const word of text.split(/[\s_\-.:,/]+/)) {
|
||||
if (word === term) return true;
|
||||
if (word.length >= MIN_PREFIX_LEN) {
|
||||
const prefixLen = Math.min(word.length, term.length, MIN_PREFIX_LEN + 2);
|
||||
if (word.slice(0, prefixLen) === term.slice(0, prefixLen)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if the keyword (loose, prefix-aware) or any of its synonyms (word-level) matches `text`. */
|
||||
function keywordMatchesField(text: string, keyword: string): boolean {
|
||||
if (prefixAwareMatch(text, keyword)) return true;
|
||||
const group = SYNONYM_LOOKUP.get(keyword);
|
||||
if (!group) return false;
|
||||
for (const term of group) {
|
||||
if (term !== keyword && synonymMatchesWord(text, term)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Field weights for scoring — eventType is the most discriminating field. */
|
||||
const FIELD_WEIGHTS = {
|
||||
eventType: 3.0,
|
||||
@@ -809,6 +810,92 @@ const FIELD_WEIGHTS = {
|
||||
data: 0.5,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The scored fields in DESCENDING weight order: a concept counts once, at the first field it matches,
|
||||
* which is only its strongest field while this order holds.
|
||||
*/
|
||||
export const SCORED_FIELDS: ReadonlyArray<{ name: string; weight: number; text: (e: EventEntry) => string }> = [
|
||||
{ name: 'eventType', weight: FIELD_WEIGHTS.eventType, text: (e) => (e.eventType ?? '').toLowerCase() },
|
||||
{ name: 'message', weight: FIELD_WEIGHTS.message, text: (e) => (e.message ?? '').toLowerCase() },
|
||||
{ name: 'source', weight: FIELD_WEIGHTS.source, text: (e) => (e.source ?? '').toLowerCase() },
|
||||
{ name: 'severity', weight: FIELD_WEIGHTS.severity, text: (e) => (e.severity ?? '').toLowerCase() },
|
||||
{ name: 'data', weight: FIELD_WEIGHTS.data, text: (e) => (e.data ? JSON.stringify(e.data).toLowerCase() : '') },
|
||||
];
|
||||
|
||||
/** One word-prefix term of a concept (the keyword or one of its synonyms), cut to MAX_PREFIX_LEN. */
|
||||
type PrefixTerm = { concept: number; head: string };
|
||||
|
||||
/**
|
||||
* A query's concepts, compiled once per search so that scoring an event splits each field into words
|
||||
* once — the per-event cost does not grow with the number of keywords.
|
||||
*
|
||||
* A concept matches a field when its keyword is a substring of the field, or when a field word shares a
|
||||
* prefix with one of the concept's terms: the shorter of the two MAX_PREFIX_LEN-character heads is a
|
||||
* prefix of the longer, both at least MIN_PREFIX_LEN long (the sharesWordPrefix rule). The terms are
|
||||
* the keyword and, for a synonym, its whole group. Synonyms match at word granularity only, never as a
|
||||
* mid-word substring: they are short (stall/hang/reset/…), "stall" sits inside "in[stall]" and "hang"
|
||||
* inside "c[hang]e", so a substring check would make a "timeout" query match every app_install_* event.
|
||||
*/
|
||||
export type ConceptMatcher = {
|
||||
readonly concepts: readonly string[];
|
||||
/** Terms by the headKey of their first three characters. */
|
||||
readonly termsByKey: ReadonlyMap<number, readonly PrefixTerm[]>;
|
||||
};
|
||||
|
||||
/** Map key of a string's first three UTF-16 code units, so a field word is looked up without cutting a substring. */
|
||||
function headKey(s: string): number {
|
||||
return s.charCodeAt(0) * 0x1_0000_0000 + s.charCodeAt(1) * 0x1_0000 + s.charCodeAt(2);
|
||||
}
|
||||
|
||||
export function compileConcepts(queryKeywords: string[]): ConceptMatcher {
|
||||
const concepts = dedupeSynonymConcepts(queryKeywords);
|
||||
const termsByKey = new Map<number, PrefixTerm[]>();
|
||||
concepts.forEach((keyword, concept) => {
|
||||
for (const term of SYNONYM_LOOKUP.get(keyword) ?? [keyword]) {
|
||||
if (term.length < MIN_PREFIX_LEN) continue;
|
||||
const head = term.slice(0, MAX_PREFIX_LEN);
|
||||
const key = headKey(head);
|
||||
const terms = termsByKey.get(key);
|
||||
if (!terms) termsByKey.set(key, [{ concept, head }]);
|
||||
else if (!terms.some((t) => t.concept === concept && t.head === head)) terms.push({ concept, head });
|
||||
}
|
||||
});
|
||||
return { concepts, termsByKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* For each concept, the index into SCORED_FIELDS of the strongest field it matches, or -1. A concept is
|
||||
* tested only until it matched, so `data` is serialized only while a concept is still open.
|
||||
*/
|
||||
export function matchConcepts(e: EventEntry, { concepts, termsByKey }: ConceptMatcher): number[] {
|
||||
const fieldOf: number[] = new Array(concepts.length).fill(-1);
|
||||
let open = concepts.length;
|
||||
for (let f = 0; f < SCORED_FIELDS.length && open > 0; f++) {
|
||||
const text = SCORED_FIELDS[f].text(e);
|
||||
if (!text) continue;
|
||||
for (let c = 0; c < concepts.length; c++) {
|
||||
if (fieldOf[c] < 0 && text.includes(concepts[c])) {
|
||||
fieldOf[c] = f;
|
||||
open--;
|
||||
}
|
||||
}
|
||||
if (open === 0 || termsByKey.size === 0) continue;
|
||||
for (const word of splitWords(text)) {
|
||||
if (word.length < MIN_PREFIX_LEN) continue;
|
||||
const terms = termsByKey.get(headKey(word));
|
||||
if (!terms) continue;
|
||||
for (const { concept, head } of terms) {
|
||||
if (fieldOf[concept] < 0 && (word.length >= head.length ? word.startsWith(head) : head.startsWith(word))) {
|
||||
fieldOf[concept] = f;
|
||||
open--;
|
||||
}
|
||||
}
|
||||
if (open === 0) break;
|
||||
}
|
||||
}
|
||||
return fieldOf;
|
||||
}
|
||||
|
||||
type ScoredEvent = {
|
||||
index: number;
|
||||
score: number;
|
||||
@@ -856,6 +943,9 @@ function severityIntentFactor(e: EventEntry, boostFailures: boolean, entityTypeM
|
||||
* non-problem keyword matches the event TYPE, the event is the specifically-named entity and
|
||||
* earns the ENTITY_BOOST (not the benign-Info damp) on a problem query — see severityIntentFactor.
|
||||
* Omitted → no entity boost (old behavior), which is why the existing unit tests are unaffected.
|
||||
*
|
||||
* `matcher` is the query compiled by compileConcepts. It is query-invariant, so scoreEvents compiles it
|
||||
* once for every fetched event; standalone callers (unit tests) omit it and get it compiled here.
|
||||
*/
|
||||
export function scoreEvent(
|
||||
e: EventEntry,
|
||||
@@ -863,49 +953,30 @@ export function scoreEvent(
|
||||
boostFailures = false,
|
||||
semanticTypeScores?: Map<string, number>,
|
||||
keywordSpecificity?: Map<string, number>,
|
||||
precomputedConcepts?: string[],
|
||||
matcher: ConceptMatcher = compileConcepts(queryKeywords),
|
||||
): ScoredEvent | null {
|
||||
const fields: Array<{ name: string; text: string; weight: number }> = [
|
||||
{ name: 'eventType', text: (e.eventType ?? '').toLowerCase(), weight: FIELD_WEIGHTS.eventType },
|
||||
{ name: 'message', text: (e.message ?? '').toLowerCase(), weight: FIELD_WEIGHTS.message },
|
||||
{ name: 'source', text: (e.source ?? '').toLowerCase(), weight: FIELD_WEIGHTS.source },
|
||||
{ name: 'severity', text: (e.severity ?? '').toLowerCase(), weight: FIELD_WEIGHTS.severity },
|
||||
{ name: 'data', text: e.data ? JSON.stringify(e.data).toLowerCase() : '', weight: FIELD_WEIGHTS.data },
|
||||
];
|
||||
|
||||
// Collapse query keywords that share a synonym group to a single concept so two query words
|
||||
// expressing ONE idea (e.g. "stuck" + "timeout") don't double-count coverage/score against an
|
||||
// off-topic type (hello_wait_timeout) and outrank the actually-named entity (app_download_started).
|
||||
// This is query-invariant, so the cross-session loop computes it once and passes it in to avoid
|
||||
// recomputing the dedupe (and its Set/Map allocations) for every one of potentially thousands of
|
||||
// fetched events; standalone callers (unit tests) omit it and get the same value computed here.
|
||||
const concepts = precomputedConcepts ?? dedupeSynonymConcepts(queryKeywords);
|
||||
// The concepts are the keywords collapsed to one per synonym group, so two query words expressing ONE
|
||||
// idea (e.g. "stuck" + "timeout") don't double-count coverage/score against an off-topic type
|
||||
// (hello_wait_timeout) and outrank the actually-named entity (app_download_started).
|
||||
const { concepts } = matcher;
|
||||
const fieldOf = matchConcepts(e, matcher);
|
||||
|
||||
let totalScore = 0;
|
||||
const matched: string[] = [];
|
||||
const bestFields = new Set<string>();
|
||||
let entityTypeMatch = false;
|
||||
|
||||
for (const kw of concepts) {
|
||||
let kwBestWeight = 0;
|
||||
let kwBestField = '';
|
||||
for (const field of fields) {
|
||||
if (field.text && keywordMatchesField(field.text, kw)) {
|
||||
if (field.weight > kwBestWeight) {
|
||||
kwBestWeight = field.weight;
|
||||
kwBestField = field.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kwBestWeight > 0) {
|
||||
totalScore += kwBestWeight;
|
||||
matched.push(kw);
|
||||
bestFields.add(kwBestField);
|
||||
// A rare, non-problem keyword matching the event TYPE marks this as the named entity.
|
||||
if (kwBestField === 'eventType' && !isProblemIntentKeyword(kw)
|
||||
&& (keywordSpecificity?.get(kw) ?? Infinity) <= RARE_THRESHOLD) {
|
||||
entityTypeMatch = true;
|
||||
}
|
||||
for (let c = 0; c < concepts.length; c++) {
|
||||
if (fieldOf[c] < 0) continue;
|
||||
const kw = concepts[c];
|
||||
const field = SCORED_FIELDS[fieldOf[c]];
|
||||
totalScore += field.weight;
|
||||
matched.push(kw);
|
||||
bestFields.add(field.name);
|
||||
// A rare, non-problem keyword matching the event TYPE marks this as the named entity.
|
||||
if (field.name === 'eventType' && !isProblemIntentKeyword(kw)
|
||||
&& (keywordSpecificity?.get(kw) ?? Infinity) <= RARE_THRESHOLD) {
|
||||
entityTypeMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,6 +1011,36 @@ export function scoreEvent(
|
||||
};
|
||||
}
|
||||
|
||||
/** Events scored between two yields to the event loop. */
|
||||
const SCORE_YIELD_INTERVAL = 256;
|
||||
|
||||
/**
|
||||
* Score fetched events against the query, keeping those at or above `minScore`. Yields to the event
|
||||
* loop every SCORE_YIELD_INTERVAL events: one process serves every tenant, and a cross-session search
|
||||
* ranks tens of thousands of events, which in one synchronous pass would stall every other request
|
||||
* (and /health) until it finished. Stops once the caller has gone, since nobody reads the result.
|
||||
*/
|
||||
export async function scoreEvents(
|
||||
events: EventEntry[],
|
||||
queryKeywords: string[],
|
||||
boostFailures: boolean,
|
||||
semanticTypeScores: Map<string, number>,
|
||||
keywordSpecificity: Map<string, number>,
|
||||
minScore: number,
|
||||
): Promise<Array<ScoredEvent & { event: EventEntry }>> {
|
||||
const matcher = compileConcepts(queryKeywords);
|
||||
const scored: Array<ScoredEvent & { event: EventEntry }> = [];
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
if (i > 0 && i % SCORE_YIELD_INTERVAL === 0) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
getCallerSignal()?.throwIfAborted();
|
||||
}
|
||||
const result = scoreEvent(events[i], queryKeywords, boostFailures, semanticTypeScores, keywordSpecificity, matcher);
|
||||
if (result && result.score >= minScore) scored.push({ ...result, index: i, event: events[i] });
|
||||
}
|
||||
return scored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spread the top-scored events across distinct sessions so one session's many
|
||||
* same-type events don't monopolize the list — cross-session search otherwise keeps
|
||||
@@ -1066,7 +1167,7 @@ export function registerSearchTools(
|
||||
'even when it sits inside another event\'s data — pass a sessionId for a complete scan of every event field. ' +
|
||||
'`truncated: true` means incomplete recall: narrow per `recallNote` or use depth="deep".',
|
||||
inputSchema: {
|
||||
query: z.string().describe('Natural-language description of what to find (e.g. "machine restarted unexpectedly", "app download stuck", "certificate error")'),
|
||||
query: z.string().max(MAX_SEARCH_QUERY_CHARS).describe('Natural-language description of what to find (e.g. "machine restarted unexpectedly", "app download stuck", "certificate error")'),
|
||||
sessionId: SessionIdSchema.optional().describe('Search within one session (complete scan). Omitted: cross-session search by event type.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
depth: z.enum(['fast', 'deep']).optional().default('fast')
|
||||
@@ -1075,7 +1176,7 @@ export function registerSearchTools(
|
||||
.describe('Number of matching events to return (1-50). Defaults: 10 (fast) / 20 (deep).'),
|
||||
minScore: z.coerce.number().min(0).max(1).optional()
|
||||
.describe('Minimum relevance score (0-1). Defaults: 0.1 (fast) / 0.05 (deep). Lower surfaces weaker matches.'),
|
||||
keywords: z.array(z.string()).optional()
|
||||
keywords: z.array(z.string().max(MAX_SEARCH_KEYWORD_CHARS)).max(MAX_SEARCH_KEYWORDS).optional()
|
||||
.describe('Additional exact keywords, merged (case-insensitively) with those auto-extracted from the query.'),
|
||||
guaranteedTopRanked: z.coerce.number().min(0).max(50).optional().default(3)
|
||||
.describe('How many top hits (by pure score) stay at the head in rank order, exempt from cross-session diversification (default 3). Set =topK to disable diversification, 0 for maximum session spread. Ignored with sessionId.'),
|
||||
@@ -1091,8 +1192,11 @@ export function registerSearchTools(
|
||||
const topK = args.topK ?? preset.defaultTopK;
|
||||
const minScore = args.minScore ?? preset.defaultMinScore;
|
||||
|
||||
// Extract keywords FIRST so we can use them for index-based pre-filtering
|
||||
const queryKeywords = resolveQueryKeywords(query, keywords);
|
||||
// Extract keywords FIRST so we can use them for index-based pre-filtering. Everything below
|
||||
// sees only the first MAX_SCORED_KEYWORDS; the response reports the rest as dropped.
|
||||
const allKeywords = resolveQueryKeywords(query, keywords);
|
||||
const queryKeywords = allKeywords.slice(0, MAX_SCORED_KEYWORDS);
|
||||
const keywordsDropped = allKeywords.length - queryKeywords.length;
|
||||
if (queryKeywords.length === 0) {
|
||||
logSearchZeroHit('search_events', query, { reason: 'no_keywords' });
|
||||
return toolResultText(
|
||||
@@ -1108,16 +1212,7 @@ export function registerSearchTools(
|
||||
await fetchSessionEvents(sessionId, tenantId, queryKeywords, preset.budget, query, eventTypeIndex, keywordSpecificity);
|
||||
|
||||
const boostFailures = queryHasProblemIntent(queryKeywords);
|
||||
// Synonym-collapse the query keywords ONCE — scoreEvent runs per fetched event (potentially
|
||||
// thousands), and the concept set is identical across all of them.
|
||||
const concepts = dedupeSynonymConcepts(queryKeywords);
|
||||
const scored: Array<ScoredEvent & { event: EventEntry }> = [];
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const result = scoreEvent(events[i], queryKeywords, boostFailures, semanticTypeScores, keywordSpecificity, concepts);
|
||||
if (result && result.score >= minScore) {
|
||||
scored.push({ ...result, index: i, event: events[i] });
|
||||
}
|
||||
}
|
||||
const scored = await scoreEvents(events, queryKeywords, boostFailures, semanticTypeScores, keywordSpecificity, minScore);
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const results = diversifyBySession(scored, topK, 2, guaranteedTopRanked).map((s) => ({
|
||||
@@ -1153,6 +1248,7 @@ export function registerSearchTools(
|
||||
depth,
|
||||
searchMethod,
|
||||
keywordsUsed: queryKeywords,
|
||||
...(keywordsDropped > 0 ? { keywordsDropped, keywordNote: KEYWORDS_DROPPED_NOTE } : {}),
|
||||
sessionsSearchedCount: sessionIds.length,
|
||||
eventsFetched: events.length,
|
||||
eventsMatched: scored.length,
|
||||
@@ -1186,7 +1282,7 @@ export function registerSearchTools(
|
||||
'because opaque codes embed poorly. For one known code, lookup_error_code is the direct answer (accepts ' +
|
||||
'decimal, symbol and enforcement-state input too).',
|
||||
inputSchema: {
|
||||
query: z.string().describe('Natural language search query (e.g. "app download timeout", "TPM not ready", "ESP stuck")'),
|
||||
query: z.string().max(MAX_SEARCH_QUERY_CHARS).describe('Natural language search query (e.g. "app download timeout", "TPM not ready", "ESP stuck")'),
|
||||
topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'),
|
||||
type: z.enum(['all', 'analyze-rule', 'gather-rule', 'ime-log-pattern', 'error-code']).optional().default('all')
|
||||
.describe('Filter by document type. Default: search all types.'),
|
||||
@@ -1333,9 +1429,9 @@ export function registerSearchTools(
|
||||
'Rule of thumb: "how does X work?" → search_docs; "why did this session fail?" → search_knowledge or ' +
|
||||
'get_session_summary.',
|
||||
inputSchema: {
|
||||
query: z.string().describe('Natural language question (e.g. "how do I deploy the agent with Intune", "where is my data stored", "which roles exist")'),
|
||||
query: z.string().max(MAX_SEARCH_QUERY_CHARS).describe('Natural language question (e.g. "how do I deploy the agent with Intune", "where is my data stored", "which roles exist")'),
|
||||
topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'),
|
||||
section: z.string().optional()
|
||||
section: z.string().max(MAX_DOCS_SECTION_CHARS).optional()
|
||||
.describe('Restrict to one documentation area, matching the bundle\'s top-level folder ' +
|
||||
`(one of: ${docs.sections.join(', ')}). Omit to search everything.`),
|
||||
minScore: z.coerce.number().min(0).max(1).optional().default(0.25)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { z } from 'zod';
|
||||
import { ApiError, apiFetch, buildQuery, jsonBody, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, pageSizeForCall, pickGlobalOrTenantPath, scanWithTimeoutFallback } from '../client.js';
|
||||
import { withToolTelemetry } from '../telemetry.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, foreignSuccessRunIds, isCorrectedForeignFailure, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, foreignSuccessRunIds, isCorrectedForeignFailure, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { lookupErrorCode } from '../error-code-catalog.js';
|
||||
import { withEventTypeNote, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
|
||||
@@ -174,7 +174,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for full sweeps.')),
|
||||
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
|
||||
continuation: ContinuationSchema.optional().describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -227,7 +227,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for full sweeps.')),
|
||||
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
|
||||
continuation: ContinuationSchema.optional().describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -410,7 +410,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
.describe('Projection. Omitted on an UNFILTERED read = lean default "' + LEAN_EVENT_FIELDS + '" without the multi-KB "data" payload (omittedFields says so); omitted on a FILTERED read = full events incl. "data". List "data" for the whole payload or "data.<key>" (e.g. "data.errorCode") for single keys. Further keys: eventId, sessionId, tenantId, receivedAt, sentAt, rowKey, originalTimestamp, timestampClamped, causedByTransitionStepIndex, causedBySignalOrdinal.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
|
||||
continuation: ContinuationSchema.optional().describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -749,7 +749,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
overallRisk: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for a full exposure audit.')),
|
||||
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
|
||||
continuation: ContinuationSchema.optional().describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
|
||||
@@ -372,6 +372,13 @@ export function pageSizeDescription(defaultSize: number, max = 1000, note?: stri
|
||||
export const CONTINUATION_DESCRIPTION =
|
||||
'The whole nextLink string from the previous response (preferred — it carries the resolved filters) or its opaque continuation value.';
|
||||
|
||||
/**
|
||||
* continuation: a server-emitted nextLink or its opaque token; real ones stay under 1 KB. The bound
|
||||
* matters: a pageSize/fields override re-sets params on the caller's link (withQueryOverrides), and
|
||||
* URLSearchParams.set drops duplicate keys one at a time — quadratic in a forged link's duplicates.
|
||||
*/
|
||||
export const ContinuationSchema = z.string().max(4096);
|
||||
|
||||
/** days: a trailing window ending now. */
|
||||
export function daysDescription(defaultDays: number, max: number, note?: string): string {
|
||||
return `Trailing window in days (1-${max}; default ${defaultDays}).${note ? ` ${note}` : ''}`;
|
||||
|
||||
@@ -309,6 +309,22 @@ namespace AutopilotMonitor.Shared
|
||||
/// </summary>
|
||||
public const string AgentDirectory = @"%ProgramData%\AutopilotMonitor\Agent";
|
||||
|
||||
/// <summary>
|
||||
/// Publisher pin for everything the release pipeline signs: a <c>-like</c> pattern on the
|
||||
/// Authenticode signer's subject, never a thumbprint (certificates are renewed, deployed
|
||||
/// verifiers are not). The bootstrap loader and installer carry the same literal as
|
||||
/// <c>$ExpectedPublisher</c>; a parity test keeps all three in step.
|
||||
/// </summary>
|
||||
public const string CodeSigningPublisherPattern = "*O=glueckkanja AG*";
|
||||
|
||||
/// <summary>
|
||||
/// Signed catalog at the root of every agent ZIP, holding the SHA-256 of every other file
|
||||
/// in the payload. Installer, OOBE bootstrap script and self-updater accept a payload only
|
||||
/// when this catalog is signed by <see cref="CodeSigningPublisherPattern"/> and matches the
|
||||
/// extracted files exactly: nothing missing, changed or extra.
|
||||
/// </summary>
|
||||
public const string AgentPayloadCatalogFileName = "AutopilotMonitor.Agent.cat";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Upload / batching defaults
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -18,8 +18,9 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string TenantId { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Domain name extracted from the first user's UPN
|
||||
/// Used for display purposes (e.g., contoso.com)
|
||||
/// Domain of the onboarding member's UPN (e.g. contoso.com), seeded once by the first member
|
||||
/// login and never from an external identity. Tenant identity, not only display: transactional
|
||||
/// mails render it, and cross-tenant grants resolve a never-signed-in UPN's home tenant through it.
|
||||
/// </summary>
|
||||
public string DomainName { get; set; } = default!;
|
||||
|
||||
@@ -34,13 +35,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string UpdatedBy { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// UPN of the user whose first login created this tenant configuration. Set once
|
||||
/// in <c>HandleNewTenantDomainAsync</c> alongside <see cref="DomainName"/> and
|
||||
/// never overwritten. Used by the preview-approval auto-promote path so background
|
||||
/// jobs that mutate <see cref="UpdatedBy"/> (e.g. global rate-limit sync) cannot
|
||||
/// leak a sentinel string into the TenantAdmins table.
|
||||
/// Null on rows that pre-date the OnboardedBy field — auto-promote falls back to
|
||||
/// <see cref="UpdatedBy"/> with a UPN-shape guard.
|
||||
/// UPN of the member whose first login onboarded this tenant. Set once in
|
||||
/// <c>HandleNewTenantDomainAsync</c> alongside <see cref="DomainName"/> and never
|
||||
/// overwritten; an external identity (guest) never sets it. Activation promotes this UPN,
|
||||
/// and only this one, to Tenant Admin, so background jobs that mutate
|
||||
/// <see cref="UpdatedBy"/> (e.g. global rate-limit sync) cannot leak a sentinel or a last
|
||||
/// writer into the TenantAdmins table. Null on rows that pre-date the field: such a tenant
|
||||
/// gets no promotion on activation.
|
||||
/// </summary>
|
||||
public string? OnboardedBy { get; set; }
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import { sameHistory } from "@/lib/push/historyList";
|
||||
import { createLongPress } from "@/lib/push/longPress";
|
||||
import { createPullToRefresh, type PullState } from "@/lib/push/pullToRefresh";
|
||||
import {
|
||||
applyBadge,
|
||||
deleteHistoryEntry,
|
||||
entryFragment,
|
||||
markAllRead,
|
||||
META_KEYS,
|
||||
parseFragment,
|
||||
pruneStoredHistory,
|
||||
@@ -19,11 +21,11 @@ import {
|
||||
writeMeta,
|
||||
type HistoryEntry,
|
||||
} from "@/lib/push/pushCore";
|
||||
import { importDisplayedNotifications, readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient";
|
||||
import { closeReadNotifications, importDisplayedNotifications, readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient";
|
||||
import { formatDateTime, formatRelativeTime } from "@/lib/push/pushFormat";
|
||||
import { EntryActionSheet } from "./EntryActionSheet";
|
||||
import { useIsClient, useLocationHash } from "./pushEnvironment";
|
||||
import { DANGER_CHIP, DEVICE_STATUS_CHIP, NEUTRAL_CHIP, SEVERITY_ACCENT } from "./pushStyles";
|
||||
import { DANGER_CHIP, DEVICE_STATUS_CHIP, NEUTRAL_CHIP, NEW_CHIP, SEVERITY_ACCENT } from "./pushStyles";
|
||||
|
||||
interface WorkerMessage {
|
||||
channel?: unknown;
|
||||
@@ -43,13 +45,16 @@ const REFRESH_MIN_MS = 500;
|
||||
* while visible, and the tab/app coming back all re-read the store; a pull from the top refreshes
|
||||
* on demand (a home-screen app has no native gesture). Every open reconciles the device with the
|
||||
* server (K14). A long press (or right-click) on an entry opens its actions; a delete stays on
|
||||
* the device.
|
||||
* the device. Like a messenger, a history on screen is read: the app badge goes to zero, the
|
||||
* notification centre lets go of what the history holds, and what arrived since the last visit
|
||||
* carries "New" until the app is left again.
|
||||
*/
|
||||
export default function PushHistoryPage() {
|
||||
const router = useRouter();
|
||||
const isClient = useIsClient();
|
||||
const hash = useLocationHash();
|
||||
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
|
||||
const [fresh, setFresh] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [device, setDevice] = useState<ReconcileResult | null>(null);
|
||||
const [historyKey, setHistoryKey] = useState(0);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
@@ -66,16 +71,26 @@ export default function PushHistoryPage() {
|
||||
if (expanded.focusId !== focusId) setExpanded({ focusId, id: focusId });
|
||||
|
||||
// One read of the store (prune first). An unchanged list keeps its identity so a poll never re-renders for nothing.
|
||||
// On screen, the read also marks everything read; the badge is cleared on every such read, since
|
||||
// the worker may raise it between the read and the mark.
|
||||
const loadHistory = useCallback(async () => {
|
||||
const onScreen = document.visibilityState === "visible";
|
||||
let list: HistoryEntry[] = [];
|
||||
let seen: string[] = [];
|
||||
try {
|
||||
await importDisplayedNotifications();
|
||||
await pruneStoredHistory();
|
||||
list = await readHistory();
|
||||
if (onScreen) seen = await markAllRead();
|
||||
} catch {
|
||||
// No IndexedDB (private mode): an empty history is the honest state.
|
||||
}
|
||||
if (onScreen) {
|
||||
applyBadge(navigator, 0);
|
||||
void closeReadNotifications(new Set(list.map((entry) => entry.id)));
|
||||
}
|
||||
setEntries((prev) => (sameHistory(prev, list) ? prev : list));
|
||||
if (seen.length > 0) setFresh((prev) => new Set([...prev, ...seen]));
|
||||
setNow(Date.now());
|
||||
}, []);
|
||||
|
||||
@@ -98,7 +113,11 @@ export default function PushHistoryPage() {
|
||||
if (event.data.type === "wiped") setReloadKey((k) => k + 1);
|
||||
};
|
||||
const onBack = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (document.visibilityState !== "visible") {
|
||||
// Leaving the app ends the visit: what was new has been seen.
|
||||
setFresh((prev) => (prev.size === 0 ? prev : new Set()));
|
||||
return;
|
||||
}
|
||||
setHistoryKey((k) => k + 1);
|
||||
setReloadKey((k) => k + 1);
|
||||
};
|
||||
@@ -337,6 +356,7 @@ export default function PushHistoryPage() {
|
||||
entry={entry}
|
||||
open={expanded.id === entry.id}
|
||||
focused={focusId === entry.id}
|
||||
fresh={fresh.has(entry.id)}
|
||||
now={now}
|
||||
onToggle={toggle}
|
||||
onActions={setSheet}
|
||||
@@ -355,6 +375,8 @@ interface EntryCardProps {
|
||||
entry: HistoryEntry;
|
||||
open: boolean;
|
||||
focused: boolean;
|
||||
/** Arrived since the history was last on screen. */
|
||||
fresh: boolean;
|
||||
now: number;
|
||||
onToggle: (id: string) => void;
|
||||
onActions: (entry: HistoryEntry) => void;
|
||||
@@ -365,7 +387,7 @@ interface EntryCardProps {
|
||||
* card suppresses the browser's own text selection and the iOS callout so the press reads as
|
||||
* a press, and it ignores the click that the release after a long press may produce.
|
||||
*/
|
||||
function EntryCard({ entry, open, focused, now, onToggle, onActions }: EntryCardProps) {
|
||||
function EntryCard({ entry, open, focused, fresh, now, onToggle, onActions }: EntryCardProps) {
|
||||
const openActions = useLatest(() => onActions(entry));
|
||||
const [press] = useState(() => createLongPress({ onLongPress: () => openActions.current() }));
|
||||
useEffect(() => () => press.dispose(), [press]);
|
||||
@@ -397,9 +419,12 @@ function EntryCard({ entry, open, focused, now, onToggle, onActions }: EntryCard
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 break-words min-w-0">{entry.title}</span>
|
||||
<time dateTime={entry.ts} title={formatDateTime(entry.ts)} className="text-xs text-gray-500 shrink-0">
|
||||
{now ? formatRelativeTime(entry.ts, now) : ""}
|
||||
</time>
|
||||
<span className="flex items-baseline gap-1.5 shrink-0">
|
||||
{fresh && <span className={`px-1.5 py-px rounded-full text-[10px] font-semibold uppercase tracking-wide ${NEW_CHIP}`}>New</span>}
|
||||
<time dateTime={entry.ts} title={formatDateTime(entry.ts)} className="text-xs text-gray-500">
|
||||
{now ? formatRelativeTime(entry.ts, now) : ""}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
{entry.body && (
|
||||
<p className={`text-sm text-gray-600 mt-0.5 break-words ${open ? "" : "line-clamp-2"}`}>{entry.body}</p>
|
||||
|
||||
@@ -19,4 +19,6 @@ export const DEVICE_STATUS_CHIP: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const NEUTRAL_CHIP = "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300";
|
||||
/** "New" on an entry that arrived since the history was last on screen. */
|
||||
export const NEW_CHIP = "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300";
|
||||
export const DANGER_CHIP = "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300";
|
||||
|
||||
@@ -21,10 +21,12 @@ interface SessionWatchButtonProps {
|
||||
|
||||
/**
|
||||
* "Notify me when done" (plan push-relay): registers a watch that pushes to the caller's own
|
||||
* paired devices when this session reaches a terminal status. Hidden once the session is
|
||||
* terminal; the host gates it to Admins/Operators (and Global Admins) like Report Session.
|
||||
* The GET says whether watching is possible for this person at all (a Push channel in one of
|
||||
* their own scopes); without that the button stays hidden instead of letting a click fail.
|
||||
* paired devices when this session reaches a terminal status — or when pre-provisioning seals
|
||||
* the device, which consumes the watch; on the sealed (Pending) session a new click watches the
|
||||
* user phase. Hidden once the session is terminal; the host gates it to Admins/Operators (and
|
||||
* Global Admins) like Report Session. The GET says whether watching is possible for this person
|
||||
* at all (a Push channel in one of their own scopes); without that the button stays hidden
|
||||
* instead of letting a click fail. Every status change re-reads the watch, so the seal shows here.
|
||||
*/
|
||||
export default function SessionWatchButton({ sessionId, effectiveTenantId, sessionStatus, getAccessToken, addNotification }: SessionWatchButtonProps) {
|
||||
const [watching, setWatching] = useState<boolean | null>(null);
|
||||
@@ -32,6 +34,8 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const active = sessionId.length > 0 && !!sessionStatus && !TERMINAL_STATUSES.has(sessionStatus);
|
||||
const sealed = sessionStatus === "Pending";
|
||||
const until = sealed ? "the user phase ends" : "this session ends or pre-provisioning finishes";
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -51,7 +55,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, sessionId, effectiveTenantId, getAccessToken]);
|
||||
}, [active, sessionStatus, sessionId, effectiveTenantId, getAccessToken]);
|
||||
|
||||
if (!active || !available) return null;
|
||||
|
||||
@@ -63,7 +67,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
const result = await setSessionWatch(sessionId, effectiveTenantId, next, getAccessToken);
|
||||
setWatching(result.watching);
|
||||
if (result.watching) {
|
||||
addNotification("success", TOAST_TITLE, "Your paired devices will be notified when this session ends.", TOAST_KEY);
|
||||
addNotification("success", TOAST_TITLE, `Your paired devices will be notified when ${until}.`, TOAST_KEY);
|
||||
} else {
|
||||
addNotification("info", TOAST_TITLE, "You will not be notified for this session.", TOAST_KEY);
|
||||
}
|
||||
@@ -84,7 +88,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
|
||||
onClick={() => void toggle()}
|
||||
disabled={busy || watching === null}
|
||||
aria-pressed={watching === true}
|
||||
title={watching ? "Stop notifying my devices when this session ends." : "Push to my paired devices when this session ends."}
|
||||
title={watching ? `Stop notifying my devices when ${until}.` : `Push to my paired devices when ${until}.`}
|
||||
className={`px-4 py-2 rounded-md transition-colors flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
watching
|
||||
? "bg-sky-100 border border-sky-300 text-sky-800 hover:bg-sky-200"
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function AdminManagementSection({
|
||||
</svg>
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Important:</strong> Make sure to keep at least one Admin in the list to maintain full access!
|
||||
The first user to log in was automatically made an admin.
|
||||
The first member of your organization to log in was automatically made an admin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ export const PROVIDERS: {
|
||||
|
||||
const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [
|
||||
{ key: "notifyOnStart", label: "Start", hint: "Enrollment started (one signal per fresh session and per Pre-Provisioning resume)" },
|
||||
{ key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully" },
|
||||
{ key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully, or pre-provisioning finished" },
|
||||
{ key: "notifyOnFailure", label: "Failure", hint: "Enrollment failed" },
|
||||
{ key: "notifyOnHardwareRejection", label: "Hardware rejection", hint: "Device rejected by the hardware whitelist" },
|
||||
{ key: "notifyOnSlaEvents", label: "SLA", hint: "SLA breach / resolved / consecutive failures (which breaches are evaluated is configured under SLA Targets)" },
|
||||
|
||||
@@ -44,6 +44,8 @@ describe("classifyEntry", () => {
|
||||
expect(classifyEntry(entry("b", "X", "platform", [{ name: "Category", value: "Consent" }]))).toEqual({ group: "platform", kind: "consent", label: "Consent" });
|
||||
expect(classifyEntry(entry("c", "test", "platform"))).toEqual({ group: "platform", kind: "other", label: "Other" });
|
||||
expect(classifyEntry(entry("d", "enrollment_succeeded"))).toEqual({ group: "enrollment", kind: "enrollment_succeeded", label: "Enrollment succeeded" });
|
||||
// The WhiteGlove seal travels on the Success toggle as its own alert kind.
|
||||
expect(classifyEntry(entry("f", "preprovisioning_completed"))).toEqual({ group: "enrollment", kind: "preprovisioning_completed", label: "Pre-provisioning completed" });
|
||||
expect(classifyEntry(entry("e", "some_new_kind"))).toEqual({ group: "enrollment", kind: "some_new_kind", label: "Some new kind" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
APP_VERSION,
|
||||
appendTraceRecord,
|
||||
applyBadge,
|
||||
base64UrlToUint8Array,
|
||||
bufferToBase64Url,
|
||||
cleanLabel,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
toNotificationOptions,
|
||||
TRACE_MAX_RECORDS,
|
||||
TRACE_VALUE_MAX_CHARS,
|
||||
unreadIds,
|
||||
type HistoryEntry,
|
||||
} from "../push/pushCore";
|
||||
|
||||
@@ -246,6 +248,62 @@ describe("pruneHistory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("app badge (unreadIds / applyBadge)", () => {
|
||||
const entry = (id: string, unread?: boolean): HistoryEntry => ({ ...normalizePayload(FULL_PAYLOAD, NOW), id, unread });
|
||||
|
||||
it("counts only entries stored unread; a fresh payload carries no flag at all", () => {
|
||||
expect(normalizePayload(FULL_PAYLOAD, NOW).unread).toBeUndefined();
|
||||
expect(unreadIds([entry("a", true), entry("b", false), entry("c"), entry("d", true)])).toEqual(["a", "d"]);
|
||||
expect(unreadIds([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("sets a count above zero and clears at zero, always with a number", () => {
|
||||
const calls: string[] = [];
|
||||
const nav = {
|
||||
setAppBadge: (count: number) => {
|
||||
calls.push(`set:${count}`);
|
||||
return Promise.resolve();
|
||||
},
|
||||
clearAppBadge: () => {
|
||||
calls.push("clear");
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
applyBadge(nav, 3);
|
||||
applyBadge(nav, 0);
|
||||
expect(calls).toEqual(["set:3", "clear"]);
|
||||
});
|
||||
|
||||
it("returns at once although Safari's setAppBadge promise never settles inside a declarative push event", () => {
|
||||
let called = false;
|
||||
const nav = {
|
||||
setAppBadge: () => {
|
||||
called = true;
|
||||
return new Promise<void>(() => {});
|
||||
},
|
||||
};
|
||||
expect(applyBadge(nav, 1)).toBeUndefined();
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows a rejection, a throw and a missing Badging API", async () => {
|
||||
applyBadge({ setAppBadge: () => Promise.reject(new Error("NotAllowedError")) }, 2);
|
||||
applyBadge(
|
||||
{
|
||||
clearAppBadge: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
applyBadge({}, 5);
|
||||
applyBadge(undefined, 5);
|
||||
applyBadge(null, 0);
|
||||
// A rejection left unhandled would fail the run; give it a tick to surface.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("planSubscriptionKeys (worker repair after a browser-side subscription change)", () => {
|
||||
const stored = { [META_KEYS.kid]: "k1", [META_KEYS.vapidPublicKey]: "key-1" };
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { GENERIC_BODY, GENERIC_TITLE } from "../push/pushCore";
|
||||
* Pins the K25 "always show something" contract of the service worker glue (public/push/sw.js):
|
||||
* the push handler reaches showNotification before IndexedDB is even opened, a payload that
|
||||
* cannot be read still shows the generic title, and the event's waitUntil promise settles
|
||||
* although the history write hangs or fails. The worker is loaded for real with a stubbed
|
||||
* worker global, so the test runs the shipped glue and not a copy of it.
|
||||
* although the history write hangs or fails — or Safari's setAppBadge never settles. The worker
|
||||
* is loaded for real with a stubbed worker global, so the test runs the shipped glue and not a
|
||||
* copy of it.
|
||||
*/
|
||||
|
||||
type Handler = (event: unknown) => void;
|
||||
@@ -25,7 +26,8 @@ interface FakeSelf {
|
||||
sequence: string[];
|
||||
}
|
||||
|
||||
function fakeSelf(): FakeSelf {
|
||||
/** @param navigator the worker's navigator (Badging API); absent like on a browser without it */
|
||||
function fakeSelf(navigator?: unknown): FakeSelf {
|
||||
const handlers = new Map<string, Handler>();
|
||||
const sequence: string[] = [];
|
||||
const showNotification = vi.fn(async () => {
|
||||
@@ -47,6 +49,7 @@ function fakeSelf(): FakeSelf {
|
||||
},
|
||||
clients,
|
||||
location: { origin: "https://portal.example.test" },
|
||||
navigator,
|
||||
};
|
||||
vi.stubGlobal("self", self);
|
||||
return { handlers, showNotification, clients, sequence };
|
||||
@@ -274,6 +277,68 @@ describe("push service worker glue (K25)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("app badge after a history write", () => {
|
||||
const CORE = "../../public/push/sw-core.js";
|
||||
const readable = { notification: { title: "Enrollment failed", body: "DESKTOP-1234", data: { id: "badge-1", type: "enrollment_failed" } } };
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock(CORE);
|
||||
});
|
||||
|
||||
/** The real glue over a store stub: the write answers `added`, the store holds `unread` unread entries. */
|
||||
async function loadWorkerWithStore(added: boolean, unread: number, navigator: unknown) {
|
||||
vi.resetModules();
|
||||
const writeTrace = vi.fn(async () => undefined);
|
||||
vi.doMock(CORE, async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../public/push/sw-core.js")>()),
|
||||
writeHistoryEntry: vi.fn(async () => added),
|
||||
countUnread: vi.fn(async () => unread),
|
||||
writeTrace,
|
||||
}));
|
||||
const env = fakeSelf(navigator);
|
||||
await import("../../public/push/sw.js");
|
||||
return { ...env, writeTrace };
|
||||
}
|
||||
|
||||
it("raises the badge to the unread count without waiting for setAppBadge, whose promise never settles in Safari's declarative push event", async () => {
|
||||
const setAppBadge = vi.fn(() => new Promise<void>(() => {}));
|
||||
const env = await loadWorkerWithStore(true, 3, { setAppBadge });
|
||||
const event = fakeEvent({ data: { json: () => readable } });
|
||||
|
||||
env.handlers.get("push")!(event);
|
||||
|
||||
// Settles by itself — not through the 5-second persist bound, which the fake timers never reach.
|
||||
await expect(event.waited).resolves.toBeDefined();
|
||||
expect(setAppBadge).toHaveBeenCalledWith(3);
|
||||
expect(env.writeTrace).toHaveBeenCalledWith(expect.objectContaining({ id: "badge-1", result: "added", badge: 3 }));
|
||||
});
|
||||
|
||||
it("leaves the badge alone for a duplicate and for an empty count", async () => {
|
||||
const setAppBadge = vi.fn(async () => undefined);
|
||||
const duplicate = await loadWorkerWithStore(false, 3, { setAppBadge });
|
||||
const first = fakeEvent({ data: { json: () => readable } });
|
||||
duplicate.handlers.get("push")!(first);
|
||||
await expect(first.waited).resolves.toBeDefined();
|
||||
|
||||
const empty = await loadWorkerWithStore(true, 0, { setAppBadge });
|
||||
const second = fakeEvent({ data: { json: () => readable } });
|
||||
empty.handlers.get("push")!(second);
|
||||
await expect(second.waited).resolves.toBeDefined();
|
||||
|
||||
expect(setAppBadge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still settles on a browser without the Badging API", async () => {
|
||||
const env = await loadWorkerWithStore(true, 2, undefined);
|
||||
const event = fakeEvent({ data: { json: () => readable } });
|
||||
|
||||
env.handlers.get("push")!(event);
|
||||
|
||||
await expect(event.waited).resolves.toBeDefined();
|
||||
expect(env.writeTrace).toHaveBeenCalledWith(expect.objectContaining({ result: "added", badge: 2 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("notificationclick on a notification the platform displayed itself", () => {
|
||||
function clickEvent(notification: Record<string, unknown>) {
|
||||
const event = fakeEvent({}) as FakeEvent & { notification: Record<string, unknown> };
|
||||
|
||||
@@ -22742,7 +22742,7 @@
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Domain name extracted from the first user's UPN Used for display purposes (e.g., contoso.com)"
|
||||
"doc": "Domain of the onboarding member's UPN (e.g. contoso.com), seeded once by the first member login and never from an external identity. Tenant identity, not only display: transactional mails render it, and cross-tenant grants resolve a never-signed-in UPN's home tenant through it."
|
||||
},
|
||||
{
|
||||
"name": "lastUpdated",
|
||||
@@ -22768,7 +22768,7 @@
|
||||
"nullable": true
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard."
|
||||
"doc": "UPN of the member whose first login onboarded this tenant. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten; an external identity (guest) never sets it. Activation promotes this UPN, and only this one, to Tenant Admin, so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel or a last writer into the TenantAdmins table. Null on rows that pre-date the field: such a tenant gets no promotion on activation."
|
||||
},
|
||||
{
|
||||
"name": "dpaVersion",
|
||||
|
||||
@@ -4961,13 +4961,13 @@ export interface TenantConfigPatchOutcomeResponse {
|
||||
export interface TenantConfiguration {
|
||||
/** Tenant ID (PartitionKey in Table Storage) */
|
||||
tenantId: string;
|
||||
/** Domain name extracted from the first user's UPN Used for display purposes (e.g., contoso.com) */
|
||||
/** Domain of the onboarding member's UPN (e.g. contoso.com), seeded once by the first member login and never from an external identity. Tenant identity, not only display: transactional mails render it, and cross-tenant grants resolve a never-signed-in UPN's home tenant through it. */
|
||||
domainName: string;
|
||||
/** When the configuration was last updated */
|
||||
lastUpdated: string;
|
||||
/** Updated by (user email or system) */
|
||||
updatedBy: string;
|
||||
/** UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard. */
|
||||
/** UPN of the member whose first login onboarded this tenant. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten; an external identity (guest) never sets it. Activation promotes this UPN, and only this one, to Tenant Admin, so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel or a last writer into the TenantAdmins table. Null on rows that pre-date the field: such a tenant gets no promotion on activation. */
|
||||
onboardedBy?: string | null;
|
||||
/** Version of the data processing agreement this tenant accepted — CurrentDpaVersion at that moment. Written once: with the onboarding when the first sign-in came through the get-started tick, otherwise when the portal's acceptance dialog is confirmed (DpaAcceptancePending). Never client-writable. Null on tenants onboarded before the field existed. */
|
||||
dpaVersion?: string | null;
|
||||
|
||||
@@ -40,6 +40,9 @@ const ENROLLMENT_LABELS: Record<string, string> = {
|
||||
enrollment_failed: "Enrollment failed",
|
||||
enrollment_complete: "Enrollment complete",
|
||||
enrollment_end: "Enrollment ended",
|
||||
preprovisioning_completed: "Pre-provisioning completed",
|
||||
preprovisioning_resumed: "Pre-provisioning resumed",
|
||||
preprovisioning_failed: "Pre-provisioning failed",
|
||||
session_stalled: "Session stalled",
|
||||
session_watch: "Session watch",
|
||||
consecutive_failures: "Consecutive failures",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
import {
|
||||
applyBadge,
|
||||
base64UrlToUint8Array,
|
||||
bufferToBase64Url,
|
||||
clearPushDb,
|
||||
@@ -172,6 +173,26 @@ export async function importDisplayedNotifications(): Promise<number> {
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the notifications whose entries the history holds from the notification centre, the way
|
||||
* a native app does once its messages were read (on Android this also clears the launcher dot,
|
||||
* which follows open notifications). Called after the import, so a notification the worker never
|
||||
* persisted is in the history first. One whose entry cannot be identified — a declarative push
|
||||
* the platform displayed itself carries no data — stays: the notification centre is its only record.
|
||||
*/
|
||||
export async function closeReadNotifications(historyIds: ReadonlySet<string>): Promise<void> {
|
||||
const registration = await getPushRegistration().catch(() => null);
|
||||
if (!registration || typeof registration.getNotifications !== "function") return;
|
||||
try {
|
||||
for (const notification of await registration.getNotifications()) {
|
||||
const id = entryFromNotification(notification, Date.now())?.id;
|
||||
if (id && historyIds.has(id)) notification.close();
|
||||
}
|
||||
} catch {
|
||||
// The notifications stay; the next open tries again.
|
||||
}
|
||||
}
|
||||
|
||||
export interface DisplayedNotification {
|
||||
/** The history id behind the notification, or null when the entry rule does not recognise it. */
|
||||
id: string | null;
|
||||
@@ -232,7 +253,7 @@ export async function collectPushDiagnostics(): Promise<PushDiagnostics> {
|
||||
return { worker, controlled: navigator.serviceWorker.controller !== null, displayed, historyCount, trace };
|
||||
}
|
||||
|
||||
/** Drops the subscription and every local trace; used by unpair, the wipe command and "device gone". */
|
||||
/** Drops the subscription, the badge and every local trace; used by unpair, the wipe command and "device gone". */
|
||||
export async function wipeLocalDevice(): Promise<void> {
|
||||
try {
|
||||
const registration = await getPushRegistration();
|
||||
@@ -241,6 +262,7 @@ export async function wipeLocalDevice(): Promise<void> {
|
||||
} catch {
|
||||
// The browser may already have dropped it; the server copy is what matters.
|
||||
}
|
||||
applyBadge(navigator, 0);
|
||||
await clearPushDb();
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export const META_KEYS = /** @type {const} */ ({
|
||||
* @property {string | null} tag
|
||||
* @property {string} scope
|
||||
* @property {boolean} generic true when the payload could not be read and the generic text was used
|
||||
* @property {boolean} [unread] set when the entry is stored, cleared once the history page shows it; the app badge counts these
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -376,6 +377,32 @@ export function pruneHistory(entries, nowMs, retentionDays = HISTORY_RETENTION_D
|
||||
return kept.map((item) => item.entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries the history page has not shown yet — what the app badge counts.
|
||||
* @param {HistoryEntry[]} entries
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function unreadIds(entries) {
|
||||
return entries.filter((entry) => entry.unread === true).map((entry) => entry.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the app icon badge to `count`, 0 clears it; fire and forget. Never awaited: inside a
|
||||
* declarative push event Safari's setAppBadge promise never settles, so awaiting it — or
|
||||
* handing it to waitUntil — would hold the event open. A call without a number clears the badge
|
||||
* on iOS, so a count is always passed. Without the Badging API nothing happens.
|
||||
* @param {{ setAppBadge?: (count: number) => Promise<void>, clearAppBadge?: () => Promise<void> } | null | undefined} nav the page's navigator or the worker's self.navigator
|
||||
* @param {number} count
|
||||
*/
|
||||
export function applyBadge(nav, count) {
|
||||
try {
|
||||
const pending = count > 0 ? nav?.setAppBadge?.(count) : nav?.clearAppBadge?.();
|
||||
pending?.catch(() => {});
|
||||
} catch {
|
||||
// A browser that throws instead of rejecting: no badge, nothing else changes.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's retention choice as the pages store it and the worker reads it: an integer 0–365
|
||||
* (0 = keep until the entry cap). Anything else — absent, non-numeric, fractional, out of range —
|
||||
@@ -726,7 +753,7 @@ export function readHistory() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts one entry (dedupe by id) and prunes what the retention rule no longer keeps.
|
||||
* Upserts one entry (dedupe by id), unread, and prunes what the retention rule no longer keeps.
|
||||
* @param {HistoryEntry} entry
|
||||
* @returns {Promise<boolean>} false when an entry with this id already existed
|
||||
*/
|
||||
@@ -734,12 +761,33 @@ export function writeHistoryEntry(entry) {
|
||||
return withStores("readwrite", async ({ history, meta }) => {
|
||||
const existing = await requestToPromise(history.get(entry.id));
|
||||
if (existing) return false;
|
||||
await requestToPromise(history.put(entry));
|
||||
await requestToPromise(history.put({ ...entry, unread: true }));
|
||||
await pruneStore(history, meta);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<number>} the stored entries the history page has not shown yet (the badge count)
|
||||
*/
|
||||
export function countUnread() {
|
||||
return withStores("readonly", async ({ history }) => unreadIds(/** @type {HistoryEntry[]} */ (await requestToPromise(history.getAll()))).length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks every unread entry read — the history page is showing them.
|
||||
* @returns {Promise<string[]>} the ids that were unread
|
||||
*/
|
||||
export function markAllRead() {
|
||||
return withStores("readwrite", async ({ history }) => {
|
||||
const all = /** @type {HistoryEntry[]} */ (await requestToPromise(history.getAll()));
|
||||
for (const entry of all) {
|
||||
if (entry.unread === true) await requestToPromise(history.put({ ...entry, unread: false }));
|
||||
}
|
||||
return unreadIds(all);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes one entry from the local history (the user's delete; nothing leaves the device).
|
||||
* @param {string} id
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
* lost. lib/__tests__/pushSw.test.ts pins the order and the settling.
|
||||
*/
|
||||
import {
|
||||
applyBadge,
|
||||
base64UrlToUint8Array,
|
||||
bufferToBase64Url,
|
||||
clearPushDb,
|
||||
countUnread,
|
||||
entryFragment,
|
||||
entryFromNotification,
|
||||
GENERIC_BODY,
|
||||
@@ -129,8 +131,10 @@ function describeError(error) {
|
||||
|
||||
/**
|
||||
* Best-effort history write plus its trace record, bounded in time as one; never rejects. The
|
||||
* record says what became of the entry ("added", "duplicate", "error: …"); when the database
|
||||
* never answers, the missing record is the evidence the status page shows.
|
||||
* record says what became of the entry ("added", "duplicate", "error: …") and the badge it set;
|
||||
* when the database never answers, the missing record is the evidence the status page shows.
|
||||
* A new entry raises the app badge to the unread count — applyBadge is fire and forget, its
|
||||
* promise never settles inside Safari's declarative push event.
|
||||
* @param {import("./sw-core.js").HistoryEntry} entry
|
||||
* @param {Record<string, unknown>} fields the record's fields besides id, type, result and shown
|
||||
* @param {Promise<string>} [shown] the display outcome — already in flight and never rejecting, awaited only for the record
|
||||
@@ -146,14 +150,24 @@ function persist(entry, fields, shown) {
|
||||
} catch (error) {
|
||||
result = "error: " + describeError(error);
|
||||
}
|
||||
if (result === "added") await notifyWindows("history").catch(() => {});
|
||||
/** @type {number | undefined} */
|
||||
let badge;
|
||||
if (result === "added") {
|
||||
await notifyWindows("history").catch(() => {});
|
||||
badge = await countUnread().catch(() => undefined);
|
||||
if (badge) applyBadge(self.navigator, badge);
|
||||
}
|
||||
const display = shown ? await shown.catch(() => "failed") : undefined;
|
||||
await writeTrace({ ...fields, id: entry.id, type: entry.type, result, shown: display });
|
||||
await writeTrace({ ...fields, id: entry.id, type: entry.type, result, shown: display, badge });
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
/** The wipe command: forget the device locally and drop the subscription. */
|
||||
/**
|
||||
* The wipe command: forget the device locally and drop the subscription. The badge is left to the
|
||||
* page's next open: a badge of 0 set inside the push event can hide the "unpaired" notification
|
||||
* on iOS 18.1–18.5 (FB16095572 on native pushes).
|
||||
*/
|
||||
async function wipe() {
|
||||
try {
|
||||
const subscription = await self.registration.pushManager.getSubscription();
|
||||
|
||||
Reference in New Issue
Block a user