Skip to content

Commit a5cf225

Browse files
indrorajoevanwanzeeleKFgithub-actions[bot]
authored
release: 1.0.0 (#2)
* added license headers, updated docs, most functionality implemented * Delete api.yaml * added api.yaml to .gitignore * Delete AEMCM.Orchestrator.sln.licenseheader * added licenseheader to .gitignore * more unit tests. improved logging. * Add Keyfactor Release Workflow configuration * docs: auto-generate README and documentation [skip ci] --------- Co-authored-by: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent bd07d60 commit a5cf225

49 files changed

Lines changed: 2550 additions & 8758 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Keyfactor Release Workflow
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
types: [opened, closed, synchronize, edited, reopened]
7+
push:
8+
create:
9+
branches:
10+
- 'release-*.*'
11+
12+
jobs:
13+
call-starter-workflow:
14+
uses: keyfactor/actions/.github/workflows/starter.yml@v5
15+
secrets:
16+
token: ${{ secrets.V2BUILDTOKEN}} # REQUIRED
17+
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} # Only required for golang builds
18+
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} # Only required for golang builds
19+
scan_token: ${{ secrets.SAST_TOKEN }} # REQUIRED

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,9 @@ Thumbs.db
2222
## Secrets — never commit credentials
2323
*.local.json
2424
secrets.json
25+
26+
## Reference-only: Cloud Manager OpenAPI spec (kept locally, not committed)
27+
api.yaml
28+
29+
## License header template (tooling artifact, not committed)
30+
.licenseheader

AEMCM.Orchestrator.Tests/BudgetManagerTests.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
2+
// Copyright 2026 Keyfactor
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
6+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
7+
// and limitations under the License.
8+
19
using System.Collections.Generic;
210
using Keyfactor.Extensions.Orchestrator.AEMCM.Client.Models;
311
using Keyfactor.Extensions.Orchestrator.AEMCM.Logic;

AEMCM.Orchestrator.Tests/CertMatcherTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
2+
// Copyright 2026 Keyfactor
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
6+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
7+
// and limitations under the License.
8+
19
using System.Collections.Generic;
210
using Keyfactor.Extensions.Orchestrator.AEMCM.Client.Models;
311
using Keyfactor.Extensions.Orchestrator.AEMCM.Logic;
@@ -76,6 +84,18 @@ public void Superset_MatchesOnlyWhenEnabled()
7684
Assert.Equal(3, enabled.Certificate!.Id);
7785
}
7886

87+
[Fact]
88+
public void AliasMatches_Name_Id_AndDisambiguatedForm()
89+
{
90+
var cert = Cert(123, "wildcard-example", SslCertificateType.Ov, "a.example.com");
91+
92+
Assert.True(CertMatcher.AliasMatches(cert, "wildcard-example")); // by name
93+
Assert.True(CertMatcher.AliasMatches(cert, "123")); // by id
94+
Assert.True(CertMatcher.AliasMatches(cert, "wildcard-example (123)")); // disambiguated form
95+
Assert.False(CertMatcher.AliasMatches(cert, "something-else"));
96+
Assert.False(CertMatcher.AliasMatches(cert, ""));
97+
}
98+
7999
[Fact]
80100
public void NoCandidates_ReturnsNoMatch()
81101
{
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+
}

AEMCM.Orchestrator.Tests/DiscoveryParseTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
2+
// Copyright 2026 Keyfactor
3+
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS,
6+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
7+
// and limitations under the License.
8+
19
using Keyfactor.Extensions.Orchestrator.AEMCM;
210
using Xunit;
311

@@ -28,5 +36,29 @@ public void ParseProgramIds_EmptyOrMissing_ReturnsEmpty()
2836
Assert.Empty(Discovery.ParseProgramIds("{}"));
2937
Assert.Empty(Discovery.ParseProgramIds(@"{ ""_embedded"": { ""programs"": [] } }"));
3038
}
39+
40+
[Fact]
41+
public void ParseTenantIds_ReadsEmbeddedTenantIds()
42+
{
43+
const string payload = @"{
44+
""_embedded"": {
45+
""tenants"": [
46+
{ ""id"": ""14"", ""imsTenantId"": ""acmeCorp"" },
47+
{ ""id"": ""15"", ""imsTenantId"": ""globex"" }
48+
]
49+
}
50+
}";
51+
52+
var ids = Discovery.ParseTenantIds(payload);
53+
54+
Assert.Equal(new[] { "14", "15" }, ids);
55+
}
56+
57+
[Fact]
58+
public void ParseTenantIds_EmptyOrMissing_ReturnsEmpty()
59+
{
60+
Assert.Empty(Discovery.ParseTenantIds("{}"));
61+
Assert.Empty(Discovery.ParseTenantIds(@"{ ""_embedded"": { ""tenants"": [] } }"));
62+
}
3163
}
3264
}

0 commit comments

Comments
 (0)