Skip to content

Commit bd07d60

Browse files
base
0 parents  commit bd07d60

30 files changed

Lines changed: 10536 additions & 0 deletions

.gitignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## .NET
2+
bin/
3+
obj/
4+
*.user
5+
*.suo
6+
.vs/
7+
artifacts/
8+
9+
## Rider
10+
.idea/
11+
12+
## Test results
13+
[Tt]est[Rr]esult*/
14+
*.trx
15+
coverage*.xml
16+
coverage*.json
17+
18+
## OS
19+
.DS_Store
20+
Thumbs.db
21+
22+
## Secrets — never commit credentials
23+
*.local.json
24+
secrets.json
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
5+
<Nullable>enable</Nullable>
6+
<LangVersion>latest</LangVersion>
7+
<IsPackable>false</IsPackable>
8+
<IsTestProject>true</IsTestProject>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
13+
<PackageReference Include="xunit" Version="2.9.2" />
14+
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
15+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
16+
<PrivateAssets>all</PrivateAssets>
17+
</PackageReference>
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<ProjectReference Include="..\AEMCM.Orchestrator\AEMCM.Orchestrator.csproj" />
22+
</ItemGroup>
23+
24+
</Project>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System.Collections.Generic;
2+
using Keyfactor.Extensions.Orchestrator.AEMCM.Client.Models;
3+
using Keyfactor.Extensions.Orchestrator.AEMCM.Logic;
4+
using Xunit;
5+
6+
namespace AEMCM.Orchestrator.Tests
7+
{
8+
public class BudgetManagerTests
9+
{
10+
private static SslCertificateRepresentation Cert(
11+
long id, string type, string status, long expireAt) => new()
12+
{
13+
Id = id,
14+
SslCertificateType = type,
15+
SslCertificateStatus = status,
16+
ExpireAt = expireAt,
17+
};
18+
19+
[Theory]
20+
[InlineData(0, true)]
21+
[InlineData(69, true)]
22+
[InlineData(70, false)]
23+
[InlineData(71, false)]
24+
public void HasBudgetForNew_RespectsLimit(int count, bool expected) =>
25+
Assert.Equal(expected, BudgetManager.HasBudgetForNew(count));
26+
27+
[Theory]
28+
[InlineData(1, true)]
29+
[InlineData(100, true)]
30+
[InlineData(101, false)]
31+
public void IsWithinSanLimit_RespectsLimit(int sanCount, bool expected) =>
32+
Assert.Equal(expected, BudgetManager.IsWithinSanLimit(sanCount));
33+
34+
[Fact]
35+
public void PickReclaimCandidate_ChoosesOldestExpiredCustomerManaged()
36+
{
37+
var existing = new[]
38+
{
39+
Cert(1, SslCertificateType.Ov, SslCertificateStatus.Valid, 5000),
40+
Cert(2, SslCertificateType.Ov, SslCertificateStatus.Expired, 3000), // oldest expired
41+
Cert(3, SslCertificateType.Ev, SslCertificateStatus.Expired, 4000),
42+
Cert(4, SslCertificateType.Dv, SslCertificateStatus.Expired, 1000), // DV — must be ignored
43+
};
44+
45+
var candidate = BudgetManager.PickReclaimCandidate(existing);
46+
47+
Assert.NotNull(candidate);
48+
Assert.Equal(2, candidate!.Id);
49+
}
50+
51+
[Fact]
52+
public void PickReclaimCandidate_ReturnsNull_WhenNothingReclaimable()
53+
{
54+
var existing = new[]
55+
{
56+
Cert(1, SslCertificateType.Ov, SslCertificateStatus.Valid, 5000),
57+
Cert(2, SslCertificateType.Dv, SslCertificateStatus.Expired, 1000),
58+
};
59+
60+
Assert.Null(BudgetManager.PickReclaimCandidate(existing));
61+
}
62+
}
63+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System.Collections.Generic;
2+
using Keyfactor.Extensions.Orchestrator.AEMCM.Client.Models;
3+
using Keyfactor.Extensions.Orchestrator.AEMCM.Logic;
4+
using Xunit;
5+
6+
namespace AEMCM.Orchestrator.Tests
7+
{
8+
public class CertMatcherTests
9+
{
10+
private static SslCertificateRepresentation Cert(
11+
long id, string name, string type, params string[] sans) => new()
12+
{
13+
Id = id,
14+
Name = name,
15+
SslCertificateType = type,
16+
SubjectAlternativeNames = new List<string>(sans),
17+
};
18+
19+
[Fact]
20+
public void ExactSanSet_Matches_RegardlessOfOrderOrCase()
21+
{
22+
var existing = new[] { Cert(1, "wildcard", SslCertificateType.Ov, "a.example.com", "b.example.com") };
23+
24+
var result = CertMatcher.FindMatch(
25+
existing, new[] { "B.EXAMPLE.COM", "a.example.com" }, alias: null, overwrite: false);
26+
27+
Assert.Equal(CertMatchType.ExactSanSet, result.MatchType);
28+
Assert.Equal(1, result.Certificate!.Id);
29+
}
30+
31+
[Fact]
32+
public void AliasMatch_OnlyWhenOverwrite()
33+
{
34+
var existing = new[] { Cert(7, "my-cert", SslCertificateType.Ev, "x.example.com") };
35+
36+
var noOverwrite = CertMatcher.FindMatch(existing, new[] { "different.example.com" }, "my-cert", overwrite: false);
37+
Assert.False(noOverwrite.IsMatch);
38+
39+
var withOverwrite = CertMatcher.FindMatch(existing, new[] { "different.example.com" }, "my-cert", overwrite: true);
40+
Assert.Equal(CertMatchType.Alias, withOverwrite.MatchType);
41+
Assert.Equal(7, withOverwrite.Certificate!.Id);
42+
}
43+
44+
[Fact]
45+
public void AliasMatch_ByNumericId()
46+
{
47+
var existing = new[] { Cert(42, "friendly", SslCertificateType.Ov, "x.example.com") };
48+
49+
var result = CertMatcher.FindMatch(existing, new[] { "new.example.com" }, "42", overwrite: true);
50+
51+
Assert.Equal(CertMatchType.Alias, result.MatchType);
52+
Assert.Equal(42, result.Certificate!.Id);
53+
}
54+
55+
[Fact]
56+
public void AdobeManagedDv_IsNeverMatched()
57+
{
58+
var existing = new[] { Cert(9, "dv", SslCertificateType.Dv, "a.example.com") };
59+
60+
var result = CertMatcher.FindMatch(existing, new[] { "a.example.com" }, "dv", overwrite: true);
61+
62+
Assert.False(result.IsMatch);
63+
}
64+
65+
[Fact]
66+
public void Superset_MatchesOnlyWhenEnabled()
67+
{
68+
var existing = new[] { Cert(3, "base", SslCertificateType.Ov, "a.example.com") };
69+
var incoming = new[] { "a.example.com", "b.example.com" }; // superset of existing
70+
71+
var disabled = CertMatcher.FindMatch(existing, incoming, alias: null, overwrite: false, allowSuperset: false);
72+
Assert.False(disabled.IsMatch);
73+
74+
var enabled = CertMatcher.FindMatch(existing, incoming, alias: null, overwrite: false, allowSuperset: true);
75+
Assert.Equal(CertMatchType.SanSuperset, enabled.MatchType);
76+
Assert.Equal(3, enabled.Certificate!.Id);
77+
}
78+
79+
[Fact]
80+
public void NoCandidates_ReturnsNoMatch()
81+
{
82+
var result = CertMatcher.FindMatch(
83+
System.Array.Empty<SslCertificateRepresentation>(),
84+
new[] { "a.example.com" }, alias: "x", overwrite: true);
85+
86+
Assert.Equal(CertMatchType.None, result.MatchType);
87+
Assert.Null(result.Certificate);
88+
}
89+
}
90+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Keyfactor.Extensions.Orchestrator.AEMCM;
2+
using Xunit;
3+
4+
namespace AEMCM.Orchestrator.Tests
5+
{
6+
public class DiscoveryParseTests
7+
{
8+
[Fact]
9+
public void ParseProgramIds_ReadsEmbeddedProgramIds()
10+
{
11+
const string payload = @"{
12+
""_embedded"": {
13+
""programs"": [
14+
{ ""id"": ""12345"", ""name"": ""Prod"" },
15+
{ ""id"": ""67890"", ""name"": ""Stage"" }
16+
]
17+
}
18+
}";
19+
20+
var ids = Discovery.ParseProgramIds(payload);
21+
22+
Assert.Equal(new[] { "12345", "67890" }, ids);
23+
}
24+
25+
[Fact]
26+
public void ParseProgramIds_EmptyOrMissing_ReturnsEmpty()
27+
{
28+
Assert.Empty(Discovery.ParseProgramIds("{}"));
29+
Assert.Empty(Discovery.ParseProgramIds(@"{ ""_embedded"": { ""programs"": [] } }"));
30+
}
31+
}
32+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System.Linq;
2+
using System.Text.RegularExpressions;
3+
using AEMCM.Orchestrator.Tests.TestHelpers;
4+
using Keyfactor.Extensions.Orchestrator.AEMCM.Logic;
5+
using Xunit;
6+
7+
namespace AEMCM.Orchestrator.Tests
8+
{
9+
public class PfxSplitterTests
10+
{
11+
private static int CountCerts(string pem) =>
12+
Regex.Matches(pem ?? string.Empty, "BEGIN CERTIFICATE").Count;
13+
14+
private static string Body(string pem) =>
15+
Regex.Replace(pem ?? string.Empty, "-----[^-]+-----|\\s", string.Empty);
16+
17+
[Fact]
18+
public void Split_SelfSigned_ProducesLeafAndKey_WithEmptyChain()
19+
{
20+
var pfx = CertTestFactory.CreateSelfSignedRsaPfx("example.com", new[] { "example.com", "www.example.com" });
21+
22+
var result = PfxSplitter.Split(pfx, CertTestFactory.DefaultPassword);
23+
24+
Assert.Contains("BEGIN CERTIFICATE", result.CertificatePem);
25+
Assert.Contains("BEGIN PRIVATE KEY", result.PrivateKeyPkcs8Pem); // PKCS#8, unencrypted
26+
Assert.DoesNotContain("ENCRYPTED", result.PrivateKeyPkcs8Pem);
27+
Assert.Equal(0, CountCerts(result.ChainPem)); // no chain for self-signed
28+
Assert.Equal("RSA", result.KeyAlgorithm);
29+
Assert.Equal(2048, result.KeySize);
30+
}
31+
32+
[Fact]
33+
public void Split_Chained_ExcludesLeafFromChain()
34+
{
35+
var pfx = CertTestFactory.CreateChainedRsaPfx("secure.example.com", new[] { "secure.example.com" });
36+
37+
var result = PfxSplitter.Split(pfx, CertTestFactory.DefaultPassword);
38+
39+
// Chain must contain the CA only (1 cert) and NOT the leaf.
40+
Assert.Equal(1, CountCerts(result.ChainPem));
41+
var leafBody = Body(result.CertificatePem);
42+
Assert.False(Body(result.ChainPem).Contains(leafBody),
43+
"Leaf certificate must be excluded from the chain field.");
44+
}
45+
46+
[Fact]
47+
public void Split_ParsesDnsSans()
48+
{
49+
var pfx = CertTestFactory.CreateSelfSignedRsaPfx(
50+
"example.com", new[] { "example.com", "www.example.com", "api.example.com" });
51+
52+
var result = PfxSplitter.Split(pfx, CertTestFactory.DefaultPassword);
53+
54+
Assert.Equal(
55+
new[] { "api.example.com", "example.com", "www.example.com" },
56+
result.SubjectAlternativeNames.OrderBy(s => s).ToArray());
57+
}
58+
59+
[Theory]
60+
[InlineData(256)]
61+
[InlineData(384)]
62+
public void Split_Ecdsa_ReportsAlgorithmAndSize(int bits)
63+
{
64+
var pfx = CertTestFactory.CreateSelfSignedEcdsaPfx("ec.example.com", new[] { "ec.example.com" }, bits);
65+
66+
var result = PfxSplitter.Split(pfx, CertTestFactory.DefaultPassword);
67+
68+
Assert.Equal("ECDSA", result.KeyAlgorithm);
69+
Assert.Equal(bits, result.KeySize);
70+
}
71+
72+
[Fact]
73+
public void Split_EmptyInput_Throws()
74+
{
75+
Assert.ThrowsAny<System.Exception>(() => PfxSplitter.Split(System.Array.Empty<byte>(), "x"));
76+
}
77+
}
78+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using System;
2+
using System.Security.Cryptography;
3+
using System.Security.Cryptography.X509Certificates;
4+
5+
namespace AEMCM.Orchestrator.Tests.TestHelpers
6+
{
7+
/// <summary>
8+
/// Builds self-signed / CA-signed PFX blobs for tests using only the BCL
9+
/// (System.Security.Cryptography). No BouncyCastle dependency.
10+
/// </summary>
11+
public static class CertTestFactory
12+
{
13+
public const string DefaultPassword = "test-password";
14+
15+
/// <summary>Self-signed RSA leaf (no chain).</summary>
16+
public static byte[] CreateSelfSignedRsaPfx(
17+
string commonName, string[] sans, int keySize = 2048, string password = DefaultPassword)
18+
{
19+
using var rsa = RSA.Create(keySize);
20+
var req = new CertificateRequest(
21+
$"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
22+
AddLeafExtensions(req, sans);
23+
using var cert = req.CreateSelfSigned(
24+
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
25+
return cert.Export(X509ContentType.Pkcs12, password);
26+
}
27+
28+
/// <summary>Self-signed ECDSA leaf (no chain). Curve: P-256 (256) or P-384 (384).</summary>
29+
public static byte[] CreateSelfSignedEcdsaPfx(
30+
string commonName, string[] sans, int keyBits = 256, string password = DefaultPassword)
31+
{
32+
var curve = keyBits == 384 ? ECCurve.NamedCurves.nistP384 : ECCurve.NamedCurves.nistP256;
33+
using var ecdsa = ECDsa.Create(curve);
34+
var req = new CertificateRequest($"CN={commonName}", ecdsa, HashAlgorithmName.SHA256);
35+
AddLeafExtensions(req, sans);
36+
using var cert = req.CreateSelfSigned(
37+
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
38+
return cert.Export(X509ContentType.Pkcs12, password);
39+
}
40+
41+
/// <summary>RSA leaf signed by a generated CA, producing a two-cert chain in the PFX.</summary>
42+
public static byte[] CreateChainedRsaPfx(
43+
string commonName, string[] sans, int keySize = 2048, string password = DefaultPassword)
44+
{
45+
using var caRsa = RSA.Create(2048);
46+
var caReq = new CertificateRequest(
47+
"CN=AEMCM Test Root CA", caRsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
48+
caReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
49+
using var caCert = caReq.CreateSelfSigned(
50+
DateTimeOffset.UtcNow.AddDays(-2), DateTimeOffset.UtcNow.AddYears(10));
51+
52+
using var leafRsa = RSA.Create(keySize);
53+
var leafReq = new CertificateRequest(
54+
$"CN={commonName}", leafRsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
55+
AddLeafExtensions(leafReq, sans);
56+
57+
var serial = new byte[8];
58+
RandomNumberGenerator.Fill(serial);
59+
using var leafPublic = leafReq.Create(
60+
caCert, DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1), serial);
61+
using var leafWithKey = leafPublic.CopyWithPrivateKey(leafRsa);
62+
63+
#if NET9_0_OR_GREATER
64+
using var caPublic = X509CertificateLoader.LoadCertificate(caCert.RawData);
65+
#else
66+
using var caPublic = new X509Certificate2(caCert.RawData); // public-only CA
67+
#endif
68+
var collection = new X509Certificate2Collection { leafWithKey, caPublic };
69+
return collection.Export(X509ContentType.Pkcs12, password)!;
70+
}
71+
72+
private static void AddLeafExtensions(CertificateRequest req, string[] sans)
73+
{
74+
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
75+
var sanBuilder = new SubjectAlternativeNameBuilder();
76+
foreach (var s in sans) sanBuilder.AddDnsName(s);
77+
req.CertificateExtensions.Add(sanBuilder.Build());
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)