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(new WorkOrderWebhookOptions { Enabled = true, AllowedClockSkewSeconds = 300, SecretId = "workorder-ingest/shoc-webhook-hmac" }), new FixedTimeProvider(at), NullLogger.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 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(new WorkOrderWebhookOptions { Enabled = true, AllowedClockSkewSeconds = 300, SecretId = "workorder-ingest/shoc-webhook-hmac" }), new FixedTimeProvider(at), NullLogger.Instance); return await service.ProcessAsync( new WorkOrderWebhookRequest(timestamp, vector.Kid, signature, body), CancellationToken.None); } private static async Task> LoadVectorsAsync() { await using var stream = File.OpenRead(FixturePath); var document = await JsonDocument.ParseAsync(stream); var result = new List(); 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 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 ApplyAsync( WorkOrderWebhookMutation mutation, CancellationToken cancellationToken) => Task.FromResult(new WorkOrderWebhookPersistenceResult( WorkOrderWebhookPersistenceStatus.Applied)); } }