Skip to content

Commit 2b64dd0

Browse files
indrorabhillkeyfactorKeyfactorgithub-actions[bot]
authored
Feature/84076 enrollment parameter defaults (#11) (#12)
* ADO 84076: Apply template parameter defaults when Command omits them Enrollment against a template that was added but never saved arrives with the plugin's declared template parameters absent from ProductParameters, because Command does not populate a template's parameter collection with the annotation defaults until the template is saved. Previously this failed enrollment (ValidityPeriod/ValidityUnits threw ArgumentException; RenewalDays returned a failure result), matching the "given key was not present in the dictionary" class of bug reported in 81803. Add RequestManager.ResolveTemplateParameter, which returns the value supplied by Command or falls back to the DefaultValue declared in GetTemplateParameterAnnotations(). Use it for ValidityPeriod, ValidityUnits, and RenewalDays so enrollment succeeds against an unsaved template using the same defaults the annotations advertise. Only a parameter with neither a supplied value nor a declared default remains an error. * Target 26.2 gateway framework and align IAnyCAPlugin to 3.3.0 Update gateway_framework to 26.2 in integration-manifest.json and bump Keyfactor.AnyGateway.IAnyCAPlugin from 3.0.0 to 3.3.0, matching the versions used by the cscglobal and sslstore plugins on the 26.2 framework. * Update generated docs * Upgrade starter workflow to starter.yml@v5 Bump the Keyfactor bootstrap workflow from starter.yml@v3 to @v5, matching the cscglobal and sslstore plugins. Adds the Command connection inputs (command_token_url, command_hostname, command_base_api_path) and the entra / command client secrets required by v5, and drops the obsolete APPROVE_README_PUSH secret. * docs: auto-generate README and documentation [skip ci] * Add unit test project with coverage for RequestManager Add HydrantCAProxy.Tests (xUnit) and wire it into the solution. Covers the ADO 84076 template-parameter-default fix (ResolveTemplateParameter and its wiring through GetEnrollmentRequest) plus the previously untested RequestManager surface: revocation-reason mapping (including reason 0), status mapping, renewal/enrollment request building, SAN construction, certificate list requests, and enrollment result mapping. 38 tests. --------- Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: Keyfactor <keyfactor@keyfactor.github.io> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent faa081f commit 2b64dd0

9 files changed

Lines changed: 518 additions & 57 deletions

File tree

.github/workflows/keyfactor-starter-workflow.yml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,17 @@ on:
1111

1212
jobs:
1313
call-starter-workflow:
14-
uses: keyfactor/actions/.github/workflows/starter.yml@v3
14+
uses: keyfactor/actions/.github/workflows/starter.yml@v5
15+
with:
16+
command_token_url: ${{ vars.COMMAND_TOKEN_URL }}
17+
command_hostname: ${{ vars.COMMAND_HOSTNAME }}
18+
command_base_api_path: ${{ vars.COMMAND_API_PATH }}
1519
secrets:
1620
token: ${{ secrets.V2BUILDTOKEN}}
17-
APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}}
1821
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }}
1922
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }}
2023
scan_token: ${{ secrets.SAST_TOKEN }}
24+
entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }}
25+
entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }}
26+
command_client_id: ${{ secrets.COMMAND_CLIENT_ID }}
27+
command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<ImplicitUsings>disable</ImplicitUsings>
6+
<Nullable>disable</Nullable>
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+
<PackageReference Include="coverlet.collector" Version="6.0.2">
19+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
20+
<PrivateAssets>all</PrivateAssets>
21+
</PackageReference>
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<ProjectReference Include="..\HydrantCAProxy\HydrantIdCAPlugin.csproj" />
26+
</ItemGroup>
27+
28+
</Project>
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
// Copyright 2025 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
// not use this file except in compliance with the License. You may obtain a
4+
// copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
5+
6+
using System;
7+
using System.Collections.Generic;
8+
using Keyfactor.AnyGateway.Extensions;
9+
using Keyfactor.Extensions.CAPlugin.HydrantId;
10+
using Keyfactor.HydrantId;
11+
using Keyfactor.HydrantId.Client.Models;
12+
using Keyfactor.HydrantId.Client.Models.Enums;
13+
using Keyfactor.HydrantId.Exceptions;
14+
using Keyfactor.PKI.Enums.EJBCA;
15+
using Xunit;
16+
17+
namespace HydrantCAProxy.Tests
18+
{
19+
public class RequestManagerTests
20+
{
21+
private readonly RequestManager _sut = new RequestManager();
22+
23+
// A valid PEM CSR (CN=unit.test.hydrantid.local) used to exercise the
24+
// enrollment/DN-parsing paths without contacting a live CA.
25+
private const string SampleCsr =
26+
"-----BEGIN CERTIFICATE REQUEST-----\n" +
27+
"MIICyDCCAbACAQAwgYIxCzAJBgNVBAYTAlVTMQ0wCwYDVQQIDARPaGlvMRUwEwYD\n" +
28+
"VQQHDAxJbmRlcGVuZGVuY2UxEjAQBgNVBAoMCUtleWZhY3RvcjEVMBMGA1UECwwM\n" +
29+
"SW50ZWdyYXRpb25zMSIwIAYDVQQDDBl1bml0LnRlc3QuaHlkcmFudGlkLmxvY2Fs\n" +
30+
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7HMrgfnq6o9t+7NAI4wZ\n" +
31+
"XmiIY3lQcuEA2drbwDqx1HW78xbs6ajhIO8A68RpHUjdBfgOl+3zwCcjgbi8+whI\n" +
32+
"OHubyMsonPCvCoKVUNv1CBclDcKEf+zAFuc7TWeL8n9aZNIeI/mLqhDxt2ZPIPuC\n" +
33+
"tNh1wZToQ5gf4u/LQXSksLwbiITeBsATEKGNMsTERM7gYuldPQFS3bTof7LGRPWT\n" +
34+
"shwNiBv6dw5QIgmXOBSJWdT0NfWVNudTF1wxV+41E/mvQCM+66Onw+ialH1nRefh\n" +
35+
"LCiWIT48LLHLrYN045QorzqbDPzk8itpka+6JA04rlNKcSOBurAypkWBvhnU9N8F\n" +
36+
"pQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBADO6dln9VOVkCG5qTBuifSxrGgDt\n" +
37+
"IoQFIHxtMVhMI2CiPPeDDfJpPDX7CoHKRGKelilwxnWlOfzupv1Qb/02YXXq/F/Z\n" +
38+
"twSyVAIbisuzL6RLIGox3GSkwlM0JTiyjASUJyVextRvxlmMRWTdc4z2v7Wxgmbf\n" +
39+
"k8wZ7VrUYofBmAj9S3ozilPWRKspl/BZrm+4IIoufa2BKfMnGQGbsad22mrpkRtG\n" +
40+
"1gm6iZDzaVTSC3iO5+CA/ZNwRT2ShIAHAbZTUSf62n5+nfs8Wki67i96hQqX7qIT\n" +
41+
"MRXVBIV6K2c9Ls9aEh5qnPR8wre/VMaufCliSb0Q4X50Tal8kJZbS6/ZfJo=\n" +
42+
"-----END CERTIFICATE REQUEST-----";
43+
44+
private static EnrollmentProductInfo ProductInfo(Dictionary<string, string> parameters) =>
45+
new EnrollmentProductInfo { ProductID = "test-policy", ProductParameters = parameters };
46+
47+
// ---------------------------------------------------------------------
48+
// ResolveTemplateParameter — the ADO 84076 / 81803 fix.
49+
// Command does not populate a template's parameter collection with the
50+
// annotation defaults until the template is saved. The resolver must fall
51+
// back to the declared DefaultValue so enrollment still works.
52+
// ---------------------------------------------------------------------
53+
54+
[Theory]
55+
[InlineData("ValidityPeriod", "Years")]
56+
[InlineData("ValidityUnits", "1")]
57+
[InlineData("RenewalDays", "30")]
58+
public void ResolveTemplateParameter_KeyMissing_ReturnsAnnotationDefault(string key, string expected)
59+
{
60+
// Unsaved template: Command supplies an empty parameter collection.
61+
var productInfo = ProductInfo(new Dictionary<string, string>());
62+
63+
var result = RequestManager.ResolveTemplateParameter(productInfo, key);
64+
65+
Assert.Equal(expected, result);
66+
}
67+
68+
[Theory]
69+
[InlineData("")]
70+
[InlineData(" ")]
71+
public void ResolveTemplateParameter_BlankValue_ReturnsAnnotationDefault(string blank)
72+
{
73+
var productInfo = ProductInfo(new Dictionary<string, string> { ["ValidityPeriod"] = blank });
74+
75+
var result = RequestManager.ResolveTemplateParameter(productInfo, "ValidityPeriod");
76+
77+
Assert.Equal("Years", result);
78+
}
79+
80+
[Fact]
81+
public void ResolveTemplateParameter_ValueSupplied_ReturnsSuppliedValue()
82+
{
83+
var productInfo = ProductInfo(new Dictionary<string, string> { ["ValidityPeriod"] = "Days" });
84+
85+
var result = RequestManager.ResolveTemplateParameter(productInfo, "ValidityPeriod");
86+
87+
Assert.Equal("Days", result);
88+
}
89+
90+
[Fact]
91+
public void ResolveTemplateParameter_NullProductParameters_ReturnsAnnotationDefault()
92+
{
93+
var productInfo = ProductInfo(null);
94+
95+
var result = RequestManager.ResolveTemplateParameter(productInfo, "RenewalDays");
96+
97+
Assert.Equal("30", result);
98+
}
99+
100+
[Fact]
101+
public void ResolveTemplateParameter_NullProductInfo_ReturnsAnnotationDefault()
102+
{
103+
var result = RequestManager.ResolveTemplateParameter(null, "ValidityUnits");
104+
105+
Assert.Equal("1", result);
106+
}
107+
108+
[Fact]
109+
public void ResolveTemplateParameter_UnknownKeyWithNoDefault_ReturnsNull()
110+
{
111+
var productInfo = ProductInfo(new Dictionary<string, string>());
112+
113+
var result = RequestManager.ResolveTemplateParameter(productInfo, "NotADeclaredParameter");
114+
115+
Assert.Null(result);
116+
}
117+
118+
// ---------------------------------------------------------------------
119+
// GetEnrollmentRequest — proves the resolver is actually wired into
120+
// enrollment: an unsaved template (no parameters) still produces a valid
121+
// request using the annotation defaults instead of throwing.
122+
// ---------------------------------------------------------------------
123+
124+
[Fact]
125+
public void GetEnrollmentRequest_UnsavedTemplate_UsesDefaultValidity()
126+
{
127+
var productInfo = ProductInfo(new Dictionary<string, string>());
128+
129+
var request = _sut.GetEnrollmentRequest(Guid.NewGuid(), productInfo, SampleCsr, null);
130+
131+
// Defaults are ValidityPeriod=Years, ValidityUnits=1.
132+
Assert.NotNull(request);
133+
Assert.Equal(1, request.Validity.Years);
134+
Assert.Null(request.Validity.Months);
135+
Assert.Null(request.Validity.Days);
136+
}
137+
138+
[Fact]
139+
public void GetEnrollmentRequest_SuppliedValidity_HonorsSuppliedValues()
140+
{
141+
var productInfo = ProductInfo(new Dictionary<string, string>
142+
{
143+
["ValidityPeriod"] = "Months",
144+
["ValidityUnits"] = "6"
145+
});
146+
147+
var request = _sut.GetEnrollmentRequest(Guid.NewGuid(), productInfo, SampleCsr, null);
148+
149+
Assert.Equal(6, request.Validity.Months);
150+
Assert.Null(request.Validity.Years);
151+
}
152+
153+
[Fact]
154+
public void GetEnrollmentRequest_NullCsr_Throws()
155+
{
156+
var productInfo = ProductInfo(new Dictionary<string, string>());
157+
158+
Assert.Throws<ArgumentNullException>(() => _sut.GetEnrollmentRequest(Guid.NewGuid(), productInfo, null, null));
159+
}
160+
161+
[Fact]
162+
public void GetEnrollmentRequest_NullProductInfo_Throws()
163+
{
164+
Assert.Throws<ArgumentNullException>(() => _sut.GetEnrollmentRequest(Guid.NewGuid(), null, SampleCsr, null));
165+
}
166+
167+
[Fact]
168+
public void GetEnrollmentRequest_WithSans_PopulatesSubjectAltNames()
169+
{
170+
var productInfo = ProductInfo(new Dictionary<string, string>());
171+
var sans = new Dictionary<string, string[]> { ["dnsname"] = new[] { "a.example.com", "b.example.com" } };
172+
173+
var request = _sut.GetEnrollmentRequest(Guid.NewGuid(), productInfo, SampleCsr, sans);
174+
175+
Assert.NotNull(request.SubjectAltNames);
176+
Assert.Equal(2, request.SubjectAltNames.Dnsname.Count);
177+
}
178+
179+
// ---------------------------------------------------------------------
180+
// GetMapRevokeReasons — revocation reason mapping (incl. ADO 86120
181+
// reason 0 = Unspecified).
182+
// ---------------------------------------------------------------------
183+
184+
[Theory]
185+
[InlineData(0u, RevocationReasons.Unspecified)]
186+
[InlineData(1u, RevocationReasons.KeyCompromise)]
187+
[InlineData(3u, RevocationReasons.AffiliationChanged)]
188+
[InlineData(4u, RevocationReasons.Superseded)]
189+
[InlineData(5u, RevocationReasons.CessationOfOperation)]
190+
public void GetMapRevokeReasons_SupportedReason_MapsCorrectly(uint input, RevocationReasons expected)
191+
{
192+
Assert.Equal(expected, _sut.GetMapRevokeReasons(input));
193+
}
194+
195+
[Theory]
196+
[InlineData(2u)] // certificateHold — not supported
197+
[InlineData(6u)]
198+
[InlineData(99u)]
199+
public void GetMapRevokeReasons_UnsupportedReason_Throws(uint input)
200+
{
201+
Assert.Throws<RevokeReasonNotSupportedException>(() => _sut.GetMapRevokeReasons(input));
202+
}
203+
204+
[Fact]
205+
public void GetRevokeRequest_SetsReason()
206+
{
207+
var result = _sut.GetRevokeRequest(RevocationReasons.KeyCompromise);
208+
209+
Assert.Equal(RevocationReasons.KeyCompromise, result.Reason);
210+
}
211+
212+
// ---------------------------------------------------------------------
213+
// GetMapReturnStatus — HydrantId status -> Keyfactor EndEntityStatus.
214+
// ---------------------------------------------------------------------
215+
216+
[Theory]
217+
[InlineData(RevocationStatusEnum.Valid, EndEntityStatus.GENERATED)]
218+
[InlineData(RevocationStatusEnum.Pending, EndEntityStatus.INPROCESS)]
219+
[InlineData(RevocationStatusEnum.Revoked, EndEntityStatus.REVOKED)]
220+
[InlineData(RevocationStatusEnum.Failed, EndEntityStatus.FAILED)]
221+
[InlineData(RevocationStatusEnum.Expired, EndEntityStatus.FAILED)] // default branch
222+
public void GetMapReturnStatus_MapsToExpectedEndEntityStatus(RevocationStatusEnum input, EndEntityStatus expected)
223+
{
224+
Assert.Equal((int)expected, _sut.GetMapReturnStatus(input));
225+
}
226+
227+
// ---------------------------------------------------------------------
228+
// GetRenewalRequest
229+
// ---------------------------------------------------------------------
230+
231+
[Fact]
232+
public void GetRenewalRequest_WithCsr_SetsCsrAndReuseFlag()
233+
{
234+
var result = _sut.GetRenewalRequest("some-csr", reuseCsr: false);
235+
236+
Assert.Equal("some-csr", result.Csr);
237+
Assert.False(result.ReuseCsr);
238+
}
239+
240+
[Fact]
241+
public void GetRenewalRequest_ReuseCsrWithoutCsr_DoesNotThrow()
242+
{
243+
var result = _sut.GetRenewalRequest(null, reuseCsr: true);
244+
245+
Assert.True(result.ReuseCsr);
246+
}
247+
248+
[Fact]
249+
public void GetRenewalRequest_NoCsrAndNoReuse_Throws()
250+
{
251+
Assert.Throws<ArgumentNullException>(() => _sut.GetRenewalRequest(null, reuseCsr: false));
252+
}
253+
254+
// ---------------------------------------------------------------------
255+
// GetSansRequest
256+
// ---------------------------------------------------------------------
257+
258+
[Fact]
259+
public void GetSansRequest_Null_ReturnsEmptySans()
260+
{
261+
var result = _sut.GetSansRequest(null);
262+
263+
Assert.NotNull(result);
264+
}
265+
266+
[Fact]
267+
public void GetSansRequest_AllTypes_Populated()
268+
{
269+
var sans = new Dictionary<string, string[]>
270+
{
271+
["dnsname"] = new[] { "example.com" },
272+
["ipaddress"] = new[] { "10.0.0.1", "10.0.0.2" },
273+
["rfc822name"] = new[] { "user@example.com" },
274+
["upn"] = new[] { "user@corp.local" }
275+
};
276+
277+
var result = _sut.GetSansRequest(sans);
278+
279+
Assert.Single(result.Dnsname);
280+
Assert.Equal(2, result.Ipaddress.Count);
281+
Assert.Single(result.Rfc822Name);
282+
Assert.Single(result.Upn);
283+
}
284+
285+
// ---------------------------------------------------------------------
286+
// GetCertificatesListRequest
287+
// ---------------------------------------------------------------------
288+
289+
[Fact]
290+
public void GetCertificatesListRequest_SetsOffsetAndLimit()
291+
{
292+
var result = _sut.GetCertificatesListRequest(offset: 100, limit: 50);
293+
294+
Assert.Equal(100, result.Offset);
295+
Assert.Equal(50, result.Limit);
296+
Assert.True(result.Expired);
297+
}
298+
299+
// ---------------------------------------------------------------------
300+
// GetEnrollmentResult
301+
// ---------------------------------------------------------------------
302+
303+
[Fact]
304+
public void GetEnrollmentResult_NullEnrollmentResult_ReturnsFailed()
305+
{
306+
var result = _sut.GetEnrollmentResult(null, new AnyCAPluginCertificate { Certificate = "cert" });
307+
308+
Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
309+
}
310+
311+
[Fact]
312+
public void GetEnrollmentResult_MissingId_ReturnsFailed()
313+
{
314+
var cert = new Certificate { Id = null };
315+
316+
var result = _sut.GetEnrollmentResult(cert, new AnyCAPluginCertificate { Certificate = "cert" });
317+
318+
Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
319+
}
320+
321+
[Fact]
322+
public void GetEnrollmentResult_MissingCertificateContent_ReturnsFailed()
323+
{
324+
var cert = new Certificate { Id = Guid.NewGuid() };
325+
326+
var result = _sut.GetEnrollmentResult(cert, new AnyCAPluginCertificate { Certificate = "" });
327+
328+
Assert.Equal((int)EndEntityStatus.FAILED, result.Status);
329+
}
330+
331+
[Fact]
332+
public void GetEnrollmentResult_Valid_ReturnsGenerated()
333+
{
334+
var id = Guid.NewGuid();
335+
var cert = new Certificate { Id = id };
336+
var pluginCert = new AnyCAPluginCertificate { Certificate = "BASE64CERT" };
337+
338+
var result = _sut.GetEnrollmentResult(cert, pluginCert);
339+
340+
Assert.Equal((int)EndEntityStatus.GENERATED, result.Status);
341+
Assert.Equal(id.ToString(), result.CARequestID);
342+
Assert.Equal("BASE64CERT", result.Certificate);
343+
}
344+
}
345+
}

0 commit comments

Comments
 (0)