mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
18b28260a7
output:'export' + trailingSlash: the SWA now serves plain static files; the
managed SSR runtime — the portal's last cold-start source ("Backend call
failure" when a cold start exceeded SWA's 45s limit) — no longer exists.
- Detail routes are query-string based (/sessions?id=, /sessions/inspector?id=,
/diagnosis?id=, /apps/detail?name=, /admin/backups/detail?id=,
/admin/customs-archive/detail?tenantId=&rowKey=). Single-definition builders
in lib/routes.ts; all 21 link sites migrated, duplicate sessionHref helpers
consolidated. Suspense boundaries added everywhere useSearchParams drives
page identity (hard export-build errors otherwise).
- 9 [section] families prerender via server shells (generateStaticParams from
the *NavSections registries, dynamicParams=false, client bodies extracted to
SectionClient) — 37 pages; server redirect() index pages became
ClientRedirect shells. robots/sitemap/og-image routes force-static.
- Legacy PATH-shaped deep links are preserved FOREVER (sent Teams/Slack/
webhook notifications cannot be edited): SWA wildcard rewrites serve the
family page and the globally mounted LegacyPathRedirect rewrites to the
canonical query URL, preserving query + #event hashes (mapping pinned by
lib/__tests__/legacyPathRedirect.test.ts).
- middleware.ts deleted → client-side HostRoutingGuard (prefix list moved to
lib/hostRouting.ts). Legacy /go/[code] route + bootstrapValidation deleted —
BootstrapSessions table verified free of active pre-cutover sessions.
- staticwebapp.config.json now carries production routing: 19 docs/marketing
redirects (order-sensitive), legacy rewrites (exact before wildcard), 404
override, all security headers incl. literalized prod CSP (no unsafe-eval),
Content-Type routes for the extension-less og images. Guarded by the new
utils/__tests__/swaConfig.guard.test.ts. Maintenance mode is now a
first-routes-entry insert, never a whole-file swap (README rewritten).
- Backend notification deep links go through Constants.PortalSessionUrl
(query shape, escaped, pinned); 3 producers migrated.
- deploy-web.yml uploads out/ with is_static_export.
Export build: 79 static pages, no go/ output. Web: tsc clean, vitest 53
files green (618 incl. new guards; 2 deleted legacy suites). Backend:
solution build clean, 3622/3622 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
154 lines
6.0 KiB
C#
154 lines
6.0 KiB
C#
using AutopilotMonitor.Functions.Functions.Bootstrap;
|
|
using AutopilotMonitor.Functions.Middleware;
|
|
using AutopilotMonitor.Functions.Security;
|
|
using AutopilotMonitor.Shared;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Seam tests for <c>GET /api/bootstrap/go/{code}</c> (GetBootstrapScriptFunction) —
|
|
/// the OOBE bootstrap script endpoint reached by customers as
|
|
/// <c>irm https://go.autopilotmonitor.com/{code} | iex</c>. Following the project
|
|
/// convention (see AppInsightsQueryFunctionTests), the HTTP entrypoint itself is not
|
|
/// faked; the compilable seams — code-format gate, policy-catalog registration, and
|
|
/// the no-store cache classification — are pinned here. Script generation and value
|
|
/// validation have their own suites.
|
|
/// </summary>
|
|
public class GetBootstrapScriptFunctionTests
|
|
{
|
|
private const string Route = "/api/bootstrap/go/abcd";
|
|
|
|
// ── Code-format gate: 4-10 alphanumeric, checked before any service call.
|
|
// Table ported from the vitest suite of the original Next.js route. ──
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("abc")] // too short
|
|
[InlineData("abcdefghijk")] // too long
|
|
[InlineData("ab cd")]
|
|
[InlineData("../etc")]
|
|
[InlineData("ab%2Fcd")]
|
|
[InlineData("abc$")]
|
|
[InlineData("ab'cd")]
|
|
[InlineData("ab.cd")]
|
|
public void Rejects_invalid_code_formats(string code)
|
|
{
|
|
Assert.False(GetBootstrapScriptFunction.IsValidCodeFormat(code));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("abcd")]
|
|
[InlineData("ABC123")]
|
|
[InlineData("a1b2c3d4e5")]
|
|
public void Accepts_valid_code_formats(string code)
|
|
{
|
|
Assert.True(GetBootstrapScriptFunction.IsValidCodeFormat(code));
|
|
}
|
|
|
|
// ── Catalog registration: anonymous (OOBE devices have no JWT, no cert),
|
|
// JWT-exempt via the catalog-derived rule. Unregistered routes fail closed,
|
|
// so this pins reachability. ──
|
|
[Fact]
|
|
public void Route_is_registered_as_PublicAnonymous()
|
|
{
|
|
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", Route);
|
|
|
|
Assert.NotNull(entry);
|
|
Assert.Equal(EndpointPolicy.PublicAnonymous, entry!.Policy);
|
|
}
|
|
|
|
[Fact]
|
|
public void Route_is_jwt_exempt()
|
|
{
|
|
Assert.True(AuthenticationMiddleware.SkipsJwtValidation("GET", Route));
|
|
}
|
|
|
|
// ── The script body inlines the bearer token — must never be cached. ──
|
|
[Fact]
|
|
public void Route_is_classified_no_store()
|
|
{
|
|
Assert.True(NoStoreCacheMiddleware.IsSensitive(Route));
|
|
}
|
|
|
|
// ── AFD-aware rate-limit extraction: X-Azure-ClientIP is honored only when
|
|
// X-Azure-FDID carries EXACTLY our profile ID. Multi-value headers mean
|
|
// someone appended to what AFD set — untrusted, fail closed. ──
|
|
private const string Fdid = "3490f60d-3639-495f-902e-681b9324d2ba";
|
|
|
|
[Theory]
|
|
[InlineData(new[] { Fdid }, true)]
|
|
[InlineData(new[] { "3490F60D-3639-495F-902E-681B9324D2BA" }, true)] // case-insensitive
|
|
[InlineData(new[] { " " + Fdid + " " }, true)] // trimmed
|
|
[InlineData(new[] { "other-profile-id" }, false)]
|
|
[InlineData(new[] { Fdid, Fdid }, false)] // two headers
|
|
[InlineData(new[] { Fdid + "," + Fdid }, false)] // comma-appended
|
|
[InlineData(new[] { "spoofed," + Fdid }, false)]
|
|
[InlineData(new string[0], false)]
|
|
[InlineData(new[] { "" }, false)]
|
|
public void FdidMatches_requires_exactly_our_profile_id(string[] headerValues, bool expected)
|
|
{
|
|
Assert.Equal(expected, ClientIpExtractor.FdidMatches(headerValues, Fdid));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the customer-facing bootstrap URL produced by CreateBootstrapSessionFunction —
|
|
/// previously unguarded, which made a silent origin change possible. The literal here
|
|
/// is a deliberate independent oracle per the url-registry doctrine (test files are
|
|
/// excluded from HardcodedUrlGuardTests).
|
|
/// </summary>
|
|
public class CreateBootstrapSessionUrlShapeTests
|
|
{
|
|
[Fact]
|
|
public void BootstrapGoBaseUrl_is_the_go_domain()
|
|
{
|
|
Assert.Equal("https://go.autopilotmonitor.com", Constants.BootstrapGoBaseUrl);
|
|
}
|
|
|
|
// ── Notification session deep link (Teams/Slack/webhook "Open session"): the
|
|
// query-string shape the static-export portal understands. Literal is a
|
|
// deliberate independent oracle; the legacy /sessions/{id} path shape is
|
|
// rewritten client-side by the portal forever. ──
|
|
[Fact]
|
|
public void PortalSessionUrl_is_the_query_string_shape()
|
|
{
|
|
Assert.Equal(
|
|
"https://portal.autopilotmonitor.com/sessions?id=abc-123",
|
|
Constants.PortalSessionUrl("abc-123"));
|
|
}
|
|
|
|
[Fact]
|
|
public void PortalSessionUrl_escapes_the_session_id()
|
|
{
|
|
Assert.Equal(
|
|
"https://portal.autopilotmonitor.com/sessions?id=a%26b%3Dc",
|
|
Constants.PortalSessionUrl("a&b=c"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateBootstrapSession_builds_the_url_from_the_go_constant()
|
|
{
|
|
// Source-level pin (the URL expression sits inside the HTTP entrypoint with no
|
|
// injectable seam): the create function must derive BootstrapUrl from
|
|
// BootstrapGoBaseUrl + "/{shortCode}" — not from WebsiteBaseUrl, and without a
|
|
// path segment between host and code.
|
|
var repoRoot = FindRepoRoot();
|
|
var source = File.ReadAllText(Path.Combine(
|
|
repoRoot, "src", "Backend", "AutopilotMonitor.Functions",
|
|
"Functions", "Bootstrap", "CreateBootstrapSessionFunction.cs"));
|
|
|
|
Assert.Contains("$\"{Constants.BootstrapGoBaseUrl}/{session.ShortCode}\"", source);
|
|
Assert.DoesNotContain("WebsiteBaseUrl}/go/", 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;
|
|
}
|
|
}
|