shoc-backend/SeaHavenIndustries.Tests/ShocWebhookVectorTests.cs
2026-07-27 16:33:06 -03:00

151 lines
5.9 KiB
C#

using System.Text;
using System.Text.Json;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
namespace SeaHavenIndustries.Tests;
public sealed class ShocWebhookVectorTests
{
private const string FixturePath = "Fixtures/shoc-webhook-test-vectors.json";
[Fact]
public async Task All_four_shared_signing_vectors_pass_signature_verification()
{
var vectors = await LoadVectorsAsync();
Assert.True(vectors.Count >= 4, "fixture must ship at least the four shared vectors");
foreach (var vector in vectors)
{
var body = Encoding.UTF8.GetBytes(vector.Body);
var timestamp = vector.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture);
var signature = "v1=" + vector.ExpectedSignature;
var at = DateTimeOffset.FromUnixTimeSeconds(vector.Timestamp);
var data = new RecordingDataService();
var service = new WorkOrderWebhookService(
new VectorSecretProvider(vector.Kid, vector.SecretHex),
data,
new StaticOptionsMonitor<WorkOrderWebhookOptions>(new WorkOrderWebhookOptions
{
Enabled = true,
AllowedClockSkewSeconds = 300,
SecretId = "workorder-ingest/shoc-webhook-hmac"
}),
new FixedTimeProvider(at),
NullLogger<WorkOrderWebhookService>.Instance);
var result = await service.ProcessAsync(
new WorkOrderWebhookRequest(timestamp, vector.Kid, signature, body),
CancellationToken.None);
Assert.True(
result.Status != WorkOrderWebhookStatus.Unauthorized,
$"vector {vector.Kid}@{vector.Timestamp} failed signature verification");
}
}
[Fact]
public async Task Valid_envelope_vectors_are_applied_and_invalid_body_vectors_are_invalid_envelope()
{
var vectors = await LoadVectorsAsync();
var byIndex = vectors.Take(4).ToList();
var applied = await RunVectorAsync(byIndex[0]);
Assert.Equal(WorkOrderWebhookStatus.Applied, applied.Status);
var comment = await RunVectorAsync(byIndex[1]);
Assert.Equal(WorkOrderWebhookStatus.Applied, comment.Status);
var empty = await RunVectorAsync(byIndex[2]);
Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, empty.Status);
var noSource = await RunVectorAsync(byIndex[3]);
Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, noSource.Status);
}
private static async Task<WorkOrderWebhookResult> RunVectorAsync(Vector vector)
{
var body = Encoding.UTF8.GetBytes(vector.Body);
var timestamp = vector.Timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture);
var signature = "v1=" + vector.ExpectedSignature;
var at = DateTimeOffset.FromUnixTimeSeconds(vector.Timestamp);
var data = new RecordingDataService();
var service = new WorkOrderWebhookService(
new VectorSecretProvider(vector.Kid, vector.SecretHex),
data,
new StaticOptionsMonitor<WorkOrderWebhookOptions>(new WorkOrderWebhookOptions
{
Enabled = true,
AllowedClockSkewSeconds = 300,
SecretId = "workorder-ingest/shoc-webhook-hmac"
}),
new FixedTimeProvider(at),
NullLogger<WorkOrderWebhookService>.Instance);
return await service.ProcessAsync(
new WorkOrderWebhookRequest(timestamp, vector.Kid, signature, body),
CancellationToken.None);
}
private static async Task<List<Vector>> LoadVectorsAsync()
{
await using var stream = File.OpenRead(FixturePath);
var document = await JsonDocument.ParseAsync(stream);
var result = new List<Vector>();
foreach (var item in document.RootElement.GetProperty("vectors").EnumerateArray())
{
result.Add(new Vector(
item.GetProperty("kid").GetString()!,
item.GetProperty("secret_hex").GetString()!,
item.GetProperty("timestamp").GetInt64(),
item.GetProperty("body").GetString()!,
item.GetProperty("expected_signature").GetString()!));
}
return result;
}
private sealed record Vector(
string Kid,
string SecretHex,
long Timestamp,
string Body,
string ExpectedSignature);
private sealed class VectorSecretProvider : IWorkOrderWebhookSecretProvider
{
private readonly string _kid;
private readonly byte[] _secret;
public VectorSecretProvider(string kid, string secretHex)
{
_kid = kid;
_secret = Encoding.UTF8.GetBytes(secretHex);
}
public Task<WorkOrderWebhookSecretResult> GetSecretAsync(
string keyId,
CancellationToken cancellationToken) =>
Task.FromResult(string.Equals(keyId, _kid, StringComparison.Ordinal)
? new WorkOrderWebhookSecretResult(
WorkOrderWebhookSecretStatus.Found,
(byte[])_secret.Clone())
: new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey));
}
private sealed class RecordingDataService : IWorkOrderWebhookDataService
{
public Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
WorkOrderWebhookMutation mutation,
CancellationToken cancellationToken) =>
Task.FromResult(new WorkOrderWebhookPersistenceResult(
WorkOrderWebhookPersistenceStatus.Applied));
}
}