Skip to content

Commit ee5ed2f

Browse files
more unit tests. improved logging.
1 parent 0fc1e03 commit ee5ed2f

5 files changed

Lines changed: 431 additions & 4 deletions

File tree

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright 2026 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
4+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
5+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
6+
// and limitations under the License.
7+
8+
using System.Net;
9+
using System.Net.Http;
10+
using System.Text.Json;
11+
using System.Threading;
12+
using System.Threading.Tasks;
13+
using AEMCM.Orchestrator.Tests.TestHelpers;
14+
using Keyfactor.Extensions.Orchestrator.AEMCM;
15+
using Keyfactor.Extensions.Orchestrator.AEMCM.Client;
16+
using Keyfactor.Extensions.Orchestrator.AEMCM.Client.Models;
17+
using Microsoft.Extensions.Logging.Abstractions;
18+
using Xunit;
19+
20+
namespace AEMCM.Orchestrator.Tests
21+
{
22+
/// <summary>
23+
/// Exercises the real <see cref="CloudManagerClient"/> over the HTTP/serialization layer
24+
/// (via a stubbed handler) — covering the write paths Adobe won't let us drive live.
25+
/// </summary>
26+
public class CloudManagerClientTests
27+
{
28+
private const long ProgramId = 211102;
29+
30+
private sealed class StubAuth : IAdobeImsAuthClient
31+
{
32+
public Task<string> GetAccessTokenAsync(CancellationToken cancellationToken = default) =>
33+
Task.FromResult("test-token");
34+
}
35+
36+
private static CloudManagerClient BuildClient(StubHttpMessageHandler handler, IAdobeImsAuthClient? auth = null) =>
37+
new(new HttpClient(handler), auth ?? new StubAuth(), NullLogger.Instance,
38+
"https://cloudmanager.adobe.io", ProgramId, apiKey: "api-key-123", imsOrgId: "org@AdobeOrg");
39+
40+
private static CreateOrUpdateSslCertificateBody SampleBody() => new()
41+
{
42+
Name = "my-cert",
43+
Certificate = "-----BEGIN CERTIFICATE-----AAA-----END CERTIFICATE-----",
44+
PrivateKey = new PrivateKeyValue { Value = "-----BEGIN PRIVATE KEY-----BBB-----END PRIVATE KEY-----" },
45+
Chain = "-----BEGIN CERTIFICATE-----CCC-----END CERTIFICATE-----",
46+
};
47+
48+
private static string Serialize(object o) => JsonSerializer.Serialize(o, AemcmJson.Options);
49+
50+
private static AllSslCertificatesList BuildList(int count, bool hasNext, int startId = 0)
51+
{
52+
var list = new AllSslCertificatesList { TotalNumberOfItems = count };
53+
for (var i = 0; i < count; i++)
54+
{
55+
list.Embedded.Certificates.Add(new SslCertificateRepresentation
56+
{
57+
Id = startId + i,
58+
Name = $"c{startId + i}",
59+
SslCertificateType = SslCertificateType.Ov,
60+
SslCertificateStatus = SslCertificateStatus.Valid,
61+
});
62+
}
63+
if (hasNext)
64+
list.Links.Next = new HalLink { Href = $"/api/program/{ProgramId}/certificates?start=100&limit=100" };
65+
return list;
66+
}
67+
68+
[Fact]
69+
public void CreateCertificate_PostsExpectedBody_AndParsesResponse()
70+
{
71+
var handler = new StubHttpMessageHandler((_, _) =>
72+
StubHttpMessageHandler.Json(HttpStatusCode.Created,
73+
"{\"id\":777,\"name\":\"my-cert\",\"sslCertificateType\":\"OV\"}"));
74+
var client = BuildClient(handler);
75+
76+
var result = client.CreateCertificateAsync(SampleBody()).GetAwaiter().GetResult();
77+
78+
Assert.Equal(777, result.Id);
79+
var req = Assert.Single(handler.Requests);
80+
Assert.Equal(HttpMethod.Post, req.Method);
81+
Assert.EndsWith($"/api/program/{ProgramId}/certificates", req.Uri.AbsolutePath);
82+
// Body field names must match the Cloud Manager contract (note the nested privateKey.value).
83+
Assert.NotNull(req.Body);
84+
Assert.Contains("\"name\":\"my-cert\"", req.Body!);
85+
Assert.Contains("\"certificate\":", req.Body!);
86+
Assert.Contains("\"privateKey\":{\"value\":", req.Body!);
87+
Assert.Contains("\"chain\":", req.Body!);
88+
// Auth + Adobe headers present.
89+
Assert.Equal("Bearer", req.Message.Headers.Authorization!.Scheme);
90+
Assert.Equal("test-token", req.Message.Headers.Authorization!.Parameter);
91+
Assert.True(req.Message.Headers.Contains("x-api-key"));
92+
Assert.True(req.Message.Headers.Contains("x-gw-ims-org-id"));
93+
}
94+
95+
[Fact]
96+
public void UpdateCertificate_PutsToIdPath()
97+
{
98+
var handler = new StubHttpMessageHandler((_, _) =>
99+
StubHttpMessageHandler.Json(HttpStatusCode.OK, "{\"id\":55,\"name\":\"my-cert\"}"));
100+
var client = BuildClient(handler);
101+
102+
var result = client.UpdateCertificateAsync(55, SampleBody()).GetAwaiter().GetResult();
103+
104+
Assert.Equal(55, result.Id);
105+
var req = Assert.Single(handler.Requests);
106+
Assert.Equal(HttpMethod.Put, req.Method);
107+
Assert.EndsWith($"/api/program/{ProgramId}/certificate/55", req.Uri.AbsolutePath);
108+
}
109+
110+
[Fact]
111+
public void DeleteCertificate_SendsDeleteToIdPath()
112+
{
113+
var handler = new StubHttpMessageHandler((_, _) => StubHttpMessageHandler.Empty(HttpStatusCode.OK));
114+
var client = BuildClient(handler);
115+
116+
client.DeleteCertificateAsync(55).GetAwaiter().GetResult();
117+
118+
var req = Assert.Single(handler.Requests);
119+
Assert.Equal(HttpMethod.Delete, req.Method);
120+
Assert.EndsWith($"/api/program/{ProgramId}/certificate/55", req.Uri.AbsolutePath);
121+
}
122+
123+
[Fact]
124+
public void GetAllCertificates_PagesAcrossMultiplePages()
125+
{
126+
var handler = new StubHttpMessageHandler((req, _) =>
127+
req.RequestUri!.Query.Contains("start=0")
128+
? StubHttpMessageHandler.Json(HttpStatusCode.OK, Serialize(BuildList(100, hasNext: true)))
129+
: StubHttpMessageHandler.Json(HttpStatusCode.OK, Serialize(BuildList(1, hasNext: false, startId: 1000))));
130+
var client = BuildClient(handler);
131+
132+
var certs = client.GetAllCertificatesAsync().GetAwaiter().GetResult();
133+
134+
Assert.Equal(101, certs.Count);
135+
Assert.Equal(2, handler.Requests.Count);
136+
Assert.Contains(handler.Requests, r => r.Uri.Query.Contains("start=0"));
137+
Assert.Contains(handler.Requests, r => r.Uri.Query.Contains("start=100"));
138+
}
139+
140+
[Fact]
141+
public void GetDomainMappingsForCertificate_FiltersByIdAndParses()
142+
{
143+
const string json =
144+
"{\"totalNumberOfItems\":1,\"domainMappings\":[{\"domainMappingId\":1,\"programId\":211102,\"certificateId\":55,\"domainName\":\"www.example.com\"}]}";
145+
var handler = new StubHttpMessageHandler((_, _) => StubHttpMessageHandler.Json(HttpStatusCode.OK, json));
146+
var client = BuildClient(handler);
147+
148+
var mappings = client.GetDomainMappingsForCertificateAsync(55).GetAwaiter().GetResult();
149+
150+
var mapping = Assert.Single(mappings);
151+
Assert.Equal("www.example.com", mapping.DomainName);
152+
var req = Assert.Single(handler.Requests);
153+
Assert.EndsWith($"/api/program/{ProgramId}/domain-mappings", req.Uri.AbsolutePath);
154+
Assert.Contains("certificateId=55", req.Uri.Query);
155+
}
156+
157+
[Fact]
158+
public void ErrorResponse_PolicyRejection_ThrowsFriendlyException()
159+
{
160+
const string errorBody = "{\"status\":400,\"title\":\"SSL Certificate validation error\"," +
161+
"\"additionalProperties\":{\"errors\":[" +
162+
"{\"field\":\"certificate\",\"code\":\"INVALID_CERTIFICATE_SIGNATURE\",\"message\":\"Signature of the certificate or chain is corrupt or invalid\"}," +
163+
"{\"field\":\"certificate\",\"code\":\"INVALID_CERTIFICATE_POLICY\",\"message\":\"Certificate policy must be EV or OV, not DV.\"}]}}";
164+
var handler = new StubHttpMessageHandler((_, _) =>
165+
StubHttpMessageHandler.Json(HttpStatusCode.BadRequest, errorBody));
166+
var client = BuildClient(handler);
167+
168+
var ex = Assert.Throws<CloudManagerApiException>(() =>
169+
client.CreateCertificateAsync(SampleBody()).GetAwaiter().GetResult());
170+
171+
Assert.Equal(400, ex.StatusCode);
172+
Assert.Contains("OV or EV", ex.Message);
173+
Assert.DoesNotContain("Signature", ex.Message);
174+
Assert.NotNull(ex.ResponseBody);
175+
Assert.Contains("INVALID_CERTIFICATE_POLICY", ex.ResponseBody!); // raw body preserved for logs
176+
}
177+
178+
[Fact]
179+
public void Unauthorized_RefreshesTokenAndRetriesOnce()
180+
{
181+
var tokenCalls = 0;
182+
var cmCalls = 0;
183+
var handler = new StubHttpMessageHandler((req, _) =>
184+
{
185+
if (req.RequestUri!.AbsoluteUri.Contains("/ims/token"))
186+
{
187+
tokenCalls++;
188+
return StubHttpMessageHandler.Json(HttpStatusCode.OK,
189+
"{\"access_token\":\"tok\",\"token_type\":\"bearer\",\"expires_in\":3600}");
190+
}
191+
192+
cmCalls++;
193+
return cmCalls == 1
194+
? StubHttpMessageHandler.Empty(HttpStatusCode.Unauthorized)
195+
: StubHttpMessageHandler.Json(HttpStatusCode.OK, Serialize(BuildList(0, hasNext: false)));
196+
});
197+
198+
var http = new HttpClient(handler);
199+
var auth = new AdobeImsAuthClient(http, NullLogger.Instance,
200+
"https://ims.example/ims/token/v3", "client-id", "client-secret", "openid");
201+
var client = new CloudManagerClient(http, auth, NullLogger.Instance,
202+
"https://cloudmanager.adobe.io", ProgramId, "client-id", "org@AdobeOrg");
203+
204+
var certs = client.GetAllCertificatesAsync().GetAwaiter().GetResult();
205+
206+
Assert.Empty(certs);
207+
Assert.Equal(2, cmCalls); // 401, then success on retry
208+
Assert.Equal(2, tokenCalls); // initial token + refresh after 401
209+
}
210+
}
211+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright 2026 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
4+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
5+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
6+
// and limitations under the License.
7+
8+
using System.Net.Http;
9+
using Keyfactor.Extensions.Orchestrator.AEMCM.Client;
10+
using Xunit;
11+
12+
namespace AEMCM.Orchestrator.Tests
13+
{
14+
public class CloudManagerErrorTests
15+
{
16+
[Fact]
17+
public void FormatError_PolicyRejection_ReturnsSimpleOvEvMessage()
18+
{
19+
// The exact shape Cloud Manager returned during testing (policy + signature errors together).
20+
const string payload = @"{
21+
""type"": ""about:blank"",
22+
""status"": 400,
23+
""title"": ""SSL Certificate validation error"",
24+
""additionalProperties"": {
25+
""errors"": [
26+
{ ""field"": ""certificate"", ""code"": ""INVALID_CERTIFICATE_SIGNATURE"", ""message"": ""Signature of the certificate or chain is corrupt or invalid"" },
27+
{ ""field"": ""chain"", ""code"": ""INVALID_CERTIFICATE_SIGNATURE"", ""message"": ""Signature of the certificate or chain is corrupt or invalid"" },
28+
{ ""field"": ""certificate"", ""code"": ""INVALID_CERTIFICATE_POLICY"", ""message"": ""Certificate policy must be EV or OV, not DV."" }
29+
]
30+
}
31+
}";
32+
33+
var message = CloudManagerClient.FormatError(HttpMethod.Post, "/api/program/1/certificates", 400, payload);
34+
35+
Assert.Contains("OV or EV", message);
36+
Assert.Contains("DV", message);
37+
// Keep it simple — the raw signature noise and JSON should not leak into the job message.
38+
Assert.DoesNotContain("Signature", message);
39+
Assert.DoesNotContain("INVALID_CERTIFICATE", message);
40+
Assert.DoesNotContain("{", message);
41+
}
42+
43+
[Fact]
44+
public void FormatError_NonPolicyValidationErrors_SurfacesDistinctMessages()
45+
{
46+
const string payload = @"{
47+
""status"": 400,
48+
""title"": ""SSL Certificate validation error"",
49+
""additionalProperties"": {
50+
""errors"": [
51+
{ ""field"": ""privateKey"", ""code"": ""INVALID_PRIVATE_KEY"", ""message"": ""Private key does not match the certificate"" }
52+
]
53+
}
54+
}";
55+
56+
var message = CloudManagerClient.FormatError(HttpMethod.Post, "/api/program/1/certificates", 400, payload);
57+
58+
Assert.Contains("Private key does not match the certificate", message);
59+
Assert.Contains("SSL Certificate validation error", message);
60+
}
61+
62+
[Fact]
63+
public void FormatError_UnrecognizedBody_FallsBackToRawSummary()
64+
{
65+
var message = CloudManagerClient.FormatError(HttpMethod.Get, "/api/program/1/certificates", 500, "Internal Server Error");
66+
67+
Assert.Contains("/api/program/1/certificates", message);
68+
Assert.Contains("500", message);
69+
Assert.Contains("Internal Server Error", message);
70+
}
71+
}
72+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright 2026 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
3+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
4+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
5+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
6+
// and limitations under the License.
7+
8+
using System;
9+
using System.Collections.Generic;
10+
using System.Net;
11+
using System.Net.Http;
12+
using System.Text;
13+
using System.Threading;
14+
using System.Threading.Tasks;
15+
16+
namespace AEMCM.Orchestrator.Tests.TestHelpers
17+
{
18+
/// <summary>A captured outgoing request (method, URI, body, and headers) for assertions.</summary>
19+
public sealed record RecordedRequest(HttpMethod Method, Uri Uri, string? Body, HttpRequestMessage Message);
20+
21+
/// <summary>
22+
/// Test double for <see cref="HttpMessageHandler"/> that lets a test drive
23+
/// <see cref="CloudManagerClient"/>/<see cref="AdobeImsAuthClient"/> over the real HTTP/serialization
24+
/// path without a network. The responder receives the request and its (already-read) body string.
25+
/// </summary>
26+
public sealed class StubHttpMessageHandler : HttpMessageHandler
27+
{
28+
private readonly Func<HttpRequestMessage, string?, HttpResponseMessage> _responder;
29+
30+
public List<RecordedRequest> Requests { get; } = new();
31+
32+
public StubHttpMessageHandler(Func<HttpRequestMessage, string?, HttpResponseMessage> responder) =>
33+
_responder = responder;
34+
35+
protected override async Task<HttpResponseMessage> SendAsync(
36+
HttpRequestMessage request, CancellationToken cancellationToken)
37+
{
38+
var body = request.Content == null
39+
? null
40+
: await request.Content.ReadAsStringAsync().ConfigureAwait(false);
41+
42+
Requests.Add(new RecordedRequest(request.Method, request.RequestUri!, body, request));
43+
return _responder(request, body);
44+
}
45+
46+
public static HttpResponseMessage Json(HttpStatusCode status, string json) =>
47+
new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
48+
49+
public static HttpResponseMessage Empty(HttpStatusCode status) => new(status);
50+
}
51+
}

0 commit comments

Comments
 (0)