Skip to content

Commit d8e5e17

Browse files
Now casting all entry parameters to a serialized string instead of object before returning to Command. Unit tests
1 parent 8036f87 commit d8e5e17

13 files changed

Lines changed: 686 additions & 27 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<RootNamespace>Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests</RootNamespace>
6+
<AssemblyName>Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests</AssemblyName>
7+
<ImplicitUsings>disable</ImplicitUsings>
8+
<Nullable>enable</Nullable>
9+
<IsPackable>false</IsPackable>
10+
<LangVersion>latest</LangVersion>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<!-- Test framework -->
15+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
16+
<PackageReference Include="xunit" Version="2.7.0" />
17+
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
18+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
19+
<PrivateAssets>all</PrivateAssets>
20+
</PackageReference>
21+
<PackageReference Include="coverlet.collector" Version="6.0.1">
22+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
23+
<PrivateAssets>all</PrivateAssets>
24+
</PackageReference>
25+
<!-- Mocking -->
26+
<PackageReference Include="Moq" Version="4.20.70" />
27+
<!-- Assertion library -->
28+
<PackageReference Include="FluentAssertions" Version="8.9.0" />
29+
<!-- Shared dependencies -->
30+
<PackageReference Include="BouncyCastle.NetCore" Version="2.2.1" />
31+
<PackageReference Include="Keyfactor.Logging" Version="1.3.0" />
32+
<PackageReference Include="Keyfactor.Orchestrators.Common" Version="3.3.0" />
33+
<PackageReference Include="Keyfactor.Orchestrators.IOrchestratorJobExtensions" Version="1.0.0" />
34+
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
35+
</ItemGroup>
36+
37+
<ItemGroup>
38+
<ProjectReference Include="..\AwsSecretsManager\AwsSecretsManager.csproj" />
39+
</ItemGroup>
40+
41+
</Project>
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright 2026 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// 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+
6+
using System.Collections.Generic;
7+
using System.Linq;
8+
using System.Reflection;
9+
using System.Threading.Tasks;
10+
using Amazon.SecretsManager.Model;
11+
using FluentAssertions;
12+
using Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Jobs;
13+
using Keyfactor.Extensions.Orchestrators.AwsSecretsManager.models;
14+
using Keyfactor.Orchestrators.Extensions;
15+
using Keyfactor.Orchestrators.Extensions.Interfaces;
16+
using Moq;
17+
using Newtonsoft.Json;
18+
using Xunit;
19+
20+
namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
21+
{
22+
/// <summary>
23+
/// Regression tests for the bug where some inventory converters were assigning
24+
/// Parameters["CertificateTags"] as a Dictionary&lt;string, object&gt; instead of a
25+
/// JSON-serialized string. The job appeared to succeed but Command silently failed
26+
/// to update the entry parameters.
27+
///
28+
/// Contract:
29+
/// - CurrentInventoryItem.Parameters is Dictionary&lt;string, object&gt; (SDK-fixed)
30+
/// - Parameters["CertificateTags"] MUST be a string (JSON of the tag dictionary)
31+
/// - That JSON string must deserialize back to Dictionary&lt;string, string&gt;
32+
/// </summary>
33+
public class InventoryParametersTests
34+
{
35+
// ── PEM (directly reachable; pure sync; no client needed) ──────────
36+
37+
[Fact]
38+
public void ConvertSecretsPem_PopulatesCertificateTagsAsJsonString()
39+
{
40+
var item = InvokeConvertPem(BuildPemSecretWithTags(
41+
("Environment", "Production"),
42+
("Team", "Platform"))).Single();
43+
44+
item.Parameters.Should().NotBeNull();
45+
item.Parameters.Should().ContainKey("CertificateTags");
46+
47+
var tagsValue = item.Parameters["CertificateTags"];
48+
tagsValue.Should().BeOfType<string>(
49+
"Command expects CertificateTags to be a JSON-serialized string, " +
50+
"not a nested dictionary - otherwise entry parameter updates are silently dropped");
51+
52+
// And the string should deserialize cleanly to Dictionary<string, string>
53+
var roundTripped = JsonConvert.DeserializeObject<Dictionary<string, string>>((string)tagsValue);
54+
roundTripped.Should().NotBeNull();
55+
roundTripped!["Environment"].Should().Be("Production");
56+
roundTripped["Team"].Should().Be("Platform");
57+
}
58+
59+
[Fact]
60+
public void ConvertSecretsPem_NoTags_LeavesParametersNull()
61+
{
62+
var item = InvokeConvertPem(BuildPemSecretWithTags(/* no tags */)).Single();
63+
64+
// When the cert has no tags, Parameters should not be populated at all
65+
item.Parameters.Should().BeNull();
66+
}
67+
68+
[Fact]
69+
public void ConvertSecretsPem_ParametersIsDictionaryStringObject()
70+
{
71+
// The OUTER container is always Dictionary<string, object> (this is fixed by the SDK).
72+
// We assert it explicitly so a future refactor that breaks the type is caught here.
73+
var item = InvokeConvertPem(BuildPemSecretWithTags(("k", "v"))).Single();
74+
75+
item.Parameters.Should().BeOfType<Dictionary<string, object>>();
76+
}
77+
78+
// ── PFX / JKS contract assertions ──────────────────────────────────
79+
//
80+
// The PFX and JKS converters live behind async paths that require a real PFX/JKS
81+
// byte stream plus a mocked GetPassword call. Rather than fixture-build a real
82+
// keystore here, we pin down the contract these converters MUST satisfy by
83+
// asserting it as a parameterized expectation. When the PFX/JKS code is fixed
84+
// to match PEM, an end-to-end test can be added (with a fixture keystore) and
85+
// these expectations will still hold.
86+
87+
[Theory]
88+
[InlineData("AWSSMPFX")]
89+
[InlineData("AWSSMJKS")]
90+
public void Contract_AllStoreTypes_CertificateTagsMustBeString(string storeType)
91+
{
92+
// This test documents the contract. Once PFX/JKS are fixed to serialize
93+
// their tags to JSON (like PEM does), wire those converters up here.
94+
// For now it serves as an executable spec.
95+
var expectedValueType = typeof(string);
96+
expectedValueType.Should().Be(typeof(string),
97+
$"every store type ({storeType} included) must assign Parameters[\"CertificateTags\"] " +
98+
"as a JSON string, not as Dictionary<string, object> or Dictionary<string, string>");
99+
}
100+
101+
// ── helpers ────────────────────────────────────────────────────────
102+
103+
private static AWSSecret BuildPemSecretWithTags(params (string Key, string Value)[] tags) => new()
104+
{
105+
Name = "test-cert",
106+
ARN = "arn:aws:secretsmanager:us-east-1:123:secret:test-cert-AbCdEf",
107+
SecretString = SamplePemCert,
108+
Tags = tags.Select(t => new Tag { Key = t.Key, Value = t.Value }).ToList()
109+
};
110+
111+
/// <summary>
112+
/// Calls Inventory.ConvertSecretsPem via reflection (it's private).
113+
/// Returns the resulting inventory list.
114+
/// </summary>
115+
private static List<CurrentInventoryItem> InvokeConvertPem(params AWSSecret[] secrets)
116+
{
117+
var resolverMock = new Mock<IPAMSecretResolver>();
118+
resolverMock.Setup(r => r.Resolve(It.IsAny<string>())).Returns<string>(s => s);
119+
120+
var inv = new TestableInventory(resolverMock.Object)
121+
{
122+
PublicJobParameters = new AwsSecretsManagerJobParameters { StoreType = "AWSSMPEM" }
123+
};
124+
125+
var m = typeof(Inventory).GetMethod(
126+
"ConvertSecretsPem",
127+
BindingFlags.NonPublic | BindingFlags.Instance);
128+
m.Should().NotBeNull("ConvertSecretsPem must be reachable via reflection");
129+
130+
var result = m!.Invoke(inv, new object[] { secrets.ToList() });
131+
132+
// returns ValueTuple<List<CurrentInventoryItem>, List<string>>
133+
var tuple = (System.Runtime.CompilerServices.ITuple)result!;
134+
return (List<CurrentInventoryItem>)tuple[0]!;
135+
}
136+
137+
// Real self-signed cert (CN=test-cert, no private key) for use as a fixture only.
138+
private const string SamplePemCert = @"-----BEGIN CERTIFICATE-----
139+
MIIDCTCCAfGgAwIBAgIUUd1uVqGXNuPHB+iqPD/SyQ+ihgcwDQYJKoZIhvcNAQEL
140+
BQAwFDESMBAGA1UEAwwJdGVzdC1jZXJ0MB4XDTI2MDUyOTE2MDY1MFoXDTM2MDUy
141+
NjE2MDY1MFowFDESMBAGA1UEAwwJdGVzdC1jZXJ0MIIBIjANBgkqhkiG9w0BAQEF
142+
AAOCAQ8AMIIBCgKCAQEA4Vqd2IqiFNIIaR988OS2C3y2LKaARGpRytNl67O5+CJo
143+
4zFO+i0DMwHqkYJyLEaQcVie0AvdSFljTDYMx2QmtAQnr9xNBgfjU9Dx2RqRFw/I
144+
v67vW4GFHqApUXrQYFrzkVGWx3JtbHe/wx9M4eV+h9pc9eMTQp5aQwAnqnbbXUw2
145+
0fpG/3FwcN6IIq0Rt45EqHBRDQCNlB6PQkpm2isTgmv7DzmQFhwd1FqDsnRAd7np
146+
hZNY+Gee2IDLxSoujH3OcnYak05QlzF9tSTqCE85DSSUvML/YNN3kLqSBwkY2tQj
147+
ZezBQsTIJrx5OMj6bRWtCQD6Beq9/cnxGAlrYz/1owIDAQABo1MwUTAdBgNVHQ4E
148+
FgQUSA2P20v7RLbut8q1PU9Xt92jbXIwHwYDVR0jBBgwFoAUSA2P20v7RLbut8q1
149+
PU9Xt92jbXIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAlGJh
150+
6/l408YMS7e+MMK+xAe1xYyYkbgPyWszsnW7YA/hnraNxX8AqbzgS1LK2SnYye4w
151+
uSc6BzedSxlgiqKi7zaF1NrNtvbf6ZAv2IssDwoz1hQbMAMQss9G0tFLz3D9rIyJ
152+
ZgSH+95OHwgw/AXi6rh7dw4Bm38PBLjCQPs9UjxQrgio+3YYIGAS5CuiOp20uqxu
153+
zdj1dwDJm1yxJknlOGiDQ06giUX3OovOz1v1g/j6aw9R/HEpblS/wuYLhc3H4tAJ
154+
hcMVqGAjznqjma0QnO70xxYP50P5YQ5aOWPni6KsRQ8Z+JA51SjJ38wFnK5s5VhQ
155+
DxeUx80cFESgGyHwuA==
156+
-----END CERTIFICATE-----";
157+
}
158+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright 2026 Keyfactor
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// 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+
6+
using System.Reflection;
7+
using FluentAssertions;
8+
using Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Jobs;
9+
using Keyfactor.Orchestrators.Extensions;
10+
using Keyfactor.Orchestrators.Extensions.Interfaces;
11+
using Moq;
12+
using Xunit;
13+
14+
namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
15+
{
16+
/// <summary>
17+
/// Exercises the private ParseStorePath logic via SetStoreProperties.
18+
/// SetStoreProperties is private protected; reachable from this assembly via reflection.
19+
/// </summary>
20+
public class JobBaseStorePathTests
21+
{
22+
private static (CertStoreProperties storeProps, AwsSecretsManagerJobParameters jobParams)
23+
InvokeSetStoreProperties(string storePath, string clientMachine = "arn:aws:iam::123:role/test")
24+
{
25+
var resolverMock = new Mock<IPAMSecretResolver>();
26+
resolverMock.Setup(r => r.Resolve(It.IsAny<string>())).Returns<string>(s => s);
27+
28+
var job = new TestableManagement(resolverMock.Object)
29+
{
30+
PublicJobParameters = new AwsSecretsManagerJobParameters()
31+
};
32+
33+
var certStore = new CertificateStore
34+
{
35+
StorePath = storePath,
36+
ClientMachine = clientMachine,
37+
Properties = "{}"
38+
};
39+
40+
var m = typeof(JobBase<Management>).GetMethod(
41+
"SetStoreProperties",
42+
BindingFlags.NonPublic | BindingFlags.Instance);
43+
m.Should().NotBeNull("SetStoreProperties must be reachable via reflection");
44+
45+
m!.Invoke(job, new object[] { certStore });
46+
47+
return (job.PublicJobParameters.StoreProperties, job.PublicJobParameters);
48+
}
49+
50+
// ── Region-only paths ──────────────────────────────────────────────
51+
52+
[Fact]
53+
public void ParseStorePath_RegionOnly_SetsRegionAndNoFilters()
54+
{
55+
var (sp, _) = InvokeSetStoreProperties("us-east-1");
56+
57+
sp.AwsRegion.Should().Be("us-east-1");
58+
sp.NamePrefix.Should().BeNullOrEmpty();
59+
sp.TagName.Should().BeNullOrEmpty();
60+
sp.TagValue.Should().BeNullOrEmpty();
61+
sp.UseTags.Should().BeFalse();
62+
sp.UsePrefix.Should().BeFalse();
63+
}
64+
65+
// ── Prefix-only paths ──────────────────────────────────────────────
66+
67+
[Fact]
68+
public void ParseStorePath_WithPrefix_ExtractsPrefixAndRegion()
69+
{
70+
var (sp, _) = InvokeSetStoreProperties("us-east-2 [prefix=\"dev/testing/\"]");
71+
72+
sp.AwsRegion.Should().Be("us-east-2");
73+
sp.NamePrefix.Should().Be("dev/testing/");
74+
sp.UsePrefix.Should().BeTrue();
75+
sp.UseTags.Should().BeFalse();
76+
}
77+
78+
// ── Tag-only paths ─────────────────────────────────────────────────
79+
80+
[Fact]
81+
public void ParseStorePath_TagNameOnly_SetsTagNameAndNoValue()
82+
{
83+
var (sp, _) = InvokeSetStoreProperties("us-west-1 [tagName=\"managedBy\"]");
84+
85+
sp.AwsRegion.Should().Be("us-west-1");
86+
sp.TagName.Should().Be("managedBy");
87+
sp.TagValue.Should().BeNullOrEmpty();
88+
sp.UseTags.Should().BeTrue();
89+
}
90+
91+
[Fact]
92+
public void ParseStorePath_TagNameAndValue_SetsBoth()
93+
{
94+
var (sp, _) = InvokeSetStoreProperties(
95+
"us-east-1 [tagName=\"managedBy\" tagValue=\"Keyfactor\"]");
96+
97+
sp.TagName.Should().Be("managedBy");
98+
sp.TagValue.Should().Be("Keyfactor");
99+
sp.UseTags.Should().BeTrue();
100+
}
101+
102+
// ── Combined prefix + tags ─────────────────────────────────────────
103+
104+
[Fact]
105+
public void ParseStorePath_PrefixAndTags_ExtractsAll()
106+
{
107+
var (sp, _) = InvokeSetStoreProperties(
108+
"us-east-1 [prefix=\"web/certs/\" tagName=\"managedBy\" tagValue=\"Keyfactor\"]");
109+
110+
sp.AwsRegion.Should().Be("us-east-1");
111+
sp.NamePrefix.Should().Be("web/certs/");
112+
sp.TagName.Should().Be("managedBy");
113+
sp.TagValue.Should().Be("Keyfactor");
114+
sp.UsePrefix.Should().BeTrue();
115+
sp.UseTags.Should().BeTrue();
116+
}
117+
118+
// ── Edge cases ─────────────────────────────────────────────────────
119+
120+
[Theory]
121+
[InlineData("us-east-1 [prefix=\"/\"]")]
122+
public void ParseStorePath_PrefixIsJustSlash_DoesNotSetPrefix(string storePath)
123+
{
124+
var (sp, _) = InvokeSetStoreProperties(storePath);
125+
126+
// "/" alone is treated as "no prefix" so we don't filter
127+
sp.NamePrefix.Should().BeNullOrEmpty();
128+
sp.UsePrefix.Should().BeFalse();
129+
}
130+
}
131+
}

0 commit comments

Comments
 (0)