mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
151 lines
5.9 KiB
C#
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));
|
|
}
|
|
}
|