Heimdall complete page: run its real script under Node in tests
A committed harness (Fixtures/complete-page-harness.cjs) executes the served page's inline script against a simulated browser; tests pin that the fragment is dropped before the post, an error status never submits, and the code is read only from the fragment. Node is required on PATH; a missing Node fails, it does not skip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d695674359
commit
cf4e5b4327
3 changed files with 172 additions and 0 deletions
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Fixtures\*.cjs" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
64
tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs
Normal file
64
tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Runs the complete page's real inline script against a simulated browser, once per scenario, and prints
|
||||
// what it did as JSON: the ordered events, and the address and form values at the moment of the submit.
|
||||
// Usage: node complete-page-harness.cjs <page.html>
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
|
||||
const html = fs.readFileSync(process.argv[2], "utf8");
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||
if (scripts.length !== 1) {
|
||||
throw new Error(`expected exactly one inline script, found ${scripts.length}`);
|
||||
}
|
||||
const script = scripts[0][1];
|
||||
|
||||
function run(name, search, hash) {
|
||||
const events = [];
|
||||
let submit = null;
|
||||
const location = {
|
||||
pathname: "/auth/heimdall/complete",
|
||||
search,
|
||||
hash,
|
||||
get href() {
|
||||
return this.pathname + this.search + this.hash;
|
||||
},
|
||||
replace(url) {
|
||||
events.push(`replace:${url}`);
|
||||
},
|
||||
};
|
||||
const history = {
|
||||
replaceState(_state, _title, url) {
|
||||
events.push(`replaceState:${url}`);
|
||||
const next = new URL(url, "https://bifrost.test" + location.href);
|
||||
location.pathname = next.pathname;
|
||||
location.search = next.search;
|
||||
location.hash = next.hash;
|
||||
},
|
||||
};
|
||||
const form = {
|
||||
elements: { attempt: { value: "" }, code: { value: "" } },
|
||||
submit() {
|
||||
events.push("submit");
|
||||
submit = {
|
||||
attempt: form.elements.attempt.value,
|
||||
code: form.elements.code.value,
|
||||
href: location.href,
|
||||
hash: location.hash,
|
||||
};
|
||||
},
|
||||
};
|
||||
const document = { getElementById: (id) => (id === "complete" ? form : null) };
|
||||
vm.runInNewContext(script, { URLSearchParams, location, history, document });
|
||||
return { name, events, submit };
|
||||
}
|
||||
|
||||
const complete = "&heimdall_completion_code=CODE123";
|
||||
console.log(
|
||||
JSON.stringify([
|
||||
run("success", "?attempt=NONCE", "#heimdall_status=success" + complete),
|
||||
run("success-with-decoy-query-code", "?attempt=NONCE&heimdall_completion_code=QCODE", "#heimdall_status=success" + complete),
|
||||
run("error-status-with-code", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied" + complete),
|
||||
run("no-code", "?attempt=NONCE", "#heimdall_status=success"),
|
||||
run("no-attempt", "", "#heimdall_status=success" + complete),
|
||||
run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""),
|
||||
]),
|
||||
);
|
||||
107
tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs
Normal file
107
tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the complete page's inline script, as the server serves it, under Node against a simulated browser
|
||||
/// (Fixtures/complete-page-harness.cjs). Node is required: a missing Node fails these tests, it does not skip them.
|
||||
/// </summary>
|
||||
public sealed class HeimdallCompletePageScriptTests
|
||||
{
|
||||
private const string ErrorTarget = "replace:/?auth=heimdall-error";
|
||||
|
||||
private sealed record Run(string Name, string[] Events, Submit? Submit);
|
||||
|
||||
private sealed record Submit(string Attempt, string Code, string Href, string Hash);
|
||||
|
||||
private static async Task<Dictionary<string, Run>> RunScenariosAsync()
|
||||
{
|
||||
using var host = new HeimdallHost();
|
||||
using var client = host.NewClient();
|
||||
var page = await client.GetStringAsync("/auth/heimdall/complete");
|
||||
var pagePath = Path.Combine(Path.GetTempPath(), $"bifrost-complete-{Guid.NewGuid():N}.html");
|
||||
await File.WriteAllTextAsync(pagePath, page);
|
||||
try
|
||||
{
|
||||
var harness = Path.Combine(AppContext.BaseDirectory, "Fixtures", "complete-page-harness.cjs");
|
||||
var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
start.ArgumentList.Add(harness);
|
||||
start.ArgumentList.Add(pagePath);
|
||||
Process process;
|
||||
try
|
||||
{
|
||||
process = Process.Start(start)!;
|
||||
}
|
||||
catch (Win32Exception error)
|
||||
{
|
||||
throw new InvalidOperationException("Node must be on PATH to run the complete page's script: " + error.Message, error);
|
||||
}
|
||||
|
||||
using (process)
|
||||
{
|
||||
var output = process.StandardOutput.ReadToEndAsync();
|
||||
var errors = process.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await errors);
|
||||
var runs = JsonSerializer.Deserialize<Run[]>(await output, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
|
||||
return runs.ToDictionary(x => x.Name);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pagePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_fragment_is_dropped_from_the_address_before_the_code_is_posted()
|
||||
{
|
||||
var runs = await RunScenariosAsync();
|
||||
|
||||
foreach (var name in new[] { "success", "success-with-decoy-query-code" })
|
||||
{
|
||||
var run = runs[name];
|
||||
Assert.Equal(2, run.Events.Length);
|
||||
Assert.StartsWith("replaceState:/auth/heimdall/complete?attempt=NONCE", run.Events[0]);
|
||||
Assert.DoesNotContain("#", run.Events[0]);
|
||||
Assert.DoesNotContain("CODE123", run.Events[0]);
|
||||
Assert.Equal("submit", run.Events[1]);
|
||||
Assert.Equal("", run.Submit!.Hash);
|
||||
Assert.DoesNotContain("CODE123", run.Submit.Href);
|
||||
Assert.DoesNotContain("#", run.Submit.Href);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_code_is_read_only_from_the_fragment()
|
||||
{
|
||||
var runs = await RunScenariosAsync();
|
||||
|
||||
Assert.Equal(new Submit("NONCE", "CODE123", "/auth/heimdall/complete?attempt=NONCE", ""), runs["success"].Submit);
|
||||
Assert.Equal("CODE123", runs["success-with-decoy-query-code"].Submit!.Code);
|
||||
|
||||
var queryOnly = runs["code-in-query-only"];
|
||||
Assert.Null(queryOnly.Submit);
|
||||
Assert.DoesNotContain("submit", queryOnly.Events);
|
||||
Assert.Equal(ErrorTarget, queryOnly.Events[^1]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("error-status-with-code")]
|
||||
[InlineData("no-code")]
|
||||
[InlineData("no-attempt")]
|
||||
public async Task A_failed_or_incomplete_handoff_never_submits_and_leaves_through_the_error_redirect(string scenario)
|
||||
{
|
||||
var run = (await RunScenariosAsync())[scenario];
|
||||
|
||||
Assert.Null(run.Submit);
|
||||
Assert.DoesNotContain("submit", run.Events);
|
||||
Assert.Equal(ErrorTarget, run.Events[^1]);
|
||||
Assert.Contains(run.Events, x => x.StartsWith("replaceState:", StringComparison.Ordinal) && !x.Contains('#'));
|
||||
Assert.DoesNotContain("CODE123", string.Join("\n", run.Events));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue