Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/keyfactor-release-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ on:

jobs:
call-starter-workflow:
uses: keyfactor/actions/.github/workflows/starter.yml@v3
uses: keyfactor/actions/.github/workflows/starter.yml@v5
secrets:
token: ${{ secrets.V2BUILDTOKEN}}
APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}}
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }}
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }}
scan_token: ${{ secrets.SAST_TOKEN }}
60 changes: 60 additions & 0 deletions AwsSecretsManager.Tests/CertUtilitiesConvertPfxToPemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,66 @@ public void ConvertPfxToPem_ChainPfx_ReturnsLeafOnly()
certs[0].Subject.Should().Be(leafSubject);
}

[Fact]
public void ConvertPfxToPem_IncludeChainFalse_ReturnsLeafOnly()
{
var pfx = TestCertFactory.CreateChainPfxBase64(out var leafSubject, out _);

var pem = CertUtilities.ConvertPfxToPem(pfx, TestCertFactory.Password, includeChain: false);

var certs = new X509Certificate2Collection();
certs.ImportFromPem(pem);

certs.Count.Should().Be(1);
certs[0].Subject.Should().Be(leafSubject);
}

[Fact]
public void ConvertPfxToPem_IncludeChain_ReturnsLeafThenChainThenKey()
{
var pfx = TestCertFactory.CreateChainPfxBase64(out var leafSubject, out var rootSubject);

var pem = CertUtilities.ConvertPfxToPem(pfx, TestCertFactory.Password, includeChain: true);

var certs = new X509Certificate2Collection();
certs.ImportFromPem(pem);

certs.Count.Should().Be(2, "the leaf and its issuer are both included");
certs[0].Subject.Should().Be(leafSubject, "the leaf comes first");
certs[1].Subject.Should().Be(rootSubject);

pem.IndexOf("-----BEGIN PRIVATE KEY-----", StringComparison.Ordinal).Should().BeGreaterThan(
pem.LastIndexOf("-----END CERTIFICATE-----", StringComparison.Ordinal),
"the private key follows the full chain");
}

[Fact]
public void ConvertPfxToPem_IncludeChain_KeyMatchesLeaf()
{
var pfx = TestCertFactory.CreateChainPfxBase64(out var leafSubject, out _);

var pem = CertUtilities.ConvertPfxToPem(pfx, TestCertFactory.Password, includeChain: true);

// CreateFromPem loads the first certificate and throws if the key does not match it.
using var rebuilt = X509Certificate2.CreateFromPem(pem, pem);
rebuilt.HasPrivateKey.Should().BeTrue();
rebuilt.Subject.Should().Be(leafSubject);
}

[Fact]
public void ConvertPfxToPem_IncludeChain_SelfSigned_ReturnsSingleCert()
{
var pfx = TestCertFactory.CreateSelfSignedRsaPfxBase64("CN=no-chain");

var pem = CertUtilities.ConvertPfxToPem(pfx, TestCertFactory.Password, includeChain: true);

var certs = new X509Certificate2Collection();
certs.ImportFromPem(pem);

certs.Count.Should().Be(1, "a PFX with no issuer certs has nothing to add");
pem.Should().Contain("-----BEGIN PRIVATE KEY-----");
}

[Fact]
public void ConvertPfxToPem_InvalidBase64_Throws()
{
Expand Down
82 changes: 82 additions & 0 deletions AwsSecretsManager.Tests/ClientPemIncludeChainWriteTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright 2026 Keyfactor
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0

using System.Reflection;
using System.Security.Cryptography.X509Certificates;
using Amazon.SecretsManager.Model;
using FluentAssertions;
using Xunit;

namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
{
/// <summary>
/// Exercises the client's single-PEM write generators (AWSSMPEM without SeparatePrivateKey)
/// to verify the IncludeChain store property controls whether the issuer chain is written.
/// The generate methods are private and AWS-free, so they are invoked via reflection.
/// </summary>
public class ClientPemIncludeChainWriteTests
{
[Theory]
[InlineData("GenerateAddSecretPemRequest")]
[InlineData("GenerateUpdateSecretPemRequest")]
public void PemRequest_IncludeChainFalse_WritesLeafOnly(string methodName)
{
var jp = BuildJobParameters(includeChain: false, out var leafSubject, out _);

var secretString = GetSecretString(methodName, jp);

var certs = new X509Certificate2Collection();
certs.ImportFromPem(secretString);
certs.Count.Should().Be(1, "the default format is unchanged: leaf and key only");
certs[0].Subject.Should().Be(leafSubject);
secretString.Should().Contain("-----BEGIN PRIVATE KEY-----");
}

[Theory]
[InlineData("GenerateAddSecretPemRequest")]
[InlineData("GenerateUpdateSecretPemRequest")]
public void PemRequest_IncludeChainTrue_WritesLeafAndChain(string methodName)
{
var jp = BuildJobParameters(includeChain: true, out var leafSubject, out var rootSubject);

var secretString = GetSecretString(methodName, jp);

var certs = new X509Certificate2Collection();
certs.ImportFromPem(secretString);
certs.Count.Should().Be(2);
certs[0].Subject.Should().Be(leafSubject);
certs[1].Subject.Should().Be(rootSubject);
secretString.Should().Contain("-----BEGIN PRIVATE KEY-----");
}

// ── helpers ────────────────────────────────────────────────────────

private static AwsSecretsManagerJobParameters BuildJobParameters(bool includeChain, out string leafSubject, out string rootSubject)
{
var jp = new AwsSecretsManagerJobParameters { StoreType = "AWSSMPEM" };
jp.StoreProperties.IncludeChain = includeChain;
jp.CertProperties.Alias = "chain-write-test";
jp.CertProperties.Contents = TestCertFactory.CreateChainPfxBase64(out leafSubject, out rootSubject);
jp.CertProperties.PrivateKeyPassword = TestCertFactory.Password;
return jp;
}

private static string GetSecretString(string methodName, AwsSecretsManagerJobParameters jp)
{
var client = new AwsSecretsManagerClient();
var m = typeof(AwsSecretsManagerClient).GetMethod(
methodName, BindingFlags.NonPublic | BindingFlags.Instance);
m.Should().NotBeNull($"{methodName} must be reachable via reflection");

var result = m!.Invoke(client, new object[] { jp });
return result switch
{
CreateSecretRequest c => c.SecretString,
UpdateSecretRequest u => u.SecretString,
_ => throw new System.InvalidOperationException($"unexpected return type from {methodName}")
};
}
}
}
115 changes: 115 additions & 0 deletions AwsSecretsManager.Tests/ClientUpdateSecretTagsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright 2026 Keyfactor
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0

using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using FluentAssertions;
using Moq;
using Xunit;

namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
{
/// <summary>
/// Verifies UpdateSecretTagsAsync merges tags: keys provided by Command replace existing
/// values, while tags not provided (e.g. added by other tooling) are left on the secret.
/// </summary>
public class ClientUpdateSecretTagsTests
{
[Fact]
public async Task UpdateSecretTags_OverlappingKey_UntagsOnlyOverlappingKeys()
{
var awsMock = MockAwsWithCurrentTags(
new Tag { Key = "Environment", Value = "Dev" },
new Tag { Key = "CostCenter", Value = "1234" }); // not managed by Command

await InvokeUpdateSecretTags(awsMock.Object, "my-cert",
new List<Tag> { new Tag { Key = "Environment", Value = "Prod" } });

awsMock.Verify(c => c.UntagResourceAsync(
It.Is<UntagResourceRequest>(r =>
r.SecretId == "my-cert" &&
r.TagKeys.Count == 1 &&
r.TagKeys[0] == "Environment"),
It.IsAny<CancellationToken>()), Times.Once);

awsMock.Verify(c => c.TagResourceAsync(
It.Is<TagResourceRequest>(r =>
r.Tags.Count == 1 &&
r.Tags[0].Key == "Environment" &&
r.Tags[0].Value == "Prod"),
It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task UpdateSecretTags_NoOverlap_DoesNotUntag()
{
var awsMock = MockAwsWithCurrentTags(new Tag { Key = "CostCenter", Value = "1234" });

await InvokeUpdateSecretTags(awsMock.Object, "my-cert",
new List<Tag> { new Tag { Key = "Environment", Value = "Prod" } });

awsMock.Verify(c => c.UntagResourceAsync(
It.IsAny<UntagResourceRequest>(), It.IsAny<CancellationToken>()), Times.Never);
awsMock.Verify(c => c.TagResourceAsync(
It.IsAny<TagResourceRequest>(), It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task UpdateSecretTags_NeverUntagsKeysNotProvided()
{
var awsMock = MockAwsWithCurrentTags(
new Tag { Key = "managedBy", Value = "Keyfactor" },
new Tag { Key = "Environment", Value = "Dev" },
new Tag { Key = "Owner", Value = "team-a" },
new Tag { Key = "aws:cloudformation:stack-name", Value = "stack" });

await InvokeUpdateSecretTags(awsMock.Object, "my-cert", new List<Tag>
{
new Tag { Key = "managedBy", Value = "Keyfactor" },
new Tag { Key = "Environment", Value = "Prod" }
});

awsMock.Verify(c => c.UntagResourceAsync(
It.Is<UntagResourceRequest>(r =>
r.TagKeys.OrderBy(k => k).SequenceEqual(new[] { "Environment", "managedBy" })),
It.IsAny<CancellationToken>()), Times.Once);
}

// ── helpers ────────────────────────────────────────────────────────

private static Mock<IAmazonSecretsManager> MockAwsWithCurrentTags(params Tag[] currentTags)
{
var awsMock = new Mock<IAmazonSecretsManager>();
awsMock.Setup(c => c.DescribeSecretAsync(It.IsAny<DescribeSecretRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DescribeSecretResponse { Tags = currentTags.ToList() });
awsMock.Setup(c => c.UntagResourceAsync(It.IsAny<UntagResourceRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new UntagResourceResponse());
awsMock.Setup(c => c.TagResourceAsync(It.IsAny<TagResourceRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new TagResourceResponse());
return awsMock;
}

private static Task InvokeUpdateSecretTags(IAmazonSecretsManager aws, string secretName, List<Tag> newTags)
{
var client = new AwsSecretsManagerClient();

var prop = typeof(AwsSecretsManagerClient).GetProperty(
"_secretsManagerClient", BindingFlags.NonPublic | BindingFlags.Instance);
prop.Should().NotBeNull("the AWS client must be injectable via reflection");
prop!.SetValue(client, aws);

var m = typeof(AwsSecretsManagerClient).GetMethod(
"UpdateSecretTagsAsync", BindingFlags.NonPublic | BindingFlags.Instance);
m.Should().NotBeNull("UpdateSecretTagsAsync must be reachable via reflection");

return (Task)m!.Invoke(client, new object[] { secretName, newTags })!;
}
}
}
20 changes: 20 additions & 0 deletions AwsSecretsManager.Tests/InventorySeparateKeyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ public void ConvertSecretsPem_JsonSplitWithTags_SerializesTagsAsJsonString()
tags!["Environment"].Should().Be("Prod");
}

[Fact]
public void ConvertSecretsPem_PlainPemWithChain_InventoriesFullChain()
{
// The IncludeChain format: leaf, then issuer chain, then key in a single PEM string.
var pfx = TestCertFactory.CreateChainPfxBase64(out _, out _);
var secret = new AWSSecret
{
Name = "chained-pem",
SecretString = CertUtilities.ConvertPfxToPem(pfx, TestCertFactory.Password, includeChain: true),
Tags = new List<Tag>()
};

var item = InvokeConvertPem(secret).Single();

item.Alias.Should().Be("chained-pem");
item.Certificates.Should().HaveCount(2);
item.UseChainLevel.Should().BeTrue();
item.PrivateKeyEntry.Should().BeTrue();
}

// ── helpers ────────────────────────────────────────────────────────

private static string BuildSeparateKeyJsonSecret(string subject)
Expand Down
47 changes: 44 additions & 3 deletions AwsSecretsManager.Tests/JobBaseSeparatePrivateKeyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
namespace Keyfactor.Extensions.Orchestrators.AwsSecretsManager.Tests
{
/// <summary>
/// Verifies that the SeparatePrivateKey store-type custom field is read out of the
/// serialized store Properties JSON, tolerant of the various shapes Command may use
/// to serialize a boolean custom field.
/// Verifies that the SeparatePrivateKey and IncludeChain store-type custom fields are read
/// out of the serialized store Properties JSON, tolerant of the various shapes Command may
/// use to serialize a boolean custom field.
/// </summary>
public class JobBaseSeparatePrivateKeyTests
{
Expand Down Expand Up @@ -75,5 +75,46 @@ public void SeparatePrivateKey_ParsesFalse(string properties)
{
InvokeSetStoreProperties(properties).SeparatePrivateKey.Should().BeFalse();
}

// IncludeChain uses the same parsing path; an absent field must read as false so that
// stores created before the field existed keep the leaf-only format after upgrade.

[Fact]
public void IncludeChain_DefaultsToFalse_WhenAbsent()
{
InvokeSetStoreProperties("{\"SeparatePrivateKey\":\"false\"}").IncludeChain.Should().BeFalse();
}

[Theory]
[InlineData("{\"IncludeChain\":true}")]
[InlineData("{\"IncludeChain\":\"true\"}")]
[InlineData("{\"IncludeChain\":\"True\"}")]
[InlineData("{\"IncludeChain\":{\"value\":\"true\"}}")]
[InlineData("{\"includechain\":\"true\"}")] // case-insensitive name match
public void IncludeChain_ParsesTrue(string properties)
{
InvokeSetStoreProperties(properties).IncludeChain.Should().BeTrue();
}

[Theory]
[InlineData("{\"IncludeChain\":false}")]
[InlineData("{\"IncludeChain\":\"false\"}")]
[InlineData("{\"IncludeChain\":\"\"}")]
[InlineData("{\"IncludeChain\":null}")]
[InlineData("")]
[InlineData("not valid json")]
public void IncludeChain_ParsesFalse(string properties)
{
InvokeSetStoreProperties(properties).IncludeChain.Should().BeFalse();
}

[Fact]
public void IncludeChain_AndSeparatePrivateKey_AreParsedIndependently()
{
var props = InvokeSetStoreProperties("{\"SeparatePrivateKey\":\"true\",\"IncludeChain\":\"true\"}");

props.SeparatePrivateKey.Should().BeTrue();
props.IncludeChain.Should().BeTrue();
}
}
}
Loading