Files
AutopilotMonitor/src/Push/AutopilotMonitor.Push.Tests/WebPushSubscriptionTests.cs
T
Oliver Kieselbach 04fae4fe86 Web Push channel: alerts to paired phones without a third-party messenger (#177)
* bump pointer: internal (ingest-session-row-reads RELEASED, measurement)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Web Push channel: alerts to paired phones without a third-party messenger (D-327 to D-330)

Provider WebhookProviderType.Push = 50 in both channel scopes. It carries no destination;
NotificationChannelDispatcher takes a NotificationScope from every caller and the push
transport resolves the scope's paired devices at send time (table-backed Admin/Operator,
GlobalAdmin with matching identity binding; Viewer and Global Reader never).

The receiver never signs in: a signed-in Admin/Operator creates a one-shot pairing code
(11 Crockford chars, hashed, 10 min, ETag one-shot, five-failure burn), the phone redeems
it anonymously, the portal session confirms. The phone's only credential is the device
token in X-Push-Device-Token, good for status, re-subscribe and unpair of its own row
(10 calls per minute per device; the anonymous pairing routes 10 per minute per IP).

Payloads are RFC 8291 per device (new BCL-only library src/Push with the RFC vector pinned
byte for byte) with a VAPID key ring and the kid on every device row; the projection reads
an allow-list only (never Sections, DataJson, Failure Reason, Last Failure or the ops
message), serial masked, 2 KB. 404/410 mark a device Stale instead of deleting it and the
receiver re-arms it on its next open (the same PUT lifts a delivery-failures pause); devices
pause when their owner stops signing in (PushOwners stamp in auth/me) through one pause path
and resume by themselves; an owner without a stamp is paused, never deleted outright; role
loss revokes with a wipe push and deletes the row (there is no Revoked state). PushDevices
and PushPairingGrants are credential-bearing: raw-table deny-list, no backup, redacted
dependency rows.

Receiver app under /push/ (module service worker, Declarative Web Push shape, local history,
pairing and status pages, manifest + icons), portal UI (Push provider in the channel editor,
pairing dialog with QR code, device lists for tenant and platform scope, "Notify me when
done" on the session page — the watch row records the watcher's own scopes, so a Global
Admin watching a customer session is notified on the platform-paired phone), 22 catalogued
routes, ops events PushDeliveryFailed and PushEndpointRefused (deduplicated per instance),
maintenance sweep inside the existing run, settings template.

The tenant-scope provider stays Global-Admin-only until the customer release (same
ValidateModel gate as Telegram). Backend 7371, library 125, MCP 997, web 2061 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* bump pointer: internal, .claude/commands, infra (push-relay: plan, D-327 to D-330, markers, VAPID generator)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* bump pointer: internal (push-relay: commit step ticked)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop sprintf-js from both lockfiles (Dependabot #114, #117)

GHSA-hp3w-g68c-fv3c (sprintf-js <= 1.1.3, denial of service through unbounded
precision specifiers) has no patched release, so both chains are cut instead.

MCP (#117): sprintf-js arrived via @huggingface/transformers -> onnxruntime-node
-> global-agent 3 -> roarr. global-agent is only loaded by onnxruntime-node's
CUDA install script, which the image never runs (npm ci --ignore-scripts). The
override global-agent ^4.1.3 drops roarr; 4.x keeps the bootstrap export that
script calls, and onnxruntime-node 1.30 depends on the same range.

Rules tooling (#114): ajv-cli 5.0.0 (no release since 2023) pulled js-yaml 3
-> argparse 1 -> sprintf-js. rules/scripts/validate.js now checks the four rule
sets against their schemas with ajv (draft 2020-12) and ajv-formats directly,
and combine-rules.yml runs it in one step instead of four ajv-cli loops. The
lockfile shrinks from 26 to 6 packages; the fast-json-patch override and the
legacy-peer-deps .npmrc (only there for ajv-cli's ts-node peer) are gone.

Verified: validate.js accepts all 149 rule files plus guardrails.json exactly
as ajv-cli did and rejects a corrupted copy (missing field, broken JSON, U+2028
in a guardrail value) with exit 1; clean npm ci + combine.js leave the
generated outputs unchanged; MCP build and 997 tests green; zizmor clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bump pointer: internal, .claude/commands (validate.js replaces ajv-cli: B-gzv, rule-builder fallback)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bump sharp, proxy-addr and source-map-js past fresh advisories (MCP, Web)

- sharp 0.35.4 -> 0.35.5 (GHSA-wq5f-xc86-pv6w, librsvg): the existing sharp
  override in both projects now floors at ^0.35.5 so the fixed build cannot be
  undercut by a parent range.
- proxy-addr 2.0.7 -> 2.0.8 (GHSA-jqcg-44mw-7w3h, via express; MCP only) and
  source-map-js 1.2.1 -> 1.2.2 (GHSA-68fv-2mgg-jv7q, via postcss) are
  lockfile-only bumps inside the ranges their parents declare.

No other lockfile entry moved. npm audit in the MCP server is clean.
Verified: MCP build and 997 tests green; Web tsc clean, 2061 tests green,
next build (static export) succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bump pointer: internal (push-relay: CORS verified, cert-exclusion prerequisite, EU app facts)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Web: Next.js 16.3.6 -> 16.3.8 (six advisories)

16.3.8 closes GHSA-3w37-wq28-93x7, GHSA-4jqv-mc3x-m676, GHSA-39w2-rjm5-chcv,
GHSA-f87g-xv8r-7p7x, GHSA-mcj8-r9mp-w47p and GHSA-cjq9-62q9-8jv4 (affected
16.0.0 - 16.3.7). Under output:"export" the cache, ISR and image-optimization
paths never run on the SWA; the dev-server disclosure is local-only. The patch
is taken anyway. next, eslint-config-next and @next/bundle-analyzer stay
aligned and floor at ^16.3.8; the lockfile is pinned to 16.3.8, not the 16.4.0
minor the caret would resolve to.

What npm audit still reports (braces, micromatch, chokidar, fast-glob,
postcss-selector-parser) comes through Tailwind 3 and eslint-config-next,
build-time only; braces has no fix, the rest needs Tailwind 4 (backlog).

Verified: tsc clean, eslint 0 findings, 2061 tests green, next build
(16.3.8, 102 static pages) succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* bump pointer: internal (B-dzs lockfile merge note, B-hxz braces chain)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:48:42 +02:00

133 lines
4.3 KiB
C#

using System.Security.Cryptography;
using AutopilotMonitor.Push;
namespace AutopilotMonitor.Push.Tests;
public class WebPushSubscriptionTests
{
private const string Endpoint = "https://fcm.googleapis.com/wp/dGVzdA";
private static (string P256dh, string Auth) ValidKeys()
{
var (point, key) = TestKeys.UserAgentKey();
key.Dispose();
return (TestKeys.Base64Url(point), TestKeys.Base64Url(RandomNumberGenerator.GetBytes(16)));
}
[Fact]
public void Valid_input_parses()
{
var (p256dh, auth) = ValidKeys();
Assert.True(WebPushSubscription.TryParse(Endpoint, p256dh, auth, out var subscription, out var error));
Assert.Null(error);
Assert.Equal(new Uri(Endpoint), subscription.Endpoint);
Assert.Equal(65, subscription.P256dh.Length);
Assert.Equal(0x04, subscription.P256dh.Span[0]);
Assert.Equal(16, subscription.Auth.Length);
Assert.Equal(TestKeys.Decode(p256dh), subscription.P256dh.ToArray());
Assert.Equal(TestKeys.Decode(auth), subscription.Auth.ToArray());
}
[Fact]
public void Padded_base64_is_tolerated()
{
var (p256dh, auth) = ValidKeys();
Assert.True(WebPushSubscription.TryParse(Endpoint, p256dh, auth + "==", out _, out _));
}
[Theory]
[InlineData(64)]
[InlineData(66)]
[InlineData(33)]
public void P256dh_with_wrong_length_is_rejected(int length)
{
var (_, auth) = ValidKeys();
var bytes = new byte[length];
bytes[0] = 0x04;
Assert.False(WebPushSubscription.TryParse(Endpoint, TestKeys.Base64Url(bytes), auth, out var subscription, out var error));
Assert.Null(subscription);
Assert.Contains("p256dh", error);
}
[Fact]
public void P256dh_without_the_0x04_prefix_is_rejected()
{
var (p256dh, auth) = ValidKeys();
var bytes = TestKeys.Decode(p256dh);
bytes[0] = 0x02;
Assert.False(WebPushSubscription.TryParse(Endpoint, TestKeys.Base64Url(bytes), auth, out _, out var error));
Assert.Contains("p256dh", error);
}
[Fact]
public void P256dh_that_is_not_on_the_curve_is_rejected()
{
var (p256dh, auth) = ValidKeys();
var bytes = TestKeys.Decode(p256dh);
bytes[64] ^= 0x01;
Assert.False(WebPushSubscription.TryParse(Endpoint, TestKeys.Base64Url(bytes), auth, out _, out var error));
Assert.Contains("p256dh", error);
}
[Theory]
[InlineData("not*base64url")]
[InlineData("")]
[InlineData(null)]
public void P256dh_that_is_not_base64url_is_rejected(string? p256dh)
{
var (_, auth) = ValidKeys();
Assert.False(WebPushSubscription.TryParse(Endpoint, p256dh, auth, out _, out var error));
Assert.Contains("p256dh", error);
}
[Theory]
[InlineData("not*base64url")]
[InlineData("")]
[InlineData(null)]
public void Auth_that_is_not_base64url_is_rejected(string? auth)
{
var (p256dh, _) = ValidKeys();
Assert.False(WebPushSubscription.TryParse(Endpoint, p256dh, auth, out _, out var error));
Assert.Contains("auth secret", error);
}
[Theory]
[InlineData(15)]
[InlineData(17)]
[InlineData(32)]
public void Auth_with_wrong_length_is_rejected(int length)
{
var (p256dh, _) = ValidKeys();
Assert.False(WebPushSubscription.TryParse(Endpoint, p256dh, TestKeys.Base64Url(new byte[length]), out _, out var error));
Assert.Equal("The auth secret must be 16 bytes.", error);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("/relative/path")]
[InlineData("https://attacker.push.apple.com.evil.invalid/x")]
public void Endpoint_that_fails_the_policy_is_rejected(string? endpoint)
{
var (p256dh, auth) = ValidKeys();
Assert.False(WebPushSubscription.TryParse(endpoint, p256dh, auth, out _, out var error));
Assert.StartsWith("Endpoint", error);
if (!string.IsNullOrEmpty(endpoint))
{
Assert.DoesNotContain(endpoint, error);
}
}
[Fact]
public void Errors_never_echo_the_input()
{
const string bogus = "ZZZZ*bogus-key-material";
Assert.False(WebPushSubscription.TryParse(Endpoint, bogus, bogus, out _, out var error));
Assert.DoesNotContain(bogus, error);
}
}