diff --git a/tests/E4A.PostGuard.Tests/CryptifyClientTests.cs b/tests/E4A.PostGuard.Tests/CryptifyClientTests.cs new file mode 100644 index 0000000..c11e716 --- /dev/null +++ b/tests/E4A.PostGuard.Tests/CryptifyClientTests.cs @@ -0,0 +1,243 @@ +using System.Net; +using E4A.PostGuard.Api; +using E4A.PostGuard.Exceptions; +using E4A.PostGuard.Models; +using E4A.PostGuard.Tests.TestHelpers; + +namespace E4A.PostGuard.Tests; + +public class CryptifyClientTests +{ + private const string BaseUrl = "https://cryptify.postguard.eu"; + private const int ChunkSize = 1024 * 1024; // mirrors CryptifyClient.ChunkSize + + private static readonly Dictionary InitToken = + new() { ["cryptifytoken"] = "token-0" }; + + private static RecipientBuilder Email(string email) => + new(email, RecipientBaseType.Email); + + private static (CryptifyClient Client, RecordingHttpMessageHandler Handler) NewClient() + { + var handler = new RecordingHttpMessageHandler(); + return (new CryptifyClient(new HttpClient(handler), BaseUrl), handler); + } + + private static HttpResponseMessage TokenResponse(string token) + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""), + }; + response.Headers.TryAddWithoutValidation("cryptifytoken", token); + return response; + } + + [Fact] + public async Task SingleChunk_WiresInitChunkFinalize() + { + var (client, handler) = NewClient(); + handler + .EnqueueJson("""{"uuid":"abc-123"}""", headers: InitToken) + .Enqueue(TokenResponse("token-1")) + .Enqueue(TokenResponse("token-2")); + + var data = new byte[100]; + var uuid = await client.UploadAsync(data, [Email("alice@example.com")], notify: null); + + Assert.Equal("abc-123", uuid); + Assert.Equal(3, handler.Requests.Count); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal($"{BaseUrl}/fileupload/init", handler.Requests[0].Uri.ToString()); + + Assert.Equal(HttpMethod.Put, handler.Requests[1].Method); + Assert.Equal($"{BaseUrl}/fileupload/abc-123", handler.Requests[1].Uri.ToString()); + + Assert.Equal(HttpMethod.Post, handler.Requests[2].Method); + Assert.Equal($"{BaseUrl}/fileupload/finalize/abc-123", handler.Requests[2].Uri.ToString()); + } + + [Fact] + public async Task ChunkContentRange_IsFormattedPerChunk() + { + var (client, handler) = NewClient(); + handler + .EnqueueJson("""{"uuid":"u"}""", headers: InitToken) + .Enqueue(TokenResponse("t1")) + .Enqueue(TokenResponse("t2")) + .Enqueue(TokenResponse("t3")); + + // 1.5 chunks → two PUTs. + var total = ChunkSize + 100; + var uuid = await client.UploadAsync(new byte[total], [Email("a@b.com")], notify: null); + Assert.Equal("u", uuid); + + var puts = handler.Requests.Where(r => r.Method == HttpMethod.Put).ToList(); + Assert.Equal(2, puts.Count); + + // NOTE: the current SDK emits an exclusive range end (`end = offset + len`). + // encryption4all/postguard-dotnet#34 changes this to an inclusive end; that + // PR must update these two assertions when it lands. + Assert.Equal($"bytes 0-{ChunkSize}/*", puts[0].ContentRange); + Assert.Equal($"bytes {ChunkSize}-{total}/*", puts[1].ContentRange); + } + + [Fact] + public async Task Finalize_SendsTotalSizeContentRange() + { + var (client, handler) = NewClient(); + handler + .EnqueueJson("""{"uuid":"u"}""", headers: InitToken) + .Enqueue(TokenResponse("t1")) + .Enqueue(TokenResponse("t2")); + + var total = 4242; + await client.UploadAsync(new byte[total], [Email("a@b.com")], notify: null); + + var finalize = handler.Requests.Single(r => r.Uri.ToString().Contains("/finalize/")); + Assert.Equal($"bytes */{total}", finalize.ContentRange); + } + + [Fact] + public async Task TokenRotates_AcrossChunksAndFinalize() + { + var (client, handler) = NewClient(); + handler + .EnqueueJson("""{"uuid":"u"}""", headers: InitToken) // init -> token-0 + .Enqueue(TokenResponse("token-1")) // chunk 1 -> token-1 + .Enqueue(TokenResponse("token-2")) // chunk 2 -> token-2 + .Enqueue(TokenResponse("token-3")); // finalize ok + + await client.UploadAsync(new byte[ChunkSize + 1], [Email("a@b.com")], notify: null); + + // init carries no token; each subsequent request carries the token from + // the previous response. + Assert.Null(handler.Requests[0].CryptifyToken); + Assert.Equal("token-0", handler.Requests[1].CryptifyToken); // first chunk uses init token + Assert.Equal("token-1", handler.Requests[2].CryptifyToken); // second chunk uses chunk-1 token + Assert.Equal("token-2", handler.Requests[3].CryptifyToken); // finalize uses last chunk token + } + + [Fact] + public async Task Init_JoinsRecipientEmailsWithComma() + { + var (client, handler) = NewClient(); + handler + .EnqueueJson("""{"uuid":"u"}""", headers: InitToken) + .Enqueue(TokenResponse("t1")) + .Enqueue(TokenResponse("t2")); + + await client.UploadAsync( + new byte[10], + [Email("alice@example.com"), Email("bob@example.com")], + notify: null); + + Assert.Contains("alice@example.com,bob@example.com", handler.Requests[0].BodyText); + } + + [Fact] + public async Task Notify_DefaultsToSilentUpload() + { + var (client, handler) = NewClient(); + handler + .EnqueueJson("""{"uuid":"u"}""", headers: InitToken) + .Enqueue(TokenResponse("t1")) + .Enqueue(TokenResponse("t2")); + + await client.UploadAsync(new byte[10], [Email("a@b.com")], notify: null); + + var body = handler.Requests[0].BodyText; + Assert.Contains("\"confirm\":false", body); + Assert.Contains("\"notifyRecipients\":false", body); + Assert.Contains("\"mailLang\":\"EN\"", body); + } + + [Fact] + public async Task Notify_PropagatesOptionsToInitBody() + { + var (client, handler) = NewClient(); + handler + .EnqueueJson("""{"uuid":"u"}""", headers: InitToken) + .Enqueue(TokenResponse("t1")) + .Enqueue(TokenResponse("t2")); + + var notify = new NotifyOptions + { + Recipients = true, + Sender = true, + Message = "hello", + Language = "NL", + }; + await client.UploadAsync(new byte[10], [Email("a@b.com")], notify); + + var body = handler.Requests[0].BodyText; + Assert.Contains("\"confirm\":true", body); + Assert.Contains("\"notifyRecipients\":true", body); + Assert.Contains("\"mailContent\":\"hello\"", body); + Assert.Contains("\"mailLang\":\"NL\"", body); + } + + [Fact] + public async Task NullUuid_ThrowsPostGuardException() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"uuid":null}""", headers: InitToken); + + var ex = await Assert.ThrowsAsync( + () => client.UploadAsync(new byte[10], [Email("a@b.com")], notify: null)); + Assert.Contains("uuid", ex.Message); + } + + [Fact] + public async Task MissingInitToken_ThrowsPostGuardException() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"uuid":"u"}"""); // no cryptifytoken header + + var ex = await Assert.ThrowsAsync( + () => client.UploadAsync(new byte[10], [Email("a@b.com")], notify: null)); + Assert.Contains("cryptifytoken", ex.Message); + } + + [Fact] + public async Task MissingChunkToken_ThrowsPostGuardException() + { + var (client, handler) = NewClient(); + handler + .EnqueueJson("""{"uuid":"u"}""", headers: InitToken) + .Enqueue(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("") }); // no token + + var ex = await Assert.ThrowsAsync( + () => client.UploadAsync(new byte[10], [Email("a@b.com")], notify: null)); + Assert.Contains("cryptifytoken", ex.Message); + } + + [Fact] + public async Task NonSuccessInit_ThrowsNetworkExceptionWithUrl() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("nope", HttpStatusCode.InternalServerError); + + var ex = await Assert.ThrowsAsync( + () => client.UploadAsync(new byte[10], [Email("a@b.com")], notify: null)); + Assert.Equal(500, ex.StatusCode); + Assert.Contains("/fileupload/init", ex.Url); + } + + [Fact] + public async Task Ctor_TrimsTrailingSlashFromUrl() + { + var handler = new RecordingHttpMessageHandler(); + handler + .EnqueueJson("""{"uuid":"u"}""", headers: InitToken) + .Enqueue(TokenResponse("t1")) + .Enqueue(TokenResponse("t2")); + var client = new CryptifyClient(new HttpClient(handler), BaseUrl + "/"); + + await client.UploadAsync(new byte[10], [Email("a@b.com")], notify: null); + + // No double slash in the init URL. + Assert.Equal($"{BaseUrl}/fileupload/init", handler.Requests[0].Uri.ToString()); + } +} diff --git a/tests/E4A.PostGuard.Tests/PkgClientTests.cs b/tests/E4A.PostGuard.Tests/PkgClientTests.cs new file mode 100644 index 0000000..fc859fa --- /dev/null +++ b/tests/E4A.PostGuard.Tests/PkgClientTests.cs @@ -0,0 +1,151 @@ +using System.Net; +using System.Text.Json; +using E4A.PostGuard.Api; +using E4A.PostGuard.Exceptions; +using E4A.PostGuard.Tests.TestHelpers; + +namespace E4A.PostGuard.Tests; + +public class PkgClientTests +{ + private const string BaseUrl = "https://pkg.postguard.eu"; + + private static (PkgClient Client, RecordingHttpMessageHandler Handler) NewClient() + { + var handler = new RecordingHttpMessageHandler(); + return (new PkgClient(new HttpClient(handler), BaseUrl), handler); + } + + [Fact] + public async Task FetchMpk_GetsParametersEndpoint() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"publicKey":"AAEC"}"""); + + await client.FetchMpkJsonAsync(); + + Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); + Assert.Equal($"{BaseUrl}/v2/parameters", handler.Requests[0].Uri.ToString()); + } + + [Fact] + public async Task FetchMpk_ReturnsSerializedStringValue() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"publicKey":"AAEC","ignored":42}"""); + + var mpk = await client.FetchMpkJsonAsync(); + + // A quoted base64 string is returned verbatim as a JSON string literal. + Assert.Equal("\"AAEC\"", mpk); + } + + [Fact] + public async Task FetchMpk_ReturnsSerializedObjectValue() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"publicKey":{"alg":"kem","key":"AAEC"}}"""); + + var mpk = await client.FetchMpkJsonAsync(); + + // The publicKey sub-object is preserved as valid JSON. + using var doc = JsonDocument.Parse(mpk); + Assert.Equal("kem", doc.RootElement.GetProperty("alg").GetString()); + Assert.Equal("AAEC", doc.RootElement.GetProperty("key").GetString()); + } + + [Fact] + public async Task FetchMpk_MissingPublicKey_Throws() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"somethingElse":"x"}"""); + + await Assert.ThrowsAsync(() => client.FetchMpkJsonAsync()); + } + + [Fact] + public async Task FetchMpk_NonSuccess_ThrowsNetworkException() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("server error", HttpStatusCode.BadGateway); + + var ex = await Assert.ThrowsAsync(() => client.FetchMpkJsonAsync()); + Assert.Equal(502, ex.StatusCode); + Assert.Contains("/v2/parameters", ex.Url); + } + + [Fact] + public async Task FetchSigningKeys_PostsWithBearerAuthAndBody() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"pubSignKey":{"k":"pub"}}"""); + + await client.FetchSigningKeysAsync("my-api-key"); + + var req = handler.Requests[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.Equal($"{BaseUrl}/v2/irma/sign/key", req.Uri.ToString()); + Assert.Equal("Bearer my-api-key", req.Authorization); + Assert.Contains("pbdf.sidn-pbdf.email.email", req.BodyText); + } + + [Fact] + public async Task FetchSigningKeys_ReturnsPubAndPriv() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"pubSignKey":{"k":"pub"},"privSignKey":{"k":"priv"}}"""); + + var (pub, priv) = await client.FetchSigningKeysAsync("key"); + + using var pubDoc = JsonDocument.Parse(pub); + Assert.Equal("pub", pubDoc.RootElement.GetProperty("k").GetString()); + + Assert.NotNull(priv); + using var privDoc = JsonDocument.Parse(priv!); + Assert.Equal("priv", privDoc.RootElement.GetProperty("k").GetString()); + } + + [Fact] + public async Task FetchSigningKeys_AbsentPrivKey_ReturnsNull() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"pubSignKey":{"k":"pub"}}"""); + + var (pub, priv) = await client.FetchSigningKeysAsync("key"); + + Assert.NotNull(pub); + Assert.Null(priv); + } + + [Fact] + public async Task FetchSigningKeys_NullPrivKey_ReturnsNull() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"pubSignKey":{"k":"pub"},"privSignKey":null}"""); + + var (_, priv) = await client.FetchSigningKeysAsync("key"); + + Assert.Null(priv); + } + + [Fact] + public async Task FetchSigningKeys_MissingPubKey_Throws() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("""{"privSignKey":{"k":"priv"}}"""); + + await Assert.ThrowsAsync(() => client.FetchSigningKeysAsync("key")); + } + + [Fact] + public async Task FetchSigningKeys_NonSuccess_ThrowsNetworkException() + { + var (client, handler) = NewClient(); + handler.EnqueueJson("unauthorized", HttpStatusCode.Unauthorized); + + var ex = await Assert.ThrowsAsync( + () => client.FetchSigningKeysAsync("bad-key")); + Assert.Equal(401, ex.StatusCode); + Assert.Contains("/v2/irma/sign/key", ex.Url); + } +} diff --git a/tests/E4A.PostGuard.Tests/SealPipelineTests.cs b/tests/E4A.PostGuard.Tests/SealPipelineTests.cs new file mode 100644 index 0000000..13f6c75 --- /dev/null +++ b/tests/E4A.PostGuard.Tests/SealPipelineTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Text; +using E4A.PostGuard; +using E4A.PostGuard.Crypto; +using E4A.PostGuard.Models; +using E4A.PostGuard.Tests.TestHelpers; + +namespace E4A.PostGuard.Tests; + +public class SealPipelineTests +{ + private const string PkgUrl = "https://pkg.postguard.eu"; + private const string CryptifyUrl = "https://cryptify.postguard.eu"; + + private static PostGuardConfig Config() => new() + { + PkgUrl = PkgUrl, + CryptifyUrl = CryptifyUrl, + }; + + private static EncryptInput Input(SignMethod sign) => new() + { + Files = [new PgFile("a.txt", new MemoryStream(Encoding.UTF8.GetBytes("x")))], + Recipients = [new RecipientBuilder("a@b.com", RecipientBaseType.Email)], + Sign = sign, + }; + + /// A non-ApiKey signing method, to exercise the guard clause. + private sealed class UnsupportedSign : SignMethod + { + } + + [Fact] + public async Task SealAsync_NonApiKeySign_ThrowsArgumentException() + { + using var http = new HttpClient(new RecordingHttpMessageHandler()); + + var ex = await Assert.ThrowsAsync( + () => SealPipeline.SealAsync(Config(), http, Input(new UnsupportedSign()))); + Assert.Contains("ApiKey", ex.Message); + } + + [Fact] + public async Task SealAsync_KeyFetchFailure_PropagatesNetworkException() + { + // Both PKG calls fire in parallel before any native seal; a PKG failure + // must surface (here: a 500 on the parameters/sign-key fetch) rather than + // proceeding to the native layer. + var handler = new RecordingHttpMessageHandler(); + handler + .EnqueueJson("boom", HttpStatusCode.InternalServerError) + .EnqueueJson("boom", HttpStatusCode.InternalServerError); + using var http = new HttpClient(handler); + + await Assert.ThrowsAsync( + () => SealPipeline.SealAsync(Config(), http, Input(new ApiKeySign("key")))); + + // The MPK + signing-key fetches both went out (parallel key-fetch wiring). + Assert.Contains(handler.Requests, r => r.Uri.ToString().EndsWith("/v2/parameters")); + Assert.Contains(handler.Requests, r => r.Uri.ToString().EndsWith("/v2/irma/sign/key")); + } +} diff --git a/tests/E4A.PostGuard.Tests/TestHelpers/RecordingHttpMessageHandler.cs b/tests/E4A.PostGuard.Tests/TestHelpers/RecordingHttpMessageHandler.cs new file mode 100644 index 0000000..b8381a4 --- /dev/null +++ b/tests/E4A.PostGuard.Tests/TestHelpers/RecordingHttpMessageHandler.cs @@ -0,0 +1,100 @@ +using System.Net; + +namespace E4A.PostGuard.Tests.TestHelpers; + +/// +/// A test that records every outgoing request +/// (method, URI, headers and buffered body) and replies with a queued sequence +/// of responses. Lets the API-client tests exercise the full request/response +/// wiring without a live server. +/// +internal sealed class RecordingHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue _responses = new(); + private readonly object _gate = new(); + + public List Requests { get; } = []; + + /// Optional callback invoked just before each response is dequeued. + public Action? OnRequest { get; set; } + + public RecordingHttpMessageHandler Enqueue(HttpResponseMessage response) + { + _responses.Enqueue(response); + return this; + } + + /// Enqueue a JSON 200 response, optionally with extra response headers. + public RecordingHttpMessageHandler EnqueueJson( + string json, + HttpStatusCode status = HttpStatusCode.OK, + IReadOnlyDictionary? headers = null) + { + var response = new HttpResponseMessage(status) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), + }; + if (headers != null) + { + foreach (var (name, value) in headers) + { + response.Headers.TryAddWithoutValidation(name, value); + } + } + return Enqueue(response); + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + // Buffer the body now — HttpClient disposes the request after this returns. + byte[]? body = null; + if (request.Content != null) + { + body = await request.Content.ReadAsByteArrayAsync(cancellationToken); + } + + var recorded = new RecordedRequest( + request.Method, + request.RequestUri!, + HeaderValue(request, "cryptifytoken"), + request.Headers.Authorization?.ToString(), + ContentHeaderValue(request, "Content-Range"), + body); + + HttpResponseMessage response; + lock (_gate) + { + Requests.Add(recorded); + if (_responses.Count == 0) + { + throw new InvalidOperationException( + $"No queued response for {request.Method} {request.RequestUri}"); + } + response = _responses.Dequeue(); + } + + OnRequest?.Invoke(recorded); + response.RequestMessage = request; + return response; + } + + private static string? HeaderValue(HttpRequestMessage request, string name) => + request.Headers.TryGetValues(name, out var values) ? values.FirstOrDefault() : null; + + private static string? ContentHeaderValue(HttpRequestMessage request, string name) => + request.Content != null && request.Content.Headers.TryGetValues(name, out var values) + ? values.FirstOrDefault() + : null; +} + +internal sealed record RecordedRequest( + HttpMethod Method, + Uri Uri, + string? CryptifyToken, + string? Authorization, + string? ContentRange, + byte[]? Body) +{ + public string BodyText => Body == null ? "" : System.Text.Encoding.UTF8.GetString(Body); +} diff --git a/tests/E4A.PostGuard.Tests/ZipHelperTests.cs b/tests/E4A.PostGuard.Tests/ZipHelperTests.cs new file mode 100644 index 0000000..fa8507c --- /dev/null +++ b/tests/E4A.PostGuard.Tests/ZipHelperTests.cs @@ -0,0 +1,79 @@ +using System.IO.Compression; +using System.Text; +using E4A.PostGuard.Models; +using E4A.PostGuard.Zip; + +namespace E4A.PostGuard.Tests; + +public class ZipHelperTests +{ + private static PgFile File(string name, string content) => + new(name, new MemoryStream(Encoding.UTF8.GetBytes(content))); + + private static Dictionary ReadZip(byte[] zip) + { + var entries = new Dictionary(); + using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read); + foreach (var entry in archive.Entries) + { + using var reader = new StreamReader(entry.Open()); + entries[entry.FullName] = reader.ReadToEnd(); + } + return entries; + } + + [Fact] + public void SingleFile_RoundTrips() + { + var zip = ZipHelper.CreateZip([File("hello.txt", "world")]); + + var entries = ReadZip(zip); + Assert.Single(entries); + Assert.Equal("world", entries["hello.txt"]); + } + + [Fact] + public void MultipleFiles_AllPresentWithContent() + { + var zip = ZipHelper.CreateZip([ + File("a.txt", "alpha"), + File("dir/b.txt", "beta"), + File("c.bin", "gamma"), + ]); + + var entries = ReadZip(zip); + Assert.Equal(3, entries.Count); + Assert.Equal("alpha", entries["a.txt"]); + Assert.Equal("beta", entries["dir/b.txt"]); + Assert.Equal("gamma", entries["c.bin"]); + } + + [Fact] + public void EmptyFileList_ProducesValidEmptyZip() + { + var zip = ZipHelper.CreateZip([]); + + Assert.NotEmpty(zip); // a valid (empty) zip still has an end-of-central-directory record + Assert.Empty(ReadZip(zip)); + } + + [Fact] + public void PreservesEntryNames_IncludingDuplicatesContent() + { + var binary = new byte[256]; + for (var i = 0; i < binary.Length; i++) + { + binary[i] = (byte)i; + } + + var zip = ZipHelper.CreateZip([new PgFile("payload.bin", new MemoryStream(binary))]); + + using var archive = new ZipArchive(new MemoryStream(zip), ZipArchiveMode.Read); + var entry = Assert.Single(archive.Entries); + Assert.Equal("payload.bin", entry.FullName); + + using var ms = new MemoryStream(); + entry.Open().CopyTo(ms); + Assert.Equal(binary, ms.ToArray()); + } +}