diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7873d4..eec47c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ permissions: jobs: build-test: runs-on: dotnet - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@v6 with: diff --git a/Compendium.sln b/Compendium.sln index 933c44e..3386207 100644 --- a/Compendium.sln +++ b/Compendium.sln @@ -191,6 +191,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.GitHub" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.GitHub.Tests", "tests\Unit\Compendium.Adapters.GitHub.Tests\Compendium.Adapters.GitHub.Tests.csproj", "{5FE1601C-93E1-4E92-9AC9-521AA26592CB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Secrets", "src\Abstractions\Compendium.Abstractions.Secrets\Compendium.Abstractions.Secrets.csproj", "{7A60AF89-1328-4BC4-96F9-9D7113D49F1A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Abstractions.Secrets.Tests", "tests\Unit\Compendium.Abstractions.Secrets.Tests\Compendium.Abstractions.Secrets.Tests.csproj", "{BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.Scaleway.SecretManager", "src\Adapters\Compendium.Adapters.Scaleway.SecretManager\Compendium.Adapters.Scaleway.SecretManager.csproj", "{7EB70764-89D0-4855-BB8C-8B7F03268D4D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Compendium.Adapters.Scaleway.SecretManager.Tests", "tests\Unit\Compendium.Adapters.Scaleway.SecretManager.Tests\Compendium.Adapters.Scaleway.SecretManager.Tests.csproj", "{18986C9E-5284-4356-A5FE-0F642CBA1F3D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -496,6 +504,22 @@ Global {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Debug|Any CPU.Build.0 = Debug|Any CPU {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Release|Any CPU.ActiveCfg = Release|Any CPU {5FE1601C-93E1-4E92-9AC9-521AA26592CB}.Release|Any CPU.Build.0 = Release|Any CPU + {7A60AF89-1328-4BC4-96F9-9D7113D49F1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A60AF89-1328-4BC4-96F9-9D7113D49F1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A60AF89-1328-4BC4-96F9-9D7113D49F1A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A60AF89-1328-4BC4-96F9-9D7113D49F1A}.Release|Any CPU.Build.0 = Release|Any CPU + {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE}.Release|Any CPU.Build.0 = Release|Any CPU + {7EB70764-89D0-4855-BB8C-8B7F03268D4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7EB70764-89D0-4855-BB8C-8B7F03268D4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7EB70764-89D0-4855-BB8C-8B7F03268D4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7EB70764-89D0-4855-BB8C-8B7F03268D4D}.Release|Any CPU.Build.0 = Release|Any CPU + {18986C9E-5284-4356-A5FE-0F642CBA1F3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18986C9E-5284-4356-A5FE-0F642CBA1F3D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18986C9E-5284-4356-A5FE-0F642CBA1F3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18986C9E-5284-4356-A5FE-0F642CBA1F3D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {FE421F00-7FFD-4666-A961-F1FF325ECD34} = {E35C8F52-5000-4427-9589-AEB5987C1AC6} @@ -585,5 +609,9 @@ Global {C0C1EAC1-5C33-4451-8CD6-2521A8D13557} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} {4D9C4249-DD0F-4F07-B9FA-A42DAF5092CE} = {73261E87-8FCA-40B6-940B-E25CBDBE33FB} {5FE1601C-93E1-4E92-9AC9-521AA26592CB} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} + {7A60AF89-1328-4BC4-96F9-9D7113D49F1A} = {DE85A2F8-C0BA-47FD-8E9A-7D782FD6D3E2} + {BC2DB8A6-9C0D-4EBC-87D3-86031B6273EE} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} + {7EB70764-89D0-4855-BB8C-8B7F03268D4D} = {73261E87-8FCA-40B6-940B-E25CBDBE33FB} + {18986C9E-5284-4356-A5FE-0F642CBA1F3D} = {6E0B453A-55CF-4567-ADBD-50CFB84CE629} EndGlobalSection EndGlobal diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapabilities.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapabilities.cs new file mode 100644 index 0000000..f0673d6 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapabilities.cs @@ -0,0 +1,75 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Secrets.Capabilities; + +/// +/// The support level an adapter declares for a . +/// +public enum SecretVaultCapabilityLevel +{ + /// The capability is not available on this provider. + None, + + /// The capability is available with limitations (documented in the adapter's CAPABILITIES.md). + Partial, + + /// The capability is fully supported. + Full, +} + +/// +/// An adapter's declared support for a single capability. +/// +/// The support level. +/// +/// A short human-readable limitation note, required when +/// is or when a conceptually +/// available capability is declared +/// for adapter-specific reasons. +/// +public sealed record SecretVaultCapabilitySupport(SecretVaultCapabilityLevel Level, string? Limitation = null); + +/// +/// The declarative capability matrix of a secret-vault adapter. Consumers use +/// it to drive UI affordances (hide or disable unsupported features with a +/// reason) and adapters use to fail uniformly. +/// +public sealed record SecretVaultCapabilities +{ + /// + /// Gets the provider identifier these capabilities describe (matches + /// ). + /// + public required string Provider { get; init; } + + /// + /// Gets the declared support per capability. Capabilities absent from the + /// dictionary are treated as . + /// + public required IReadOnlyDictionary Entries { get; init; } + + /// + /// Returns whether the capability is available at any level + /// ( or ). + /// + public bool Supports(SecretVaultCapability capability) => + Entries.TryGetValue(capability, out var support) && support.Level != SecretVaultCapabilityLevel.None; + + /// + /// Returns success when the capability is available, otherwise the standard + /// SecretVault.CapabilityNotSupported failure. Adapters call this at + /// the top of optional-capability methods so every provider fails identically. + /// + public Result EnsureSupported(SecretVaultCapability capability) => + Supports(capability) + ? Result.Success() + : Result.Failure(SecretVaultErrors.NotSupported( + Provider, + capability, + Entries.TryGetValue(capability, out var support) ? support.Limitation : null)); +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapability.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapability.cs new file mode 100644 index 0000000..3e1428c --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Capabilities/SecretVaultCapability.cs @@ -0,0 +1,65 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Secrets.Capabilities; + +/// +/// The optional capabilities a secret-vault adapter can declare. Consumers use +/// the declared matrix to drive UI affordances and to select providers; +/// adapters use to fail +/// uniformly when an undeclared capability is invoked. +/// +public enum SecretVaultCapability +{ + /// + /// Versions are immutable once written: the material of a revision can + /// never change, so a revision reference is a stable rollback anchor. + /// This is the core contract; adapters that cannot guarantee it must not + /// declare it, and callers requiring stable history must refuse them. + /// + ImmutableVersions, + + /// + /// Individual revisions can be disabled (kill-switch for a leaked value) + /// and re-enabled without destroying the material. + /// + VersionEnableDisable, + + /// + /// Individual revisions can be permanently destroyed (retention/cost + /// control). Destroyed revisions read as not found. + /// + VersionDestroy, + + /// + /// Secrets are organized in a hierarchical path namespace that supports + /// prefix listing. + /// + PathHierarchy, + + /// + /// Secrets carry provider-side key/value tags usable as list filters. + /// + Tags, + + /// + /// The provider accepts payloads larger than 64 KiB per version. + /// + LargePayload, + + /// + /// The provider supports ephemeral (auto-expiring) secrets. Not surfaced + /// by the v1 ports; declared for capability-driven UI only. + /// + EphemeralSecrets, + + /// + /// The provider can rotate secret material server-side on a schedule. Not + /// surfaced by the v1 ports; declared for capability-driven UI only. + /// + ServerSideRotation, +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Compendium.Abstractions.Secrets.csproj b/src/Abstractions/Compendium.Abstractions.Secrets/Compendium.Abstractions.Secrets.csproj new file mode 100644 index 0000000..7dd5a1b --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Compendium.Abstractions.Secrets.csproj @@ -0,0 +1,18 @@ + + + + Compendium.Abstractions.Secrets + Compendium.Abstractions.Secrets + Compendium.Abstractions.Secrets + Secret-vault abstractions for Compendium Framework: the ISecretVault facade with container and immutable-version ports, a provider-neutral connection/credential model, and declarative per-adapter capabilities. Versions are addressed by explicit revision (never "latest"), which is what makes value history and rollback stable across providers. Provider-agnostic ports for Scaleway Secret Manager, HashiCorp Vault, cloud key vaults, and in-house stores. + + + + + + + + + + + diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultConnection.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultConnection.cs new file mode 100644 index 0000000..6595699 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultConnection.cs @@ -0,0 +1,44 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Secrets.Connections; + +/// +/// Everything an adapter needs to reach one vault tenancy on one provider. +/// Passed explicitly to every port method so adapters stay stateless +/// singletons and a single adapter instance can serve many tenancies +/// (platform-owned today, per-tenant or BYO-vault later) without contract +/// changes. +/// +public sealed record SecretVaultConnection +{ + /// + /// Gets the provider identifier this connection targets (matches + /// ). + /// + public required string Provider { get; init; } + + /// + /// Gets the provider-side tenancy container the connection is scoped to + /// (Scaleway: the project id; Vault: a namespace). + /// when the provider has no tenancy concept or the adapter's configured + /// default applies. + /// + public string? Tenancy { get; init; } + + /// + /// Gets the provider region hosting the tenancy, when regional + /// (e.g. "fr-par"). selects the adapter's + /// configured default. + /// + public string? Region { get; init; } + + /// + /// Gets the credential material used to authenticate. + /// + public required SecretVaultCredential Credential { get; init; } +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultCredential.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultCredential.cs new file mode 100644 index 0000000..e6e81da --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Connections/SecretVaultCredential.cs @@ -0,0 +1,37 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Secrets.Connections; + +/// +/// The credential material of a , as a +/// closed union. Token-bearing members redact their material in +/// ToString() so a logged connection never leaks a secret. +/// +public abstract record SecretVaultCredential +{ + private SecretVaultCredential() + { + } + + /// + /// A durable API token or key (Scaleway IAM API key secret, Vault token, + /// cloud key-vault access key). + /// + /// The token material. Redacted in ToString(). + public sealed record ApiToken(string Token) : SecretVaultCredential + { + /// + public override string ToString() => "ApiToken(***)"; + } + + /// + /// No credential material: for in-memory and null vaults, or adapters that + /// authenticate ambiently (workload identity, instance profile). + /// + public sealed record None : SecretVaultCredential; +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/GlobalUsings.cs b/src/Abstractions/Compendium.Abstractions.Secrets/GlobalUsings.cs new file mode 100644 index 0000000..d2393ca --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/GlobalUsings.cs @@ -0,0 +1,8 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Compendium.Core.Results; diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/ISecretVault.cs b/src/Abstractions/Compendium.Abstractions.Secrets/ISecretVault.cs new file mode 100644 index 0000000..b53bd70 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/ISecretVault.cs @@ -0,0 +1,43 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Capabilities; +using Compendium.Abstractions.Secrets.Services; + +namespace Compendium.Abstractions.Secrets; + +/// +/// The secret-vault facade: one instance per provider adapter, carrying the +/// provider discriminator, the declarative capability matrix, and the +/// concern-scoped ports. Consumers resolve IEnumerable<ISecretVault> +/// and dispatch on (the sub-ports are also registered +/// individually for single-concern consumers). +/// +/// +/// Implementations are stateless singletons: all tenancy state travels in the +/// passed to every method, so +/// a single adapter instance serves any number of tenancies concurrently. +/// +public interface ISecretVault +{ + /// + /// Gets the provider identifier used for dispatch (e.g. "scaleway", + /// "vault", "inmemory"). + /// + string Provider { get; } + + /// + /// Gets the adapter's declared capability matrix. + /// + SecretVaultCapabilities Capabilities { get; } + + /// Gets the secret container lifecycle port. + ISecretContainerService Secrets { get; } + + /// Gets the immutable-version port. + ISecretVersionService Versions { get; } +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/CreateVaultSecret.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/CreateVaultSecret.cs new file mode 100644 index 0000000..2867c96 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/CreateVaultSecret.cs @@ -0,0 +1,40 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Secrets.Model; + +/// +/// The request to create a secret container (which holds versions, but no +/// material yet — write the first version with +/// ). +/// +public sealed record CreateVaultSecret +{ + /// + /// Gets the secret name, unique within its . Diagnostic + /// and console-facing only — the provider-side id returned at creation is + /// the identity. + /// + public required string Name { get; init; } + + /// + /// Gets the hierarchical path the secret lives under. + /// + public required SecretScopePath Path { get; init; } + + /// + /// Gets an optional human-readable description. + /// + public string? Description { get; init; } + + /// + /// Gets optional provider-side tags (list filters, reconciliation + /// markers). Adapters without the + /// capability ignore them. + /// + public IReadOnlyDictionary? Tags { get; init; } +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretMaterial.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretMaterial.cs new file mode 100644 index 0000000..ad5bb3a --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretMaterial.cs @@ -0,0 +1,58 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text; + +namespace Compendium.Abstractions.Secrets.Model; + +/// +/// The payload of one secret version. Wraps the raw bytes so the value can +/// never leak through ToString() (logging, interpolation, exception +/// messages). No zeroing guarantees are made — the .NET GC copies freely — +/// so treat any process that held a material as having seen the plaintext. +/// +public sealed class SecretMaterial +{ + private readonly byte[] _data; + + private SecretMaterial(byte[] data) + { + _data = data; + } + + /// + /// Gets the raw payload bytes. + /// + public ReadOnlyMemory Data => _data; + + /// + /// Gets the payload size in bytes. + /// + public int Length => _data.Length; + + /// + /// Wraps raw bytes as secret material. The array is copied so later + /// mutation of the source cannot alter the material. + /// + public static SecretMaterial FromBytes(ReadOnlySpan data) => new(data.ToArray()); + + /// + /// Wraps a UTF-8 string value as secret material. + /// + public static SecretMaterial FromString(string value) => new(Encoding.UTF8.GetBytes(value)); + + /// + /// Decodes the material as a UTF-8 string. + /// + public string AsString() => Encoding.UTF8.GetString(_data); + + /// + /// Always redacts; the payload is never representable as text through this + /// type's string form. + /// + public override string ToString() => "SecretMaterial(***)"; +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretScopePath.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretScopePath.cs new file mode 100644 index 0000000..1eec351 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/SecretScopePath.cs @@ -0,0 +1,62 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Secrets.Model; + +/// +/// A validated hierarchical path (/segment/segment/...) locating a +/// secret inside a vault tenancy. The path is organizational only — callers +/// define their own layout conventions; adapters map it onto the provider's +/// folder/path concept (or a name prefix when the provider has none). The +/// path is never the secret's identity: consumers must address secrets by the +/// provider-side id returned at creation. +/// +public sealed record SecretScopePath +{ + private SecretScopePath(IReadOnlyList segments) + { + Segments = segments; + } + + /// + /// Gets the root path (/). + /// + public static SecretScopePath Root { get; } = new([]); + + /// + /// Gets the ordered path segments (empty for the root path). + /// + public IReadOnlyList Segments { get; } + + /// + /// Builds a path from validated segments: each must be non-empty, at most + /// 100 characters, and restricted to letters, digits, -, _ + /// and . (no separators, no whitespace). + /// + public static Result From(params string[] segments) + { + foreach (var segment in segments) + { + if (string.IsNullOrWhiteSpace(segment) || segment.Length > 100 || + !segment.All(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.')) + { + return Result.Failure(Error.Validation( + $"{SecretVaultErrors.Prefix}.InvalidPathSegment", + $"Invalid path segment '{segment}': segments must be 1-100 characters of letters, digits, '-', '_' or '.'.")); + } + } + + return Result.Success(new SecretScopePath([.. segments])); + } + + /// + /// Returns the canonical string form: /-prefixed joined segments, + /// or / for the root. + /// + public override string ToString() => + Segments.Count == 0 ? "/" : "/" + string.Join('/', Segments); +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretDescriptor.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretDescriptor.cs new file mode 100644 index 0000000..2a3883c --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretDescriptor.cs @@ -0,0 +1,52 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Secrets.Model; + +/// +/// A secret container as the provider reports it. Carries no material. +/// +public sealed record VaultSecretDescriptor +{ + /// + /// Gets the provider-side identifier. This is the secret's identity: + /// consumers persist and address by this id, never by name/path. + /// + public required string SecretId { get; init; } + + /// + /// Gets the secret name (unique within ). + /// + public required string Name { get; init; } + + /// + /// Gets the hierarchical path the secret lives under. + /// + public required SecretScopePath Path { get; init; } + + /// + /// Gets the human-readable description, when set. + /// + public string? Description { get; init; } + + /// + /// Gets the provider-side tags (empty when untagged or unsupported). + /// + public IReadOnlyDictionary Tags { get; init; } = + new Dictionary(); + + /// + /// Gets the number of versions the provider reports for this secret, + /// when known. + /// + public long? VersionCount { get; init; } + + /// + /// Gets the creation timestamp, when the provider reports one. + /// + public DateTimeOffset? CreatedAt { get; init; } +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretVersion.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretVersion.cs new file mode 100644 index 0000000..c8ee77f --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Model/VaultSecretVersion.cs @@ -0,0 +1,52 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Abstractions.Secrets.Model; + +/// +/// The lifecycle state of a single secret version. +/// +public enum VaultSecretVersionStatus +{ + /// The version is readable. + Enabled, + + /// The version is kill-switched: access is refused until re-enabled. + Disabled, + + /// The material was permanently destroyed; the revision number remains reserved. + Destroyed, +} + +/// +/// One immutable version of a secret, identified by its monotonically +/// increasing (1-based). A revision number is never +/// reused, which is what makes (secretId, revision) a stable value +/// reference for history and rollback. +/// +public sealed record VaultSecretVersion +{ + /// + /// Gets the 1-based, monotonically increasing revision number. + /// + public required long Revision { get; init; } + + /// + /// Gets the version's lifecycle state. + /// + public required VaultSecretVersionStatus Status { get; init; } + + /// + /// Gets the description recorded when the version was written, when any. + /// + public string? Description { get; init; } + + /// + /// Gets the creation timestamp, when the provider reports one. + /// + public DateTimeOffset? CreatedAt { get; init; } +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/README.md b/src/Abstractions/Compendium.Abstractions.Secrets/README.md new file mode 100644 index 0000000..e745bfa --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/README.md @@ -0,0 +1,38 @@ +# Compendium.Abstractions.Secrets + +Provider-agnostic secret-vault abstractions: the `ISecretVault` facade with two +concern-scoped ports (`ISecretContainerService`, `ISecretVersionService`), a +neutral connection/credential model, and declarative per-adapter capabilities. + +## Design contract + +- **Versions are immutable and addressed by explicit revision.** The port has + no "latest" concept: which revision is *current* is the caller's metadata. + This makes `(secretId, revision)` a stable value reference — history stays + trustworthy and rollback is a re-pointing of references, never a provider-side + mutation. +- **The provider-side `SecretId` is the identity.** Names and paths are + organizational/diagnostic; consumers persist ids. +- **No secret ever stringifies.** `SecretMaterial` and token credentials redact + in `ToString()`. +- **Errors are uniform** (`SecretVault.*` codes via the Result pattern — never + exceptions for control flow), so consumers handle Scaleway, Vault, or an + in-house store identically. +- **Capabilities are declarative.** Adapters declare `ImmutableVersions`, + `VersionEnableDisable`, `VersionDestroy`, `PathHierarchy`, `Tags`, + `LargePayload`, … and fail undeclared operations with + `SecretVault.CapabilityNotSupported`; each adapter documents its matrix in + `CAPABILITIES.md`. + +## Testing + +`Compendium.Testing` ships `InMemorySecretVault` (a full-fidelity fake) and +`SecretVaultContractTests`, the behavioral contract suite every adapter must +pass — inheriting it in an adapter's test suite is what makes swapping vault +backends safe. + +## Adapters + +- `Compendium.Adapters.Scaleway.SecretManager` — Scaleway Secret Manager + (regional, immutable versions, tags/paths). +- `NullSecretVault` (in this package) — fail-fast stub for unconfigured hosts. diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/SecretVaultErrors.cs b/src/Abstractions/Compendium.Abstractions.Secrets/SecretVaultErrors.cs new file mode 100644 index 0000000..4b2082f --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/SecretVaultErrors.cs @@ -0,0 +1,157 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Capabilities; + +namespace Compendium.Abstractions.Secrets; + +/// +/// Provides standardized error definitions for secret-vault operations. +/// Adapters map provider responses onto these errors so consumers can handle +/// failures uniformly across providers. +/// +public static class SecretVaultErrors +{ + /// + /// Gets the error code prefix for secret-vault errors. + /// + public const string Prefix = "SecretVault"; + + /// + /// No secret vault is configured on this host (missing credentials, + /// project, or region). Returned by and + /// by adapters whose required options are absent. + /// + public static Error NotConfigured(string? provider = null) => + Error.Unavailable( + $"{Prefix}.NotConfigured", + provider is null + ? "No secret vault is configured on this host." + : $"The '{provider}' secret vault is not configured on this host."); + + /// + /// The operation requires a capability the provider does not support (or + /// supports only partially). Carries provider and capability + /// metadata; see the adapter's CAPABILITIES.md for the support matrix. + /// + public static Error NotSupported(string provider, SecretVaultCapability capability, string? limitation = null) + { + var metadata = new Dictionary + { + ["provider"] = provider, + ["capability"] = capability.ToString(), + }; + if (limitation is not null) + { + metadata["limitation"] = limitation; + } + + return Error.Unavailable( + $"{Prefix}.CapabilityNotSupported", + limitation is null + ? $"Provider '{provider}' does not support capability '{capability}'." + : $"Provider '{provider}' does not support capability '{capability}': {limitation}", + metadata); + } + + /// + /// The vault rejected the caller's credentials (expired key, revoked + /// application, wrong tenancy). + /// + public static Error AuthenticationFailed(string provider, string? detail = null) => + Error.Unauthorized( + $"{Prefix}.AuthenticationFailed", + detail is null + ? $"Authentication with the '{provider}' secret vault failed." + : $"Authentication with the '{provider}' secret vault failed: {detail}"); + + /// + /// No secret exists for the given provider-side identifier (or it has been + /// deleted). + /// + public static Error SecretNotFound(string secretId) => + Error.NotFound( + $"{Prefix}.SecretNotFound", + $"Secret '{secretId}' was not found in the vault."); + + /// + /// The secret exists but has no revision with the given number (never + /// written, or destroyed). + /// + public static Error VersionNotFound(string secretId, long revision) => + Error.NotFound( + $"{Prefix}.VersionNotFound", + $"Revision {revision} of secret '{secretId}' was not found (never written or destroyed)."); + + /// + /// The revision exists but is disabled (kill-switch). Access is refused + /// until the revision is re-enabled. + /// + public static Error VersionDisabled(string secretId, long revision) => + Error.Conflict( + $"{Prefix}.VersionDisabled", + $"Revision {revision} of secret '{secretId}' is disabled and cannot be accessed."); + + /// + /// A secret with the same name already exists at the given path. + /// + public static Error ConflictExists(string name, string path) => + Error.Conflict( + $"{Prefix}.ConflictExists", + $"A secret named '{name}' already exists at path '{path}'."); + + /// + /// The payload exceeds the provider's per-version size limit. Carries + /// size and maxSize metadata. + /// + public static Error PayloadTooLarge(int size, int maxSize) => + Error.Validation( + $"{Prefix}.PayloadTooLarge", + $"Secret payload of {size} bytes exceeds the provider limit of {maxSize} bytes.", + new Dictionary { ["size"] = size, ["maxSize"] = maxSize }); + + /// + /// A provider-side quota was exhausted (secrets per project, versions per + /// secret, ...). Not retryable without operator action. + /// + public static Error QuotaExceeded(string provider, string detail) => + Error.Unavailable( + $"{Prefix}.QuotaExceeded", + $"Provider '{provider}' quota exceeded: {detail}"); + + /// + /// The provider throttled the request. Carries retryAfterSeconds + /// metadata when the provider supplied a Retry-After hint. + /// + public static Error Throttled(string provider, int? retryAfterSeconds = null) + { + var metadata = new Dictionary { ["provider"] = provider }; + if (retryAfterSeconds is not null) + { + metadata["retryAfterSeconds"] = retryAfterSeconds.Value; + } + + return Error.TooManyRequests( + $"{Prefix}.Throttled", + retryAfterSeconds is null + ? $"Provider '{provider}' throttled the request." + : $"Provider '{provider}' throttled the request; retry after {retryAfterSeconds.Value}s.", + metadata); + } + + /// + /// The provider rejected the request for a reason not covered by a more + /// specific error. Carries provider and statusCode metadata. + /// + public static Error ProviderRejected(string provider, int statusCode, string? detail = null) => + Error.Failure( + $"{Prefix}.ProviderRejected", + detail is null + ? $"Provider '{provider}' rejected the request with status {statusCode}." + : $"Provider '{provider}' rejected the request with status {statusCode}: {detail}", + new Dictionary { ["provider"] = provider, ["statusCode"] = statusCode }); +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretContainerService.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretContainerService.cs new file mode 100644 index 0000000..8c5a846 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretContainerService.cs @@ -0,0 +1,60 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Abstractions.Secrets.Model; + +namespace Compendium.Abstractions.Secrets.Services; + +/// +/// Secret container lifecycle: create, describe, list, delete. Containers +/// hold versions (); they carry no +/// material themselves. +/// +public interface ISecretContainerService +{ + /// + /// Creates a secret container. Fails with + /// SecretVault.ConflictExists when a secret with the same name + /// already exists at the same path. + /// + Task> CreateAsync( + SecretVaultConnection connection, + CreateVaultSecret request, + CancellationToken cancellationToken = default); + + /// + /// Describes a secret by its provider-side id. + /// + Task> GetAsync( + SecretVaultConnection connection, + string secretId, + CancellationToken cancellationToken = default); + + /// + /// Lists secrets under a path prefix, optionally filtered by tags + /// (all given tags must match). Adapters without + /// treat the + /// path as an exact match; adapters without + /// fail when a tag + /// filter is supplied. + /// + Task>> ListAsync( + SecretVaultConnection connection, + SecretScopePath pathPrefix, + IReadOnlyDictionary? tagFilter = null, + CancellationToken cancellationToken = default); + + /// + /// Deletes a secret container and every version it holds. Idempotent: + /// deleting an unknown id succeeds. + /// + Task DeleteAsync( + SecretVaultConnection connection, + string secretId, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretVersionService.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretVersionService.cs new file mode 100644 index 0000000..c86357e --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Services/ISecretVersionService.cs @@ -0,0 +1,85 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Abstractions.Secrets.Model; + +namespace Compendium.Abstractions.Secrets.Services; + +/// +/// Immutable secret versions. Every read addresses an explicit revision — +/// the port has no "latest" concept, deliberately: which revision is current +/// is the caller's metadata, so history stays stable and rollback is a +/// re-pointing of references, independent of provider-side mutable state. +/// +public interface ISecretVersionService +{ + /// + /// Appends an immutable version and returns it with its stable revision + /// number (the rollback anchor). Not safely retryable after the payload + /// was sent — callers own idempotency (deduplicate by content hash before + /// writing). + /// + Task> AddAsync( + SecretVaultConnection connection, + string secretId, + SecretMaterial material, + string? description = null, + CancellationToken cancellationToken = default); + + /// + /// Reads the material of an exact revision. Revisions are immutable, so a + /// successful read is cacheable indefinitely by the caller. Fails with + /// SecretVault.VersionDisabled on a kill-switched revision and + /// SecretVault.VersionNotFound on a destroyed or unknown one. + /// + Task> AccessAsync( + SecretVaultConnection connection, + string secretId, + long revision, + CancellationToken cancellationToken = default); + + /// + /// Lists the versions of a secret (metadata only, no material). + /// + Task>> ListAsync( + SecretVaultConnection connection, + string secretId, + CancellationToken cancellationToken = default); + + /// + /// Re-enables a disabled revision. Idempotent on an already-enabled one. + /// Requires . + /// + Task EnableAsync( + SecretVaultConnection connection, + string secretId, + long revision, + CancellationToken cancellationToken = default); + + /// + /// Kill-switches a revision: its material stops being readable until + /// re-enabled. Idempotent on an already-disabled one. Requires + /// . + /// + Task DisableAsync( + SecretVaultConnection connection, + string secretId, + long revision, + CancellationToken cancellationToken = default); + + /// + /// Permanently destroys a revision's material (retention/cost control). + /// The revision number stays reserved and subsequently reads as not + /// found. Requires . + /// + Task DestroyAsync( + SecretVaultConnection connection, + string secretId, + long revision, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/Compendium.Abstractions.Secrets/Stubs/NullSecretVault.cs b/src/Abstractions/Compendium.Abstractions.Secrets/Stubs/NullSecretVault.cs new file mode 100644 index 0000000..a9ba1f3 --- /dev/null +++ b/src/Abstractions/Compendium.Abstractions.Secrets/Stubs/NullSecretVault.cs @@ -0,0 +1,95 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Capabilities; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Abstractions.Secrets.Model; +using Compendium.Abstractions.Secrets.Services; + +namespace Compendium.Abstractions.Secrets.Stubs; + +/// +/// The fail-fast vault registered when no provider is configured: every +/// operation fails with SecretVault.NotConfigured. Keeps consumers +/// honest — a missing configuration surfaces as a uniform, diagnosable error +/// instead of a null reference or a silent no-op. +/// +public sealed class NullSecretVault : ISecretVault, ISecretContainerService, ISecretVersionService +{ + /// + /// The provider discriminator of the null vault. + /// + public const string ProviderName = "null"; + + /// + public string Provider => ProviderName; + + /// + public SecretVaultCapabilities Capabilities { get; } = new() + { + Provider = ProviderName, + Entries = new Dictionary(), + }; + + /// + public ISecretContainerService Secrets => this; + + /// + public ISecretVersionService Versions => this; + + /// + public Task> CreateAsync( + SecretVaultConnection connection, CreateVaultSecret request, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName))); + + /// + public Task> GetAsync( + SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName))); + + /// + public Task>> ListAsync( + SecretVaultConnection connection, SecretScopePath pathPrefix, + IReadOnlyDictionary? tagFilter = null, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure>(SecretVaultErrors.NotConfigured(ProviderName))); + + /// + public Task DeleteAsync( + SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName))); + + /// + public Task> AddAsync( + SecretVaultConnection connection, string secretId, SecretMaterial material, + string? description = null, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName))); + + /// + public Task> AccessAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName))); + + /// + public Task>> ListAsync( + SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure>(SecretVaultErrors.NotConfigured(ProviderName))); + + /// + public Task EnableAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName))); + + /// + public Task DisableAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName))); + + /// + public Task DestroyAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Failure(SecretVaultErrors.NotConfigured(ProviderName))); +} diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/CAPABILITIES.md b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/CAPABILITIES.md new file mode 100644 index 0000000..c48d5c9 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/CAPABILITIES.md @@ -0,0 +1,37 @@ +# Scaleway Secret Manager — capability matrix + +Adapter: `Compendium.Adapters.Scaleway.SecretManager` · Provider id: `scaleway` +API: Secret Manager regional `v1beta1` (`fr-par`, `nl-ams`, `pl-waw`) + +| Capability | Level | Notes | +|---|---|---| +| `ImmutableVersions` | **Full** | Versions are immutable; revisions are 1-based, monotonic, never reused. `(secretId, revision)` is a stable rollback anchor. | +| `VersionEnableDisable` | **Full** | Kill-switch per revision. The adapter makes enable/disable idempotent (a no-op transition reads as success). | +| `VersionDestroy` | **Full** | Permanent destruction; the revision number stays reserved. | +| `PathHierarchy` | **Full** | Secrets live in `/`-separated folders. Prefix listing is filtered adapter-side over the tenancy's secrets (paged at 100/page), keeping prefix semantics uniform across providers. | +| `Tags` | **Partial** | The provider models tags as a list of strings; the neutral key/value tags are encoded as `key:value` entries (colon-less tags decode to an empty value). Tag filters apply adapter-side. | +| `LargePayload` | **None** | A version payload is limited to 64 KiB; larger payloads fail fast with `SecretVault.PayloadTooLarge` before any network call. | +| `EphemeralSecrets` | **None** | Exists provider-side (ephemeral policies); not exposed through the v1 ports. | +| `ServerSideRotation` | **None** | Not provided by Secret Manager. | + +## Operational notes + +- **Authentication**: IAM API secret key sent as `X-Auth-Token`, supplied per + call via `SecretVaultConnection.Credential` (`ApiToken`). The adapter holds + no credential state; options carry only base URL, default region, default + project. +- **Tenancy**: `SecretVaultConnection.Tenancy` = the Scaleway project id + (falls back to `DefaultProjectId`). +- **Retries**: requests are sent exactly once. Version writes are not + idempotent; retry/deduplication policy belongs to the caller (deduplicate by + content hash before writing). +- **Throttling**: HTTP 429 maps to `SecretVault.Throttled` carrying + `retryAfterSeconds` when the provider supplies `Retry-After`. +- **Disabled-version access**: the provider's 4xx on access is disambiguated + by reading the version metadata, so consumers always receive the precise + `VersionDisabled` vs `VersionNotFound` vs `SecretNotFound` code. +- **Billing**: Scaleway bills stored versions (enabled or disabled) and API + requests. Cost levers for callers: deduplicate identical payloads before + `AddAsync`, cache `AccessAsync` results per revision (immutable → cacheable + indefinitely), destroy old revisions beyond a retention window — but never + destroy a revision still referenced as a rollback target. diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Compendium.Adapters.Scaleway.SecretManager.csproj b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Compendium.Adapters.Scaleway.SecretManager.csproj new file mode 100644 index 0000000..b00ada2 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Compendium.Adapters.Scaleway.SecretManager.csproj @@ -0,0 +1,32 @@ + + + + Compendium.Adapters.Scaleway.SecretManager + Compendium.Adapters.Scaleway.SecretManager + Compendium.Adapters.Scaleway.SecretManager + Scaleway Secret Manager adapter for Compendium Framework: a concrete ISecretVault implementing the Compendium.Abstractions.Secrets ports against the Scaleway Secret Manager regional API (v1beta1). Immutable versions addressed by revision, enable/disable kill-switch, destroy for retention control, hierarchical paths and tag filters, uniform SecretVault.* error mapping including throttling with Retry-After. + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Configuration/ScalewaySecretManagerOptions.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Configuration/ScalewaySecretManagerOptions.cs new file mode 100644 index 0000000..c4616e7 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Configuration/ScalewaySecretManagerOptions.cs @@ -0,0 +1,34 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.Scaleway.SecretManager.Configuration; + +/// +/// Adapter options. Deliberately credential-free: the IAM secret key travels +/// in the +/// passed to every call, so one adapter instance serves any number of +/// tenancies and no secret lives in host configuration. +/// +public sealed class ScalewaySecretManagerOptions +{ + /// + /// Gets or sets the API base URL. Override for testing against a stub. + /// + public Uri ApiBaseUrl { get; set; } = ScalewayDefaults.DefaultApiBase; + + /// + /// Gets or sets the region used when the connection does not specify one + /// (e.g. "fr-par", "nl-ams", "pl-waw"). + /// + public string DefaultRegion { get; set; } = "fr-par"; + + /// + /// Gets or sets the Scaleway project id used when the connection does not + /// specify a tenancy. + /// + public string? DefaultProjectId { get; set; } +} diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/DependencyInjection/ServiceCollectionExtensions.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..279e73f --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,59 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets; +using Compendium.Adapters.Scaleway.SecretManager.Configuration; +using Compendium.Adapters.Scaleway.SecretManager.Http; +using Compendium.Adapters.Scaleway.SecretManager.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Compendium.Adapters.Scaleway.SecretManager.DependencyInjection; + +/// +/// DI extensions for registering the Scaleway Secret Manager +/// adapter. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers the Scaleway Secret Manager adapter. The facade is + /// contributed to IEnumerable<ISecretVault> for + /// provider-dispatch consumers and also resolvable as + /// directly. + /// + /// The service collection. + /// An optional callback to configure adapter options. + /// The service collection, for chaining. + public static IServiceCollection AddScalewaySecretVault( + this IServiceCollection services, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + if (configure is not null) + { + services.Configure(configure); + } + else + { + services.AddOptions(); + } + + services.AddHttpClient(ScalewayDefaults.HttpClientName); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => new ScalewaySecretVault( + sp.GetRequiredService(), + sp.GetRequiredService())); + services.TryAddEnumerable(ServiceDescriptor.Singleton( + static sp => sp.GetRequiredService())); + + return services; + } +} diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/GlobalUsings.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/GlobalUsings.cs new file mode 100644 index 0000000..d2393ca --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/GlobalUsings.cs @@ -0,0 +1,8 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +global using Compendium.Core.Results; diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiClient.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiClient.cs new file mode 100644 index 0000000..e11e754 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiClient.cs @@ -0,0 +1,167 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Adapters.Scaleway.SecretManager.Configuration; +using Microsoft.Extensions.Options; + +namespace Compendium.Adapters.Scaleway.SecretManager.Http; + +/// +/// The raw HTTP executor for the Secret Manager regional API: builds +/// authenticated requests from the per-call connection, deserializes +/// snake_case bodies, and maps provider failures onto the uniform +/// SecretVault.* errors. Requests are sent exactly once — write +/// endpoints are not idempotent, so retry policy belongs to callers who can +/// deduplicate. +/// +internal sealed class ScalewayApiClient +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly ScalewaySecretManagerOptions _options; + + public ScalewayApiClient(IHttpClientFactory httpClientFactory, IOptions options) + { + _httpClientFactory = httpClientFactory; + _options = options.Value; + } + + /// + /// Resolves the Scaleway project id from the connection tenancy or the + /// configured default. + /// + public Result ResolveProject(SecretVaultConnection connection) + { + var project = connection.Tenancy ?? _options.DefaultProjectId; + return string.IsNullOrWhiteSpace(project) + ? Result.Failure(SecretVaultErrors.NotConfigured(ScalewayDefaults.Provider)) + : Result.Success(project); + } + + /// + /// Sends a request and deserializes the successful body. notFound + /// and conflict supply the caller's context-specific mapping for + /// 404/409; every other failure maps uniformly. + /// + public async Task> SendAsync( + SecretVaultConnection connection, + HttpMethod method, + string path, + object? body, + Error notFound, + Error? conflict = null, + CancellationToken cancellationToken = default) + { + if (connection.Credential is not SecretVaultCredential.ApiToken token || + string.IsNullOrWhiteSpace(token.Token)) + { + return Result.Failure(SecretVaultErrors.NotConfigured(ScalewayDefaults.Provider)); + } + + using var request = new HttpRequestMessage(method, BuildUri(connection, path)); + request.Headers.Add(ScalewayDefaults.AuthHeader, token.Token); + if (body is not null) + { + request.Content = JsonContent.Create(body, options: Json); + } + + HttpResponseMessage response; + try + { + var client = _httpClientFactory.CreateClient(ScalewayDefaults.HttpClientName); + response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return Result.Failure(SecretVaultErrors.ProviderRejected( + ScalewayDefaults.Provider, 0, ex.Message)); + } + + using (response) + { + if (response.IsSuccessStatusCode) + { + if (typeof(TResponse) == typeof(Unit)) + { + return Result.Success((TResponse)(object)Unit.Value); + } + + var payload = await response.Content + .ReadFromJsonAsync(Json, cancellationToken) + .ConfigureAwait(false); + return payload is null + ? Result.Failure(SecretVaultErrors.ProviderRejected( + ScalewayDefaults.Provider, (int)response.StatusCode, "Empty response body.")) + : Result.Success(payload); + } + + var error = await MapErrorAsync(response, notFound, conflict, cancellationToken).ConfigureAwait(false); + return Result.Failure(error); + } + } + + /// A no-body marker for void endpoints. + internal readonly record struct Unit + { + public static Unit Value => default; + } + + private Uri BuildUri(SecretVaultConnection connection, string path) + { + var region = connection.Region ?? _options.DefaultRegion; + return new Uri(_options.ApiBaseUrl, $"secret-manager/v1beta1/regions/{region}/{path}"); + } + + private static async Task MapErrorAsync( + HttpResponseMessage response, Error notFound, Error? conflict, CancellationToken cancellationToken) + { + var detail = await ReadErrorMessageAsync(response, cancellationToken).ConfigureAwait(false); + return response.StatusCode switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => + SecretVaultErrors.AuthenticationFailed(ScalewayDefaults.Provider, detail), + HttpStatusCode.NotFound => notFound, + HttpStatusCode.Conflict when conflict is not null => conflict, + HttpStatusCode.TooManyRequests => + SecretVaultErrors.Throttled(ScalewayDefaults.Provider, ReadRetryAfterSeconds(response)), + HttpStatusCode.BadRequest or HttpStatusCode.PreconditionFailed when + detail is not null && detail.Contains("quota", StringComparison.OrdinalIgnoreCase) => + SecretVaultErrors.QuotaExceeded(ScalewayDefaults.Provider, detail), + _ => SecretVaultErrors.ProviderRejected( + ScalewayDefaults.Provider, (int)response.StatusCode, detail), + }; + } + + private static async Task ReadErrorMessageAsync( + HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content + .ReadFromJsonAsync(Json, cancellationToken) + .ConfigureAwait(false); + return body?.Message; + } + catch (JsonException) + { + return null; + } + } + + private static int? ReadRetryAfterSeconds(HttpResponseMessage response) => + response.Headers.RetryAfter?.Delta is { } delta ? (int)delta.TotalSeconds : null; +} diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiModels.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiModels.cs new file mode 100644 index 0000000..a9a05c7 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayApiModels.cs @@ -0,0 +1,129 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text.Json.Serialization; + +namespace Compendium.Adapters.Scaleway.SecretManager.Http; + +/// A Secret Manager secret resource. +internal sealed record ScalewaySecret +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("project_id")] + public string? ProjectId { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("path")] + public string? Path { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("tags")] + public IReadOnlyList? Tags { get; init; } + + [JsonPropertyName("version_count")] + public long? VersionCount { get; init; } + + [JsonPropertyName("created_at")] + public DateTimeOffset? CreatedAt { get; init; } +} + +/// A Secret Manager secret version resource. +internal sealed record ScalewaySecretVersion +{ + [JsonPropertyName("revision")] + public required long Revision { get; init; } + + [JsonPropertyName("secret_id")] + public string? SecretId { get; init; } + + [JsonPropertyName("status")] + public string? Status { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("created_at")] + public DateTimeOffset? CreatedAt { get; init; } +} + +/// The payload returned by the version access endpoint. +internal sealed record ScalewayAccessResponse +{ + [JsonPropertyName("revision")] + public long Revision { get; init; } + + [JsonPropertyName("data")] + public required string Data { get; init; } +} + +/// A paginated secret listing. +internal sealed record ScalewayListSecretsResponse +{ + [JsonPropertyName("secrets")] + public IReadOnlyList? Secrets { get; init; } + + [JsonPropertyName("total_count")] + public long TotalCount { get; init; } +} + +/// A paginated version listing. +internal sealed record ScalewayListVersionsResponse +{ + [JsonPropertyName("versions")] + public IReadOnlyList? Versions { get; init; } + + [JsonPropertyName("total_count")] + public long TotalCount { get; init; } +} + +/// The request to create a secret. +internal sealed record ScalewayCreateSecretRequest +{ + [JsonPropertyName("project_id")] + public required string ProjectId { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } + + [JsonPropertyName("tags")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Tags { get; init; } +} + +/// The request to append a secret version. +internal sealed record ScalewayCreateVersionRequest +{ + [JsonPropertyName("data")] + public required string Data { get; init; } + + [JsonPropertyName("description")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Description { get; init; } +} + +/// A Scaleway API error body. +internal sealed record ScalewayErrorResponse +{ + [JsonPropertyName("message")] + public string? Message { get; init; } + + [JsonPropertyName("type")] + public string? Type { get; init; } +} diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayMapping.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayMapping.cs new file mode 100644 index 0000000..bf42b8f --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Http/ScalewayMapping.cs @@ -0,0 +1,97 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Model; + +namespace Compendium.Adapters.Scaleway.SecretManager.Http; + +/// +/// Pure mappings between the neutral abstraction model and the Secret Manager +/// wire model: key/value tags to the provider's string list +/// (key:value), paths, statuses, and resource descriptors. +/// +internal static class ScalewayMapping +{ + /// + /// Encodes neutral key/value tags as the provider's string list. + /// + public static IReadOnlyList? ToTagList(IReadOnlyDictionary? tags) => + tags is null or { Count: 0 } + ? null + : [.. tags.Select(kv => string.IsNullOrEmpty(kv.Value) ? kv.Key : $"{kv.Key}:{kv.Value}")]; + + /// + /// Decodes the provider's string list back into key/value tags (first + /// : splits; colon-less tags map to an empty value). + /// + public static IReadOnlyDictionary FromTagList(IReadOnlyList? tags) + { + var result = new Dictionary(); + foreach (var tag in tags ?? []) + { + var separator = tag.IndexOf(':', StringComparison.Ordinal); + if (separator < 0) + { + result[tag] = string.Empty; + } + else + { + result[tag[..separator]] = tag[(separator + 1)..]; + } + } + + return result; + } + + /// + /// Parses a provider path (/a/b) back into a scope path, tolerating + /// separators and empty segments. + /// + public static SecretScopePath ParsePath(string? path) + { + var segments = (path ?? "/").Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var parsed = SecretScopePath.From(segments); + return parsed.IsSuccess ? parsed.Value : SecretScopePath.Root; + } + + /// + /// Maps a provider version status onto the neutral lifecycle. Unknown + /// statuses read as disabled (fail-closed for consumption). + /// + public static VaultSecretVersionStatus ParseStatus(string? status) => + status?.ToLowerInvariant() switch + { + "enabled" => VaultSecretVersionStatus.Enabled, + "deleted" or "destroyed" => VaultSecretVersionStatus.Destroyed, + _ => VaultSecretVersionStatus.Disabled, + }; + + /// + /// Maps a provider secret onto the neutral descriptor. + /// + public static VaultSecretDescriptor ToDescriptor(ScalewaySecret secret) => new() + { + SecretId = secret.Id, + Name = secret.Name, + Path = ParsePath(secret.Path), + Description = string.IsNullOrEmpty(secret.Description) ? null : secret.Description, + Tags = FromTagList(secret.Tags), + VersionCount = secret.VersionCount, + CreatedAt = secret.CreatedAt, + }; + + /// + /// Maps a provider version onto the neutral version. + /// + public static VaultSecretVersion ToVersion(ScalewaySecretVersion version) => new() + { + Revision = version.Revision, + Status = ParseStatus(version.Status), + Description = string.IsNullOrEmpty(version.Description) ? null : version.Description, + CreatedAt = version.CreatedAt, + }; +} diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/README.md b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/README.md new file mode 100644 index 0000000..80fa6a6 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/README.md @@ -0,0 +1,35 @@ +# Compendium.Adapters.Scaleway.SecretManager + +Scaleway Secret Manager adapter for `Compendium.Abstractions.Secrets`: a +concrete `ISecretVault` over the regional `v1beta1` REST API (raw HTTP, +`X-Auth-Token` auth — no provider SDK dependency). + +```csharp +services.AddScalewaySecretVault(o => +{ + o.DefaultRegion = "fr-par"; + o.DefaultProjectId = ""; +}); + +var connection = new SecretVaultConnection +{ + Provider = "scaleway", + Credential = new SecretVaultCredential.ApiToken(scwSecretKey), +}; + +var created = await vault.Secrets.CreateAsync(connection, new CreateVaultSecret +{ + Name = "db-password", + Path = SecretScopePath.From("myapp", "prod").Value, +}); +var v1 = await vault.Versions.AddAsync(connection, created.Value.SecretId, + SecretMaterial.FromString("hunter2")); +var read = await vault.Versions.AccessAsync(connection, created.Value.SecretId, v1.Value.Revision); +``` + +See `CAPABILITIES.md` for the declared capability matrix and operational +notes (64 KiB payload limit, tag encoding, retry policy, billing levers). + +The adapter passes the `SecretVaultContractTests` behavioral contract from +`Compendium.Testing` (run against a live tenancy via the integration suite; +CI runs the same contract against `InMemorySecretVault`). diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewayDefaults.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewayDefaults.cs new file mode 100644 index 0000000..3926e43 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewayDefaults.cs @@ -0,0 +1,31 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +namespace Compendium.Adapters.Scaleway.SecretManager; + +/// +/// Shared constants for the Scaleway Secret Manager adapter: the provider +/// discriminator, the named , the API +/// base and the provider's per-version payload limit. +/// +internal static class ScalewayDefaults +{ + /// The provider discriminator (matches ). + public const string Provider = "scaleway"; + + /// The named HttpClient used for Secret Manager REST calls. + public const string HttpClientName = "compendium-scaleway-secret-manager"; + + /// The authentication header carrying the IAM API secret key. + public const string AuthHeader = "X-Auth-Token"; + + /// The Secret Manager per-version payload limit, in bytes (64 KiB). + public const int MaxPayloadBytes = 64 * 1024; + + /// The api.scaleway.com base URL used when no override is configured. + public static readonly Uri DefaultApiBase = new("https://api.scaleway.com"); +} diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewaySecretVault.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewaySecretVault.cs new file mode 100644 index 0000000..a4ee39c --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/ScalewaySecretVault.cs @@ -0,0 +1,65 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Capabilities; +using Compendium.Abstractions.Secrets.Services; +using Compendium.Adapters.Scaleway.SecretManager.Services; + +namespace Compendium.Adapters.Scaleway.SecretManager; + +/// +/// The Scaleway Secret Manager : a stateless +/// singleton facade over the regional v1beta1 API. See CAPABILITIES.md for +/// the declared matrix and its limitations. +/// +public sealed class ScalewaySecretVault : ISecretVault +{ + private readonly ScalewaySecretContainerService _secrets; + private readonly ScalewaySecretVersionService _versions; + + internal ScalewaySecretVault( + ScalewaySecretContainerService secrets, ScalewaySecretVersionService versions) + { + _secrets = secrets; + _versions = versions; + } + + /// + public string Provider => ScalewayDefaults.Provider; + + /// + public SecretVaultCapabilities Capabilities { get; } = new() + { + Provider = ScalewayDefaults.Provider, + Entries = new Dictionary + { + [SecretVaultCapability.ImmutableVersions] = new(SecretVaultCapabilityLevel.Full), + [SecretVaultCapability.VersionEnableDisable] = new(SecretVaultCapabilityLevel.Full), + [SecretVaultCapability.VersionDestroy] = new(SecretVaultCapabilityLevel.Full), + [SecretVaultCapability.PathHierarchy] = new( + SecretVaultCapabilityLevel.Full, + "Prefix listing is filtered adapter-side over the tenancy's secrets."), + [SecretVaultCapability.Tags] = new( + SecretVaultCapabilityLevel.Partial, + "Key/value tags are encoded as 'key:value' strings on the provider's tag list."), + [SecretVaultCapability.LargePayload] = new( + SecretVaultCapabilityLevel.None, + "Secret Manager limits a version payload to 64 KiB."), + [SecretVaultCapability.EphemeralSecrets] = new( + SecretVaultCapabilityLevel.None, + "Supported by the provider but not exposed through the v1 ports."), + [SecretVaultCapability.ServerSideRotation] = new(SecretVaultCapabilityLevel.None), + }, + }; + + /// + public ISecretContainerService Secrets => _secrets; + + /// + public ISecretVersionService Versions => _versions; +} diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretContainerService.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretContainerService.cs new file mode 100644 index 0000000..ea43d08 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretContainerService.cs @@ -0,0 +1,147 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Abstractions.Secrets.Model; +using Compendium.Abstractions.Secrets.Services; +using Compendium.Adapters.Scaleway.SecretManager.Http; + +namespace Compendium.Adapters.Scaleway.SecretManager.Services; + +/// +/// Secret container lifecycle against the Secret Manager API. Listing fetches +/// the tenancy's secrets page by page and applies path-prefix and tag filters +/// client-side, which keeps prefix semantics uniform across providers. +/// +internal sealed class ScalewaySecretContainerService : ISecretContainerService +{ + private const int PageSize = 100; + + private readonly ScalewayApiClient _client; + + public ScalewaySecretContainerService(ScalewayApiClient client) + { + _client = client; + } + + /// + public async Task> CreateAsync( + SecretVaultConnection connection, CreateVaultSecret request, CancellationToken cancellationToken = default) + { + var project = _client.ResolveProject(connection); + if (project.IsFailure) + { + return Result.Failure(project.Error); + } + + var result = await _client.SendAsync( + connection, + HttpMethod.Post, + "secrets", + new ScalewayCreateSecretRequest + { + ProjectId = project.Value, + Name = request.Name, + Path = request.Path.ToString(), + Description = request.Description, + Tags = ScalewayMapping.ToTagList(request.Tags), + }, + notFound: SecretVaultErrors.ProviderRejected(ScalewayDefaults.Provider, 404, "Project or region not found."), + conflict: SecretVaultErrors.ConflictExists(request.Name, request.Path.ToString()), + cancellationToken).ConfigureAwait(false); + + return result.IsFailure + ? Result.Failure(result.Error) + : Result.Success(ScalewayMapping.ToDescriptor(result.Value)); + } + + /// + public async Task> GetAsync( + SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) + { + var result = await _client.SendAsync( + connection, + HttpMethod.Get, + $"secrets/{Uri.EscapeDataString(secretId)}", + body: null, + notFound: SecretVaultErrors.SecretNotFound(secretId), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return result.IsFailure + ? Result.Failure(result.Error) + : Result.Success(ScalewayMapping.ToDescriptor(result.Value)); + } + + /// + public async Task>> ListAsync( + SecretVaultConnection connection, SecretScopePath pathPrefix, + IReadOnlyDictionary? tagFilter = null, CancellationToken cancellationToken = default) + { + var project = _client.ResolveProject(connection); + if (project.IsFailure) + { + return Result.Failure>(project.Error); + } + + var all = new List(); + for (var page = 1; ; page++) + { + var result = await _client.SendAsync( + connection, + HttpMethod.Get, + $"secrets?project_id={Uri.EscapeDataString(project.Value)}&page={page}&page_size={PageSize}", + body: null, + notFound: SecretVaultErrors.ProviderRejected(ScalewayDefaults.Provider, 404, "Project or region not found."), + cancellationToken: cancellationToken).ConfigureAwait(false); + if (result.IsFailure) + { + return Result.Failure>(result.Error); + } + + var batch = result.Value.Secrets ?? []; + all.AddRange(batch); + if (batch.Count < PageSize || all.Count >= result.Value.TotalCount) + { + break; + } + } + + var prefix = pathPrefix.ToString(); + var matches = all + .Select(ScalewayMapping.ToDescriptor) + .Where(d => IsUnderPrefix(d.Path.ToString(), prefix)) + .Where(d => tagFilter is null || + tagFilter.All(kv => d.Tags.TryGetValue(kv.Key, out var v) && v == kv.Value)) + .OrderBy(d => d.Path.ToString(), StringComparer.Ordinal) + .ThenBy(d => d.Name, StringComparer.Ordinal) + .ToList(); + + return Result.Success>(matches); + } + + /// + public async Task DeleteAsync( + SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) + { + var result = await _client.SendAsync( + connection, + HttpMethod.Delete, + $"secrets/{Uri.EscapeDataString(secretId)}", + body: null, + notFound: SecretVaultErrors.SecretNotFound(secretId), + cancellationToken: cancellationToken).ConfigureAwait(false); + + // Deleting an already-gone secret is a success (idempotent contract). + return result.IsSuccess || result.Error.Code == $"{SecretVaultErrors.Prefix}.SecretNotFound" + ? Result.Success() + : Result.Failure(result.Error); + } + + private static bool IsUnderPrefix(string path, string prefix) => + prefix == "/" || path == prefix || path.StartsWith(prefix + "/", StringComparison.Ordinal); +} diff --git a/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretVersionService.cs b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretVersionService.cs new file mode 100644 index 0000000..93f2794 --- /dev/null +++ b/src/Adapters/Compendium.Adapters.Scaleway.SecretManager/Services/ScalewaySecretVersionService.cs @@ -0,0 +1,221 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Abstractions.Secrets.Model; +using Compendium.Abstractions.Secrets.Services; +using Compendium.Adapters.Scaleway.SecretManager.Http; + +namespace Compendium.Adapters.Scaleway.SecretManager.Services; + +/// +/// Immutable versions against the Secret Manager API. Access is by explicit +/// revision only; ambiguous provider failures (a 4xx on access) are +/// disambiguated by reading the version metadata so callers always get the +/// precise VersionDisabled / VersionNotFound / SecretNotFound +/// code. Enable/disable are made idempotent by treating "already in target +/// state" as success. +/// +internal sealed class ScalewaySecretVersionService : ISecretVersionService +{ + private const int PageSize = 100; + + private readonly ScalewayApiClient _client; + + public ScalewaySecretVersionService(ScalewayApiClient client) + { + _client = client; + } + + /// + public async Task> AddAsync( + SecretVaultConnection connection, string secretId, SecretMaterial material, + string? description = null, CancellationToken cancellationToken = default) + { + if (material.Length > ScalewayDefaults.MaxPayloadBytes) + { + return Result.Failure( + SecretVaultErrors.PayloadTooLarge(material.Length, ScalewayDefaults.MaxPayloadBytes)); + } + + var result = await _client.SendAsync( + connection, + HttpMethod.Post, + $"secrets/{Uri.EscapeDataString(secretId)}/versions", + new ScalewayCreateVersionRequest + { + Data = Convert.ToBase64String(material.Data.Span), + Description = description, + }, + notFound: SecretVaultErrors.SecretNotFound(secretId), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return result.IsFailure + ? Result.Failure(result.Error) + : Result.Success(ScalewayMapping.ToVersion(result.Value)); + } + + /// + public async Task> AccessAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) + { + var result = await _client.SendAsync( + connection, + HttpMethod.Get, + $"secrets/{Uri.EscapeDataString(secretId)}/versions/{revision}/access", + body: null, + notFound: SecretVaultErrors.VersionNotFound(secretId, revision), + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.IsSuccess) + { + try + { + return Result.Success(SecretMaterial.FromBytes(Convert.FromBase64String(result.Value.Data))); + } + catch (FormatException) + { + return Result.Failure(SecretVaultErrors.ProviderRejected( + ScalewayDefaults.Provider, 200, "Version payload was not valid base64.")); + } + } + + // The provider refuses access to disabled versions with the same 4xx + // shape as a missing one; disambiguate through the version metadata. + if (result.Error.Type is ErrorType.NotFound or ErrorType.Failure) + { + var version = await GetVersionAsync(connection, secretId, revision, cancellationToken).ConfigureAwait(false); + if (version.IsSuccess && version.Value.Status == VaultSecretVersionStatus.Disabled) + { + return Result.Failure(SecretVaultErrors.VersionDisabled(secretId, revision)); + } + + if (version.IsFailure && version.Error.Code == $"{SecretVaultErrors.Prefix}.SecretNotFound") + { + return Result.Failure(version.Error); + } + } + + return Result.Failure(result.Error); + } + + /// + public async Task>> ListAsync( + SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) + { + var all = new List(); + for (var page = 1; ; page++) + { + var result = await _client.SendAsync( + connection, + HttpMethod.Get, + $"secrets/{Uri.EscapeDataString(secretId)}/versions?page={page}&page_size={PageSize}", + body: null, + notFound: SecretVaultErrors.SecretNotFound(secretId), + cancellationToken: cancellationToken).ConfigureAwait(false); + if (result.IsFailure) + { + return Result.Failure>(result.Error); + } + + var batch = result.Value.Versions ?? []; + all.AddRange(batch); + if (batch.Count < PageSize || all.Count >= result.Value.TotalCount) + { + break; + } + } + + return Result.Success>( + [.. all.Select(ScalewayMapping.ToVersion).OrderBy(v => v.Revision)]); + } + + /// + public Task EnableAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) => + SetStatusAsync(connection, secretId, revision, enable: true, cancellationToken); + + /// + public Task DisableAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) => + SetStatusAsync(connection, secretId, revision, enable: false, cancellationToken); + + /// + public async Task DestroyAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) + { + var result = await _client.SendAsync( + connection, + HttpMethod.Delete, + $"secrets/{Uri.EscapeDataString(secretId)}/versions/{revision}", + body: null, + notFound: SecretVaultErrors.VersionNotFound(secretId, revision), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return result.IsFailure ? Result.Failure(result.Error) : Result.Success(); + } + + private async Task SetStatusAsync( + SecretVaultConnection connection, string secretId, long revision, bool enable, + CancellationToken cancellationToken) + { + var action = enable ? "enable" : "disable"; + var result = await _client.SendAsync( + connection, + HttpMethod.Post, + $"secrets/{Uri.EscapeDataString(secretId)}/versions/{revision}/{action}", + body: new { }, + notFound: SecretVaultErrors.VersionNotFound(secretId, revision), + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.IsSuccess) + { + return Result.Success(); + } + + // The provider rejects a no-op transition; the contract requires + // idempotency, so "already in the target state" is a success. + var version = await GetVersionAsync(connection, secretId, revision, cancellationToken).ConfigureAwait(false); + var target = enable ? VaultSecretVersionStatus.Enabled : VaultSecretVersionStatus.Disabled; + return version.IsSuccess && version.Value.Status == target + ? Result.Success() + : Result.Failure(result.Error); + } + + private async Task> GetVersionAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken) + { + var result = await _client.SendAsync( + connection, + HttpMethod.Get, + $"secrets/{Uri.EscapeDataString(secretId)}/versions/{revision}", + body: null, + notFound: SecretVaultErrors.VersionNotFound(secretId, revision), + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (result.IsFailure && result.Error.Code == $"{SecretVaultErrors.Prefix}.VersionNotFound") + { + // Distinguish "secret gone" from "version gone". + var secret = await _client.SendAsync( + connection, + HttpMethod.Get, + $"secrets/{Uri.EscapeDataString(secretId)}", + body: null, + notFound: SecretVaultErrors.SecretNotFound(secretId), + cancellationToken: cancellationToken).ConfigureAwait(false); + if (secret.IsFailure) + { + return Result.Failure(secret.Error); + } + } + + return result.IsFailure + ? Result.Failure(result.Error) + : Result.Success(ScalewayMapping.ToVersion(result.Value)); + } +} diff --git a/src/Testing/Compendium.Testing/Compendium.Testing.csproj b/src/Testing/Compendium.Testing/Compendium.Testing.csproj index 8e5d33b..357279a 100644 --- a/src/Testing/Compendium.Testing/Compendium.Testing.csproj +++ b/src/Testing/Compendium.Testing/Compendium.Testing.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Testing/Compendium.Testing/Secrets/InMemorySecretVault.cs b/src/Testing/Compendium.Testing/Secrets/InMemorySecretVault.cs new file mode 100644 index 0000000..5245e69 --- /dev/null +++ b/src/Testing/Compendium.Testing/Secrets/InMemorySecretVault.cs @@ -0,0 +1,310 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Collections.Concurrent; +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Capabilities; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Abstractions.Secrets.Model; +using Compendium.Abstractions.Secrets.Services; +using Compendium.Core.Results; + +namespace Compendium.Testing.Secrets; + +/// +/// A full-fidelity in-memory for tests and dev +/// hosts: immutable monotonic revisions, enable/disable/destroy semantics, +/// path-prefix listing and tag filters, thread-safe, with a call log for +/// interaction assertions. Subscribes to +/// so the fake and real adapters stay behaviorally aligned. +/// +public sealed class InMemorySecretVault : ISecretVault, ISecretContainerService, ISecretVersionService +{ + /// + /// The provider discriminator of the in-memory vault. + /// + public const string ProviderName = "inmemory"; + + private readonly ConcurrentDictionary _secrets = new(); + private readonly ConcurrentQueue _callLog = new(); + private readonly object _createLock = new(); + + /// + public string Provider => ProviderName; + + /// + public SecretVaultCapabilities Capabilities { get; } = new() + { + Provider = ProviderName, + Entries = new Dictionary + { + [SecretVaultCapability.ImmutableVersions] = new(SecretVaultCapabilityLevel.Full), + [SecretVaultCapability.VersionEnableDisable] = new(SecretVaultCapabilityLevel.Full), + [SecretVaultCapability.VersionDestroy] = new(SecretVaultCapabilityLevel.Full), + [SecretVaultCapability.PathHierarchy] = new(SecretVaultCapabilityLevel.Full), + [SecretVaultCapability.Tags] = new(SecretVaultCapabilityLevel.Full), + [SecretVaultCapability.LargePayload] = new(SecretVaultCapabilityLevel.Full), + }, + }; + + /// + public ISecretContainerService Secrets => this; + + /// + public ISecretVersionService Versions => this; + + /// + /// Gets the ordered log of port calls ("MethodName secretId") for + /// interaction assertions. + /// + public IReadOnlyCollection CallLog => _callLog; + + /// + public Task> CreateAsync( + SecretVaultConnection connection, CreateVaultSecret request, CancellationToken cancellationToken = default) + { + _callLog.Enqueue($"CreateAsync {request.Path}/{request.Name}"); + lock (_createLock) + { + var path = request.Path.ToString(); + if (_secrets.Values.Any(s => s.Name == request.Name && s.Path == path)) + { + return Task.FromResult(Result.Failure( + SecretVaultErrors.ConflictExists(request.Name, path))); + } + + var stored = new StoredSecret + { + Id = Guid.NewGuid().ToString(), + Name = request.Name, + Path = path, + PathSegments = [.. request.Path.Segments], + Description = request.Description, + Tags = new Dictionary(request.Tags ?? new Dictionary()), + CreatedAt = DateTimeOffset.UtcNow, + }; + _secrets[stored.Id] = stored; + return Task.FromResult(Result.Success(Describe(stored))); + } + } + + /// + public Task> GetAsync( + SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) + { + _callLog.Enqueue($"GetAsync {secretId}"); + return Task.FromResult(_secrets.TryGetValue(secretId, out var stored) + ? Result.Success(Describe(stored)) + : Result.Failure(SecretVaultErrors.SecretNotFound(secretId))); + } + + /// + public Task>> ListAsync( + SecretVaultConnection connection, SecretScopePath pathPrefix, + IReadOnlyDictionary? tagFilter = null, CancellationToken cancellationToken = default) + { + _callLog.Enqueue($"ListAsync {pathPrefix}"); + var matches = _secrets.Values + .Where(s => IsUnderPrefix(s.PathSegments, pathPrefix.Segments)) + .Where(s => tagFilter is null || tagFilter.All(kv => s.Tags.TryGetValue(kv.Key, out var v) && v == kv.Value)) + .OrderBy(s => s.Path, StringComparer.Ordinal) + .ThenBy(s => s.Name, StringComparer.Ordinal) + .Select(Describe) + .ToList(); + return Task.FromResult(Result.Success>(matches)); + } + + /// + public Task DeleteAsync( + SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) + { + _callLog.Enqueue($"DeleteAsync {secretId}"); + _secrets.TryRemove(secretId, out _); + return Task.FromResult(Result.Success()); + } + + /// + public Task> AddAsync( + SecretVaultConnection connection, string secretId, SecretMaterial material, + string? description = null, CancellationToken cancellationToken = default) + { + _callLog.Enqueue($"AddAsync {secretId}"); + if (!_secrets.TryGetValue(secretId, out var stored)) + { + return Task.FromResult(Result.Failure(SecretVaultErrors.SecretNotFound(secretId))); + } + + lock (stored.Lock) + { + var version = new StoredVersion + { + Revision = stored.NextRevision++, + Material = material, + Status = VaultSecretVersionStatus.Enabled, + Description = description, + CreatedAt = DateTimeOffset.UtcNow, + }; + stored.Versions[version.Revision] = version; + return Task.FromResult(Result.Success(DescribeVersion(version))); + } + } + + /// + public Task> AccessAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) + { + _callLog.Enqueue($"AccessAsync {secretId}#{revision}"); + if (!_secrets.TryGetValue(secretId, out var stored)) + { + return Task.FromResult(Result.Failure(SecretVaultErrors.SecretNotFound(secretId))); + } + + lock (stored.Lock) + { + if (!stored.Versions.TryGetValue(revision, out var version) || + version.Status == VaultSecretVersionStatus.Destroyed) + { + return Task.FromResult(Result.Failure( + SecretVaultErrors.VersionNotFound(secretId, revision))); + } + + return Task.FromResult(version.Status == VaultSecretVersionStatus.Disabled + ? Result.Failure(SecretVaultErrors.VersionDisabled(secretId, revision)) + : Result.Success(version.Material)); + } + } + + /// + public Task>> ListAsync( + SecretVaultConnection connection, string secretId, CancellationToken cancellationToken = default) + { + _callLog.Enqueue($"ListVersionsAsync {secretId}"); + if (!_secrets.TryGetValue(secretId, out var stored)) + { + return Task.FromResult(Result.Failure>( + SecretVaultErrors.SecretNotFound(secretId))); + } + + lock (stored.Lock) + { + var versions = stored.Versions.Values + .OrderBy(v => v.Revision) + .Select(DescribeVersion) + .ToList(); + return Task.FromResult(Result.Success>(versions)); + } + } + + /// + public Task EnableAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) => + SetStatusAsync("EnableAsync", secretId, revision, VaultSecretVersionStatus.Enabled); + + /// + public Task DisableAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) => + SetStatusAsync("DisableAsync", secretId, revision, VaultSecretVersionStatus.Disabled); + + /// + public Task DestroyAsync( + SecretVaultConnection connection, string secretId, long revision, CancellationToken cancellationToken = default) => + SetStatusAsync("DestroyAsync", secretId, revision, VaultSecretVersionStatus.Destroyed); + + private static bool IsUnderPrefix(IReadOnlyList path, IReadOnlyList prefix) + { + if (prefix.Count > path.Count) + { + return false; + } + + for (var i = 0; i < prefix.Count; i++) + { + if (!string.Equals(path[i], prefix[i], StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static VaultSecretDescriptor Describe(StoredSecret stored) => new() + { + SecretId = stored.Id, + Name = stored.Name, + Path = SecretScopePath.From([.. stored.PathSegments]).Value, + Description = stored.Description, + Tags = new Dictionary(stored.Tags), + VersionCount = stored.Versions.Count, + CreatedAt = stored.CreatedAt, + }; + + private static VaultSecretVersion DescribeVersion(StoredVersion version) => new() + { + Revision = version.Revision, + Status = version.Status, + Description = version.Description, + CreatedAt = version.CreatedAt, + }; + + private Task SetStatusAsync(string operation, string secretId, long revision, VaultSecretVersionStatus status) + { + _callLog.Enqueue($"{operation} {secretId}#{revision}"); + if (!_secrets.TryGetValue(secretId, out var stored)) + { + return Task.FromResult(Result.Failure(SecretVaultErrors.SecretNotFound(secretId))); + } + + lock (stored.Lock) + { + if (!stored.Versions.TryGetValue(revision, out var version) || + version.Status == VaultSecretVersionStatus.Destroyed) + { + return Task.FromResult(Result.Failure(SecretVaultErrors.VersionNotFound(secretId, revision))); + } + + version.Status = status; + return Task.FromResult(Result.Success()); + } + } + + private sealed class StoredSecret + { + public required string Id { get; init; } + + public required string Name { get; init; } + + public required string Path { get; init; } + + public required List PathSegments { get; init; } + + public string? Description { get; init; } + + public required Dictionary Tags { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public Dictionary Versions { get; } = []; + + public long NextRevision { get; set; } = 1; + + public object Lock { get; } = new(); + } + + private sealed class StoredVersion + { + public required long Revision { get; init; } + + public required SecretMaterial Material { get; init; } + + public required VaultSecretVersionStatus Status { get; set; } + + public string? Description { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + } +} diff --git a/src/Testing/Compendium.Testing/Secrets/SecretVaultContractTests.cs b/src/Testing/Compendium.Testing/Secrets/SecretVaultContractTests.cs new file mode 100644 index 0000000..c9390d7 --- /dev/null +++ b/src/Testing/Compendium.Testing/Secrets/SecretVaultContractTests.cs @@ -0,0 +1,319 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Capabilities; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Abstractions.Secrets.Model; +using Compendium.Core.Results; +using FluentAssertions; +using Xunit; + +namespace Compendium.Testing.Secrets; + +/// +/// The behavioral contract every adapter must +/// satisfy. Inherit in the adapter's test suite and provide the fixture +/// members; tests for capabilities the adapter does not declare are skipped +/// automatically (capability honesty is itself asserted: a declared +/// capability must work). subscribes to +/// this contract, keeping the fake and real adapters aligned. Passing this +/// suite is what makes swapping vault backends safe. +/// +public abstract class SecretVaultContractTests +{ + /// Gets the vault under test. + protected abstract ISecretVault Vault { get; } + + /// Gets a connection with valid credentials for . + protected abstract SecretVaultConnection Connection { get; } + + /// Gets a path under which the connection can create secrets. + protected virtual SecretScopePath TestPath => SecretScopePath.From("contract-tests").Value; + + /// Generates a unique secret name for a test run. + protected virtual string NewSecretName() => $"contract-{Guid.NewGuid():N}"[..24]; + + /// The declared provider must match the facade discriminator and never be empty. + [Fact] + public void Capabilities_DeclaresTheVaultProvider() + { + Vault.Provider.Should().NotBeNullOrWhiteSpace(); + Vault.Capabilities.Provider.Should().Be(Vault.Provider); + } + + /// An undeclared capability must fail with the standard machine code, never throw. + [SkippableFact] + public void EnsureSupported_UndeclaredCapability_FailsWithCapabilityNotSupported() + { + var undeclared = Enum.GetValues() + .FirstOrDefault(c => !Vault.Capabilities.Supports(c), (SecretVaultCapability)(-1)); + Skip.If(undeclared == (SecretVaultCapability)(-1), "Adapter declares every capability."); + + var result = Vault.Capabilities.EnsureSupported(undeclared); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.CapabilityNotSupported"); + } + + /// Creation round-trips through Get by the returned provider-side id. + [Fact] + public async Task Create_RoundTripsThroughGet() + { + var name = NewSecretName(); + var created = await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret + { + Name = name, + Path = TestPath, + Description = "contract round-trip", + }); + + created.IsSuccess.Should().BeTrue(created.IsFailure ? created.Error.Message : string.Empty); + created.Value.SecretId.Should().NotBeNullOrWhiteSpace(); + created.Value.Name.Should().Be(name); + + var fetched = await Vault.Secrets.GetAsync(Connection, created.Value.SecretId); + fetched.IsSuccess.Should().BeTrue(fetched.IsFailure ? fetched.Error.Message : string.Empty); + fetched.Value.Name.Should().Be(name); + fetched.Value.Path.ToString().Should().Be(TestPath.ToString()); + } + + /// Creating the same (name, path) twice conflicts with the standard code. + [Fact] + public async Task Create_DuplicateNameAndPath_Conflicts() + { + var request = new CreateVaultSecret { Name = NewSecretName(), Path = TestPath }; + + (await Vault.Secrets.CreateAsync(Connection, request)).IsSuccess.Should().BeTrue(); + var duplicate = await Vault.Secrets.CreateAsync(Connection, request); + + duplicate.IsFailure.Should().BeTrue(); + duplicate.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.ConflictExists"); + } + + /// Reading an unknown secret fails with the standard not-found code. + [Fact] + public async Task Get_UnknownSecret_FailsWithSecretNotFound() + { + var result = await Vault.Secrets.GetAsync(Connection, Guid.NewGuid().ToString()); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.SecretNotFound"); + } + + /// Revisions start at 1 and increase monotonically. + [Fact] + public async Task Add_RevisionsAreMonotonicFromOne() + { + var secretId = await CreateSecretAsync(); + + var first = await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v1")); + var second = await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v2")); + + first.IsSuccess.Should().BeTrue(first.IsFailure ? first.Error.Message : string.Empty); + second.IsSuccess.Should().BeTrue(second.IsFailure ? second.Error.Message : string.Empty); + first.Value.Revision.Should().Be(1); + second.Value.Revision.Should().Be(2); + } + + /// Writing a new version never alters an older revision's material (immutability). + [SkippableFact] + public async Task Access_OlderRevision_IsImmutableAfterNewerWrites() + { + Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.ImmutableVersions)); + var secretId = await CreateSecretAsync(); + await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("original")); + await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("replacement")); + + var original = await Vault.Versions.AccessAsync(Connection, secretId, 1); + var replacement = await Vault.Versions.AccessAsync(Connection, secretId, 2); + + original.IsSuccess.Should().BeTrue(original.IsFailure ? original.Error.Message : string.Empty); + original.Value.AsString().Should().Be("original"); + replacement.Value.AsString().Should().Be("replacement"); + } + + /// Accessing a never-written revision fails with the standard code. + [Fact] + public async Task Access_UnknownRevision_FailsWithVersionNotFound() + { + var secretId = await CreateSecretAsync(); + + var result = await Vault.Versions.AccessAsync(Connection, secretId, 42); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.VersionNotFound"); + } + + /// Accessing a version of an unknown secret fails with secret-not-found. + [Fact] + public async Task Access_UnknownSecret_FailsWithSecretNotFound() + { + var result = await Vault.Versions.AccessAsync(Connection, Guid.NewGuid().ToString(), 1); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.SecretNotFound"); + } + + /// Version listing reports every written revision with its status and description. + [Fact] + public async Task ListVersions_ReportsAllRevisions() + { + var secretId = await CreateSecretAsync(); + await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v1"), "first"); + await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v2"), "second"); + + var versions = await Vault.Versions.ListAsync(Connection, secretId); + + versions.IsSuccess.Should().BeTrue(versions.IsFailure ? versions.Error.Message : string.Empty); + versions.Value.Should().HaveCount(2); + versions.Value.Select(v => v.Revision).Should().ContainInOrder(1L, 2L); + versions.Value.Should().OnlyContain(v => v.Status == VaultSecretVersionStatus.Enabled); + } + + /// Disable kill-switches a revision (access refused) and Enable restores it. + [SkippableFact] + public async Task DisableEnable_KillSwitchRoundTrip() + { + Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.VersionEnableDisable)); + var secretId = await CreateSecretAsync(); + await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("hot")); + + (await Vault.Versions.DisableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue(); + var whileDisabled = await Vault.Versions.AccessAsync(Connection, secretId, 1); + whileDisabled.IsFailure.Should().BeTrue(); + whileDisabled.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.VersionDisabled"); + + (await Vault.Versions.EnableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue(); + var reEnabled = await Vault.Versions.AccessAsync(Connection, secretId, 1); + reEnabled.IsSuccess.Should().BeTrue(reEnabled.IsFailure ? reEnabled.Error.Message : string.Empty); + reEnabled.Value.AsString().Should().Be("hot"); + } + + /// Disable and Enable are idempotent on a revision already in the target state. + [SkippableFact] + public async Task DisableEnable_AreIdempotent() + { + Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.VersionEnableDisable)); + var secretId = await CreateSecretAsync(); + await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("v1")); + + (await Vault.Versions.DisableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue(); + (await Vault.Versions.DisableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue(); + (await Vault.Versions.EnableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue(); + (await Vault.Versions.EnableAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue(); + } + + /// Destroyed material reads as not found and its revision number is never reused. + [SkippableFact] + public async Task Destroy_MaterialGoneAndRevisionNumberReserved() + { + Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.VersionDestroy)); + var secretId = await CreateSecretAsync(); + await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("doomed")); + + (await Vault.Versions.DestroyAsync(Connection, secretId, 1)).IsSuccess.Should().BeTrue(); + + var access = await Vault.Versions.AccessAsync(Connection, secretId, 1); + access.IsFailure.Should().BeTrue(); + access.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.VersionNotFound"); + + var next = await Vault.Versions.AddAsync(Connection, secretId, SecretMaterial.FromString("survivor")); + next.IsSuccess.Should().BeTrue(next.IsFailure ? next.Error.Message : string.Empty); + next.Value.Revision.Should().Be(2, "destroyed revision numbers must stay reserved"); + } + + /// Deleting a container removes it (and delete is idempotent). + [Fact] + public async Task Delete_RemovesContainer_AndIsIdempotent() + { + var secretId = await CreateSecretAsync(); + + (await Vault.Secrets.DeleteAsync(Connection, secretId)).IsSuccess.Should().BeTrue(); + (await Vault.Secrets.GetAsync(Connection, secretId)).IsFailure.Should().BeTrue(); + (await Vault.Secrets.DeleteAsync(Connection, secretId)).IsSuccess.Should().BeTrue(); + } + + /// Prefix listing finds secrets nested under the prefix, not siblings. + [SkippableFact] + public async Task List_ByPathPrefix_FindsNestedSecretsOnly() + { + Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.PathHierarchy)); + var marker = $"nest{Guid.NewGuid():N}"[..12]; + var nestedPath = SecretVaultContractTestsHelper.Append(TestPath, marker, "inner").Value; + var siblingPath = SecretVaultContractTestsHelper.Append(TestPath, $"other{Guid.NewGuid():N}"[..12]).Value; + var nestedName = NewSecretName(); + await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret { Name = nestedName, Path = nestedPath }); + await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret { Name = NewSecretName(), Path = siblingPath }); + + var listed = await Vault.Secrets.ListAsync( + Connection, SecretVaultContractTestsHelper.Append(TestPath, marker).Value); + + listed.IsSuccess.Should().BeTrue(listed.IsFailure ? listed.Error.Message : string.Empty); + listed.Value.Should().ContainSingle(d => d.Name == nestedName); + } + + /// Tag filters match secrets carrying every requested tag. + [SkippableFact] + public async Task List_ByTagFilter_MatchesAllRequestedTags() + { + Skip.IfNot(Vault.Capabilities.Supports(SecretVaultCapability.Tags)); + var marker = Guid.NewGuid().ToString("N"); + var taggedName = NewSecretName(); + await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret + { + Name = taggedName, + Path = TestPath, + Tags = new Dictionary { ["contract-marker"] = marker, ["managed-by"] = "contract" }, + }); + await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret + { + Name = NewSecretName(), + Path = TestPath, + Tags = new Dictionary { ["managed-by"] = "contract" }, + }); + + var listed = await Vault.Secrets.ListAsync( + Connection, TestPath, new Dictionary { ["contract-marker"] = marker }); + + listed.IsSuccess.Should().BeTrue(listed.IsFailure ? listed.Error.Message : string.Empty); + listed.Value.Should().ContainSingle(d => d.Name == taggedName); + } + + /// Secret material and token credentials never leak through ToString(). + [Fact] + public void Redaction_MaterialAndCredentialsNeverStringify() + { + SecretMaterial.FromString("hunter2").ToString().Should().NotContain("hunter2"); + new SecretVaultCredential.ApiToken("hunter2").ToString().Should().NotContain("hunter2"); + var connection = Connection with { Credential = new SecretVaultCredential.ApiToken("hunter2") }; + connection.ToString().Should().NotContain("hunter2"); + } + + private async Task CreateSecretAsync() + { + var created = await Vault.Secrets.CreateAsync(Connection, new CreateVaultSecret + { + Name = NewSecretName(), + Path = TestPath, + }); + created.IsSuccess.Should().BeTrue(created.IsFailure ? created.Error.Message : string.Empty); + return created.Value.SecretId; + } +} + +/// +/// Path helpers for . +/// +internal static class SecretVaultContractTestsHelper +{ + /// + /// Appends segments to a path. + /// + public static Result Append(SecretScopePath path, params string[] segments) => + SecretScopePath.From([.. path.Segments, .. segments]); +} diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/Compendium.Abstractions.Secrets.Tests.csproj b/tests/Unit/Compendium.Abstractions.Secrets.Tests/Compendium.Abstractions.Secrets.Tests.csproj new file mode 100644 index 0000000..87fa4cf --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/Compendium.Abstractions.Secrets.Tests.csproj @@ -0,0 +1,34 @@ + + + + Compendium.Abstractions.Secrets.Tests + Compendium.Abstractions.Secrets.Tests + false + true + enable + enable + false + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/InMemorySecretVaultContractTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/InMemorySecretVaultContractTests.cs new file mode 100644 index 0000000..f29581f --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/InMemorySecretVaultContractTests.cs @@ -0,0 +1,31 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Testing.Secrets; + +namespace Compendium.Abstractions.Secrets.Tests; + +/// +/// Subscribes to the vault behavioral +/// contract, keeping the fake aligned with real adapters. +/// +public sealed class InMemorySecretVaultContractTests : SecretVaultContractTests +{ + private readonly InMemorySecretVault _vault = new(); + + /// + protected override ISecretVault Vault => _vault; + + /// + protected override SecretVaultConnection Connection { get; } = new() + { + Provider = InMemorySecretVault.ProviderName, + Credential = new SecretVaultCredential.None(), + }; +} diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/NullSecretVaultTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/NullSecretVaultTests.cs new file mode 100644 index 0000000..24b694b --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/NullSecretVaultTests.cs @@ -0,0 +1,70 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Capabilities; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Abstractions.Secrets.Model; +using Compendium.Abstractions.Secrets.Stubs; +using FluentAssertions; +using Xunit; + +namespace Compendium.Abstractions.Secrets.Tests; + +/// +/// The null vault must fail every operation with the standard +/// not-configured error and declare no capabilities. +/// +public sealed class NullSecretVaultTests +{ + private static readonly SecretVaultConnection Connection = new() + { + Provider = NullSecretVault.ProviderName, + Credential = new SecretVaultCredential.None(), + }; + + private readonly NullSecretVault _vault = new(); + + [Fact] + public void DeclaresNoCapabilities() + { + _vault.Provider.Should().Be("null"); + _vault.Capabilities.Entries.Should().BeEmpty(); + Enum.GetValues() + .Should().OnlyContain(c => !_vault.Capabilities.Supports(c)); + } + + [Fact] + public async Task EveryOperation_FailsWithNotConfigured() + { + var expected = $"{SecretVaultErrors.Prefix}.NotConfigured"; + var create = await _vault.Secrets.CreateAsync(Connection, new CreateVaultSecret + { + Name = "any", + Path = SecretScopePath.Root, + }); + var get = await _vault.Secrets.GetAsync(Connection, "id"); + var list = await _vault.Secrets.ListAsync(Connection, SecretScopePath.Root); + var delete = await _vault.Secrets.DeleteAsync(Connection, "id"); + var add = await _vault.Versions.AddAsync(Connection, "id", SecretMaterial.FromString("x")); + var access = await _vault.Versions.AccessAsync(Connection, "id", 1); + var versions = await _vault.Versions.ListAsync(Connection, "id"); + var enable = await _vault.Versions.EnableAsync(Connection, "id", 1); + var disable = await _vault.Versions.DisableAsync(Connection, "id", 1); + var destroy = await _vault.Versions.DestroyAsync(Connection, "id", 1); + + create.Error.Code.Should().Be(expected); + get.Error.Code.Should().Be(expected); + list.Error.Code.Should().Be(expected); + delete.Error.Code.Should().Be(expected); + add.Error.Code.Should().Be(expected); + access.Error.Code.Should().Be(expected); + versions.Error.Code.Should().Be(expected); + enable.Error.Code.Should().Be(expected); + disable.Error.Code.Should().Be(expected); + destroy.Error.Code.Should().Be(expected); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretMaterialTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretMaterialTests.cs new file mode 100644 index 0000000..b6f4783 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretMaterialTests.cs @@ -0,0 +1,39 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Model; +using FluentAssertions; +using Xunit; + +namespace Compendium.Abstractions.Secrets.Tests; + +/// +/// Byte/string round-trips and copy semantics of . +/// +public sealed class SecretMaterialTests +{ + [Fact] + public void FromBytes_CopiesTheSource_SoLaterMutationDoesNotAlterTheMaterial() + { + var source = new byte[] { 1, 2, 3 }; + var material = SecretMaterial.FromBytes(source); + + source[0] = 99; + + material.Data.ToArray().Should().ContainInOrder((byte)1, (byte)2, (byte)3); + material.Length.Should().Be(3); + } + + [Fact] + public void FromString_RoundTripsUtf8() + { + var material = SecretMaterial.FromString("héllo wörld"); + + material.AsString().Should().Be("héllo wörld"); + material.Length.Should().BeGreaterThan(11, "non-ASCII chars take multiple UTF-8 bytes"); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretScopePathTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretScopePathTests.cs new file mode 100644 index 0000000..20069d2 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretScopePathTests.cs @@ -0,0 +1,68 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Model; +using FluentAssertions; +using Xunit; + +namespace Compendium.Abstractions.Secrets.Tests; + +/// +/// Validation and canonical-form tests for . +/// +public sealed class SecretScopePathTests +{ + [Theory] + [InlineData("nexus")] + [InlineData("a1b2-c3_d4.e5")] + [InlineData("0e21a7ab-77b5-4d20-9a4c-000000000001")] + public void From_ValidSegment_Succeeds(string segment) + { + var result = SecretScopePath.From(segment); + + result.IsSuccess.Should().BeTrue(); + result.Value.ToString().Should().Be($"/{segment}"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("a/b")] + [InlineData("a b")] + [InlineData("a\\b")] + public void From_InvalidSegment_FailsWithValidationError(string segment) + { + var result = SecretScopePath.From(segment); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be($"{SecretVaultErrors.Prefix}.InvalidPathSegment"); + } + + [Fact] + public void From_SegmentOver100Characters_Fails() + { + SecretScopePath.From(new string('a', 101)).IsFailure.Should().BeTrue(); + SecretScopePath.From(new string('a', 100)).IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Root_IsSlash_WithNoSegments() + { + SecretScopePath.Root.Segments.Should().BeEmpty(); + SecretScopePath.Root.ToString().Should().Be("/"); + } + + [Fact] + public void From_MultipleSegments_JoinsCanonically() + { + var result = SecretScopePath.From("nexus", "org-1", "app-2"); + + result.IsSuccess.Should().BeTrue(); + result.Value.ToString().Should().Be("/nexus/org-1/app-2"); + result.Value.Segments.Should().ContainInOrder("nexus", "org-1", "app-2"); + } +} diff --git a/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretVaultErrorsTests.cs b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretVaultErrorsTests.cs new file mode 100644 index 0000000..29b2342 --- /dev/null +++ b/tests/Unit/Compendium.Abstractions.Secrets.Tests/SecretVaultErrorsTests.cs @@ -0,0 +1,124 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Capabilities; +using Compendium.Core.Results; +using FluentAssertions; +using Xunit; + +namespace Compendium.Abstractions.Secrets.Tests; + +/// +/// Error-code and metadata guarantees consumers rely on for uniform handling. +/// +public sealed class SecretVaultErrorsTests +{ + [Fact] + public void NotSupported_CarriesProviderAndCapabilityMetadata() + { + var error = SecretVaultErrors.NotSupported("scaleway", SecretVaultCapability.LargePayload, "64 KiB limit"); + + error.Code.Should().Be("SecretVault.CapabilityNotSupported"); + error.Metadata.Should().ContainKey("provider").WhoseValue.Should().Be("scaleway"); + error.Metadata.Should().ContainKey("capability").WhoseValue.Should().Be("LargePayload"); + error.Metadata.Should().ContainKey("limitation"); + } + + [Fact] + public void Throttled_IsTooManyRequests_WithRetryAfterMetadata() + { + var error = SecretVaultErrors.Throttled("scaleway", 30); + + error.Type.Should().Be(ErrorType.TooManyRequests); + error.Metadata.Should().ContainKey("retryAfterSeconds").WhoseValue.Should().Be(30); + } + + [Fact] + public void PayloadTooLarge_CarriesSizes() + { + var error = SecretVaultErrors.PayloadTooLarge(100_000, 65_536); + + error.Type.Should().Be(ErrorType.Validation); + error.Metadata.Should().ContainKey("size").WhoseValue.Should().Be(100_000); + error.Metadata.Should().ContainKey("maxSize").WhoseValue.Should().Be(65_536); + } + + [Fact] + public void NotSupported_WithoutLimitation_OmitsTheMetadataKey() + { + var error = SecretVaultErrors.NotSupported("scaleway", SecretVaultCapability.ServerSideRotation); + + error.Code.Should().Be("SecretVault.CapabilityNotSupported"); + error.Metadata.Should().NotContainKey("limitation"); + } + + [Fact] + public void QuotaExceeded_IsUnavailable_WithDetailInMessage() + { + var error = SecretVaultErrors.QuotaExceeded("scaleway", "1000 secrets per project"); + + error.Type.Should().Be(ErrorType.Unavailable); + error.Message.Should().Contain("1000 secrets per project"); + } + + [Fact] + public void ProviderRejected_BothDetailBranches_CarryStatusCode() + { + var bare = SecretVaultErrors.ProviderRejected("scaleway", 500); + var detailed = SecretVaultErrors.ProviderRejected("scaleway", 502, "bad gateway"); + + bare.Metadata.Should().ContainKey("statusCode").WhoseValue.Should().Be(500); + detailed.Message.Should().Contain("bad gateway"); + } + + [Fact] + public void AuthenticationFailed_And_NotConfigured_BothBranches() + { + SecretVaultErrors.AuthenticationFailed("scaleway").Type.Should().Be(ErrorType.Unauthorized); + SecretVaultErrors.AuthenticationFailed("scaleway", "expired").Message.Should().Contain("expired"); + SecretVaultErrors.NotConfigured().Message.Should().Contain("No secret vault"); + SecretVaultErrors.NotConfigured("null").Message.Should().Contain("'null'"); + } + + [Fact] + public void Throttled_WithoutRetryAfter_OmitsTheMetadataKey() + { + var error = SecretVaultErrors.Throttled("scaleway"); + + error.Type.Should().Be(ErrorType.TooManyRequests); + error.Metadata.Should().NotContainKey("retryAfterSeconds"); + } + + [Fact] + public void NotFoundAndConflictFamilies_UseStableCodes() + { + SecretVaultErrors.SecretNotFound("s1").Code.Should().Be("SecretVault.SecretNotFound"); + SecretVaultErrors.VersionNotFound("s1", 3).Code.Should().Be("SecretVault.VersionNotFound"); + SecretVaultErrors.VersionDisabled("s1", 3).Code.Should().Be("SecretVault.VersionDisabled"); + SecretVaultErrors.ConflictExists("name", "/p").Code.Should().Be("SecretVault.ConflictExists"); + } + + [Fact] + public void EnsureSupported_DeclaredCapability_Succeeds() + { + var capabilities = new SecretVaultCapabilities + { + Provider = "test", + Entries = new Dictionary + { + [SecretVaultCapability.Tags] = new(SecretVaultCapabilityLevel.Partial, "limited filters"), + }, + }; + + capabilities.EnsureSupported(SecretVaultCapability.Tags).IsSuccess.Should().BeTrue(); + capabilities.Supports(SecretVaultCapability.PathHierarchy).Should().BeFalse(); + + var missing = capabilities.EnsureSupported(SecretVaultCapability.PathHierarchy); + missing.IsFailure.Should().BeTrue(); + missing.Error.Code.Should().Be("SecretVault.CapabilityNotSupported"); + } +} diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Compendium.Adapters.Scaleway.SecretManager.Tests.csproj b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Compendium.Adapters.Scaleway.SecretManager.Tests.csproj new file mode 100644 index 0000000..f4c783c --- /dev/null +++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Compendium.Adapters.Scaleway.SecretManager.Tests.csproj @@ -0,0 +1,35 @@ + + + + Compendium.Adapters.Scaleway.SecretManager.Tests + Compendium.Adapters.Scaleway.SecretManager.Tests + false + true + enable + enable + false + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Infrastructure/ScalewayTestHarness.cs b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Infrastructure/ScalewayTestHarness.cs new file mode 100644 index 0000000..31bb40f --- /dev/null +++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/Infrastructure/ScalewayTestHarness.cs @@ -0,0 +1,79 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Adapters.Scaleway.SecretManager.Configuration; +using Compendium.Adapters.Scaleway.SecretManager.Http; +using Compendium.Adapters.Scaleway.SecretManager.Services; +using Microsoft.Extensions.Options; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Compendium.Adapters.Scaleway.SecretManager.Tests.Infrastructure; + +/// +/// A WireMock-backed harness wiring the Scaleway adapter against a local mock +/// Secret Manager API. Disposable — stops the WireMock server. +/// +internal sealed class ScalewayTestHarness : IDisposable +{ + public const string ProjectId = "11111111-2222-3333-4444-555555555555"; + + public ScalewayTestHarness(string? defaultProjectId = ProjectId) + { + Server = WireMockServer.Start(); + var options = new ScalewaySecretManagerOptions + { + ApiBaseUrl = new Uri(Server.Url!), + DefaultRegion = "fr-par", + DefaultProjectId = defaultProjectId, + }; + Client = new ScalewayApiClient(new TestHttpClientFactory(), Options.Create(options)); + Containers = new ScalewaySecretContainerService(Client); + Versions = new ScalewaySecretVersionService(Client); + Vault = new ScalewaySecretVault(Containers, Versions); + } + + public WireMockServer Server { get; } + + public ScalewayApiClient Client { get; } + + public ScalewaySecretContainerService Containers { get; } + + public ScalewaySecretVersionService Versions { get; } + + public ScalewaySecretVault Vault { get; } + + /// The regional path prefix every endpoint lives under. + public static string Api(string suffix) => $"/secret-manager/v1beta1/regions/fr-par/{suffix}"; + + /// A connection carrying a valid API token. + public SecretVaultConnection Connection(string token = "scw_secret_key") => new() + { + Provider = "scaleway", + Credential = new SecretVaultCredential.ApiToken(token), + }; + + public void Dispose() => Server.Stop(); + + private sealed class TestHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } +} + +/// +/// WireMock JSON response builders that set Content-Type: application/json. +/// +internal static class Json +{ + public static IResponseBuilder Ok(object body) => Status(200, body); + + public static IResponseBuilder Status(int statusCode, object body) => + Response.Create().WithStatusCode(statusCode) + .WithHeader("Content-Type", "application/json").WithBodyAsJson(body); +} diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayAdapterWiringTests.cs b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayAdapterWiringTests.cs new file mode 100644 index 0000000..3601d7c --- /dev/null +++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayAdapterWiringTests.cs @@ -0,0 +1,60 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Capabilities; +using Compendium.Adapters.Scaleway.SecretManager.DependencyInjection; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Compendium.Adapters.Scaleway.SecretManager.Tests; + +/// +/// DI registration and declared capability matrix of the Scaleway adapter. +/// +public sealed class ScalewayAdapterWiringTests +{ + [Fact] + public void AddScalewaySecretVault_ContributesTheFacade_AndIsIdempotent() + { + var services = new ServiceCollection(); + services.AddScalewaySecretVault(o => o.DefaultRegion = "nl-ams"); + services.AddScalewaySecretVault(); + + using var provider = services.BuildServiceProvider(); + var vaults = provider.GetServices().ToList(); + + vaults.Should().ContainSingle(v => v.Provider == "scaleway"); + provider.GetRequiredService().Should().BeSameAs(vaults.Single()); + } + + [Fact] + public void Capabilities_DeclareTheDocumentedMatrix() + { + var services = new ServiceCollection(); + services.AddScalewaySecretVault(); + using var provider = services.BuildServiceProvider(); + var vault = provider.GetRequiredService(); + + vault.Capabilities.Provider.Should().Be("scaleway"); + vault.Capabilities.Supports(SecretVaultCapability.ImmutableVersions).Should().BeTrue(); + vault.Capabilities.Supports(SecretVaultCapability.VersionEnableDisable).Should().BeTrue(); + vault.Capabilities.Supports(SecretVaultCapability.VersionDestroy).Should().BeTrue(); + vault.Capabilities.Supports(SecretVaultCapability.PathHierarchy).Should().BeTrue(); + vault.Capabilities.Supports(SecretVaultCapability.Tags).Should().BeTrue(); + vault.Capabilities.Supports(SecretVaultCapability.LargePayload).Should().BeFalse(); + vault.Capabilities.Supports(SecretVaultCapability.ServerSideRotation).Should().BeFalse(); + + var refused = vault.Capabilities.EnsureSupported(SecretVaultCapability.LargePayload); + refused.IsFailure.Should().BeTrue(); + refused.Error.Metadata.Should().ContainKey("limitation"); + + vault.Secrets.Should().NotBeNull(); + vault.Versions.Should().NotBeNull(); + } +} diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayMappingTests.cs b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayMappingTests.cs new file mode 100644 index 0000000..f76548f --- /dev/null +++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewayMappingTests.cs @@ -0,0 +1,112 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets.Model; +using Compendium.Adapters.Scaleway.SecretManager.Http; +using FluentAssertions; +using Xunit; + +namespace Compendium.Adapters.Scaleway.SecretManager.Tests; + +/// +/// Pure-mapping guarantees: tag encoding round-trips, defensive path/status +/// parsing, descriptor/version projection. +/// +public sealed class ScalewayMappingTests +{ + [Fact] + public void Tags_RoundTrip_IncludingEmptyValueAndColonlessForms() + { + var encoded = ScalewayMapping.ToTagList(new Dictionary + { + ["org"] = "acme", + ["flag"] = string.Empty, + }); + + encoded.Should().Contain("org:acme"); + encoded.Should().Contain("flag"); + + var decoded = ScalewayMapping.FromTagList(["org:acme", "bare-tag", "kv:with:colons"]); + decoded["org"].Should().Be("acme"); + decoded["bare-tag"].Should().Be(string.Empty); + decoded["kv"].Should().Be("with:colons"); + } + + [Fact] + public void ToTagList_EmptyOrNull_ReturnsNull() + { + ScalewayMapping.ToTagList(null).Should().BeNull(); + ScalewayMapping.ToTagList(new Dictionary()).Should().BeNull(); + } + + [Theory] + [InlineData(null, "/")] + [InlineData("/", "/")] + [InlineData("/a/b", "/a/b")] + [InlineData("a//b/", "/a/b")] + public void ParsePath_Tolerant_ProducesCanonicalForm(string? input, string expected) + { + ScalewayMapping.ParsePath(input).ToString().Should().Be(expected); + } + + [Fact] + public void ParsePath_InvalidSegments_FallsBackToRoot() + { + ScalewayMapping.ParsePath("/bad segment/x").ToString().Should().Be("/"); + } + + [Theory] + [InlineData("enabled", VaultSecretVersionStatus.Enabled)] + [InlineData("ENABLED", VaultSecretVersionStatus.Enabled)] + [InlineData("deleted", VaultSecretVersionStatus.Destroyed)] + [InlineData("destroyed", VaultSecretVersionStatus.Destroyed)] + [InlineData("disabled", VaultSecretVersionStatus.Disabled)] + [InlineData("something_new", VaultSecretVersionStatus.Disabled)] + [InlineData(null, VaultSecretVersionStatus.Disabled)] + public void ParseStatus_MapsKnownStatuses_AndFailsClosedOnUnknown(string? status, VaultSecretVersionStatus expected) + { + ScalewayMapping.ParseStatus(status).Should().Be(expected); + } + + [Fact] + public void ToDescriptor_ProjectsAllFields() + { + var descriptor = ScalewayMapping.ToDescriptor(new ScalewaySecret + { + Id = "sec-1", + ProjectId = "proj-1", + Name = "db", + Path = "/nexus/org", + Description = "database password", + Tags = ["org:acme"], + VersionCount = 4, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + + descriptor.SecretId.Should().Be("sec-1"); + descriptor.Description.Should().Be("database password"); + descriptor.VersionCount.Should().Be(4); + descriptor.CreatedAt.Should().Be(DateTimeOffset.UnixEpoch); + } + + [Fact] + public void ToVersion_ProjectsAllFields_AndEmptyDescriptionReadsAsNull() + { + var version = ScalewayMapping.ToVersion(new ScalewaySecretVersion + { + Revision = 7, + SecretId = "sec-1", + Status = "enabled", + Description = string.Empty, + CreatedAt = DateTimeOffset.UnixEpoch, + }); + + version.Revision.Should().Be(7); + version.Description.Should().BeNull(); + version.Status.Should().Be(VaultSecretVersionStatus.Enabled); + } +} diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewaySecretContainerServiceTests.cs b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewaySecretContainerServiceTests.cs new file mode 100644 index 0000000..478f93d --- /dev/null +++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewaySecretContainerServiceTests.cs @@ -0,0 +1,244 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Abstractions.Secrets.Model; +using Compendium.Adapters.Scaleway.SecretManager.Tests.Infrastructure; +using FluentAssertions; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using Xunit; + +namespace Compendium.Adapters.Scaleway.SecretManager.Tests; + +public sealed class ScalewaySecretContainerServiceTests +{ + private static readonly SecretScopePath Path = SecretScopePath.From("nexus", "org-1").Value; + + [Fact] + public async Task Create_SendsAuthAndProject_AndMapsDescriptor() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets")).UsingPost() + .WithHeader("X-Auth-Token", "scw_secret_key") + .WithBody(b => b!.Contains(ScalewayTestHarness.ProjectId) && b.Contains("\"/nexus/org-1\""))) + .RespondWith(Json.Ok(new + { + id = "sec-1", + name = "db-password", + path = "/nexus/org-1", + tags = new[] { "org:acme", "managed-by:nexus" }, + version_count = 0, + })); + + var result = await harness.Containers.CreateAsync(harness.Connection(), new CreateVaultSecret + { + Name = "db-password", + Path = Path, + Tags = new Dictionary { ["org"] = "acme", ["managed-by"] = "nexus" }, + }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.SecretId.Should().Be("sec-1"); + result.Value.Path.ToString().Should().Be("/nexus/org-1"); + result.Value.Tags.Should().Contain("org", "acme"); + } + + [Fact] + public async Task Create_Conflict_MapsToConflictExists() + { + using var harness = new ScalewayTestHarness(); + harness.Server.Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets")).UsingPost()) + .RespondWith(Json.Status(409, new { message = "secret already exists" })); + + var result = await harness.Containers.CreateAsync(harness.Connection(), new CreateVaultSecret + { + Name = "dup", + Path = Path, + }); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.ConflictExists"); + } + + [Fact] + public async Task Get_NotFound_MapsToSecretNotFound() + { + using var harness = new ScalewayTestHarness(); + harness.Server.Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/ghost")).UsingGet()) + .RespondWith(Json.Status(404, new { message = "not found" })); + + var result = await harness.Containers.GetAsync(harness.Connection(), "ghost"); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.SecretNotFound"); + } + + [Fact] + public async Task AnyCall_Unauthorized_MapsToAuthenticationFailed() + { + using var harness = new ScalewayTestHarness(); + harness.Server.Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1")).UsingGet()) + .RespondWith(Json.Status(403, new { message = "invalid token" })); + + var result = await harness.Containers.GetAsync(harness.Connection("bad"), "sec-1"); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.AuthenticationFailed"); + } + + [Fact] + public async Task AnyCall_Throttled_CarriesRetryAfter() + { + using var harness = new ScalewayTestHarness(); + harness.Server.Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1")).UsingGet()) + .RespondWith(Response.Create().WithStatusCode(429) + .WithHeader("Retry-After", "30") + .WithHeader("Content-Type", "application/json") + .WithBodyAsJson(new { message = "too many requests" })); + + var result = await harness.Containers.GetAsync(harness.Connection(), "sec-1"); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.Throttled"); + result.Error.Metadata.Should().ContainKey("retryAfterSeconds").WhoseValue.Should().Be(30); + } + + [Fact] + public async Task MissingCredential_FailsWithNotConfigured_WithoutNetworkCall() + { + using var harness = new ScalewayTestHarness(); + var connection = harness.Connection() with { Credential = new SecretVaultCredential.None() }; + + var result = await harness.Containers.GetAsync(connection, "sec-1"); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.NotConfigured"); + harness.Server.LogEntries.Should().BeEmpty(); + } + + [Fact] + public async Task List_PaginatesAndFiltersByPrefixAndTags() + { + using var harness = new ScalewayTestHarness(); + var pageOne = Enumerable.Range(1, 100).Select(i => new + { + id = $"sec-{i}", + name = $"key-{i}", + path = "/nexus/other", + tags = Array.Empty(), + }).Cast().ToArray(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets")).UsingGet() + .WithParam("page", "1")) + .RespondWith(Json.Ok(new { secrets = pageOne, total_count = 102 })); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets")).UsingGet() + .WithParam("page", "2")) + .RespondWith(Json.Ok(new + { + secrets = new object[] + { + new { id = "sec-a", name = "wanted", path = "/nexus/org-1/app", tags = new[] { "env:prod" } }, + new { id = "sec-b", name = "untagged", path = "/nexus/org-1", tags = Array.Empty() }, + }, + total_count = 102, + })); + + var result = await harness.Containers.ListAsync( + harness.Connection(), + SecretScopePath.From("nexus", "org-1").Value, + new Dictionary { ["env"] = "prod" }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle(d => d.SecretId == "sec-a"); + } + + [Fact] + public async Task Create_WithoutProjectAnywhere_FailsWithNotConfigured() + { + using var harness = new ScalewayTestHarness(defaultProjectId: null); + + var result = await harness.Containers.CreateAsync(harness.Connection(), new CreateVaultSecret + { + Name = "orphan", + Path = Path, + }); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.NotConfigured"); + harness.Server.LogEntries.Should().BeEmpty(); + } + + [Fact] + public async Task Create_ConnectionTenancy_OverridesTheDefaultProject() + { + using var harness = new ScalewayTestHarness(defaultProjectId: null); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets")).UsingPost() + .WithBody(b => b!.Contains("tenancy-project"))) + .RespondWith(Json.Ok(new { id = "sec-t", name = "k", path = "/" })); + + var connection = harness.Connection() with { Tenancy = "tenancy-project" }; + var result = await harness.Containers.CreateAsync(connection, new CreateVaultSecret + { + Name = "k", + Path = SecretScopePath.Root, + }); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + } + + [Fact] + public async Task List_WithoutTagFilter_ReturnsEverythingUnderThePrefix() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets")).UsingGet()) + .RespondWith(Json.Ok(new + { + secrets = new object[] + { + new { id = "a", name = "one", path = "/nexus", tags = Array.Empty() }, + new { id = "b", name = "two", path = "/other", tags = Array.Empty() }, + }, + total_count = 2, + })); + + var result = await harness.Containers.ListAsync( + harness.Connection(), SecretScopePath.From("nexus").Value); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Should().ContainSingle(d => d.SecretId == "a"); + } + + [Fact] + public async Task List_ProviderFailure_PropagatesTheError() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets")).UsingGet()) + .RespondWith(Json.Status(500, new { message = "boom" })); + + var result = await harness.Containers.ListAsync(harness.Connection(), SecretScopePath.Root); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.ProviderRejected"); + } + + [Fact] + public async Task Delete_NotFound_IsIdempotentSuccess() + { + using var harness = new ScalewayTestHarness(); + harness.Server.Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/gone")).UsingDelete()) + .RespondWith(Json.Status(404, new { message = "not found" })); + + (await harness.Containers.DeleteAsync(harness.Connection(), "gone")).IsSuccess.Should().BeTrue(); + } +} diff --git a/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewaySecretVersionServiceTests.cs b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewaySecretVersionServiceTests.cs new file mode 100644 index 0000000..76a8c75 --- /dev/null +++ b/tests/Unit/Compendium.Adapters.Scaleway.SecretManager.Tests/ScalewaySecretVersionServiceTests.cs @@ -0,0 +1,267 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text; +using Compendium.Abstractions.Secrets.Model; +using Compendium.Adapters.Scaleway.SecretManager.Tests.Infrastructure; +using FluentAssertions; +using WireMock.RequestBuilders; +using Xunit; + +namespace Compendium.Adapters.Scaleway.SecretManager.Tests; + +public sealed class ScalewaySecretVersionServiceTests +{ + [Fact] + public async Task Add_SendsBase64Payload_AndMapsRevision() + { + using var harness = new ScalewayTestHarness(); + var expectedBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("hunter2")); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions")).UsingPost() + .WithBody(b => b!.Contains(expectedBase64))) + .RespondWith(Json.Ok(new { revision = 3, status = "enabled" })); + + var result = await harness.Versions.AddAsync( + harness.Connection(), "sec-1", SecretMaterial.FromString("hunter2"), "rotation"); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Revision.Should().Be(3); + result.Value.Status.Should().Be(VaultSecretVersionStatus.Enabled); + } + + [Fact] + public async Task Add_PayloadOver64KiB_FailsFastWithoutNetworkCall() + { + using var harness = new ScalewayTestHarness(); + + var result = await harness.Versions.AddAsync( + harness.Connection(), "sec-1", SecretMaterial.FromBytes(new byte[64 * 1024 + 1])); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.PayloadTooLarge"); + harness.Server.LogEntries.Should().BeEmpty(); + } + + [Fact] + public async Task Access_DecodesBase64Material() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/2/access")).UsingGet()) + .RespondWith(Json.Ok(new + { + revision = 2, + data = Convert.ToBase64String(Encoding.UTF8.GetBytes("plaintext")), + })); + + var result = await harness.Versions.AccessAsync(harness.Connection(), "sec-1", 2); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.AsString().Should().Be("plaintext"); + } + + [Fact] + public async Task Access_DisabledRevision_DisambiguatesToVersionDisabled() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/2/access")).UsingGet()) + .RespondWith(Json.Status(404, new { message = "version not enabled" })); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/2")).UsingGet()) + .RespondWith(Json.Ok(new { revision = 2, status = "disabled" })); + + var result = await harness.Versions.AccessAsync(harness.Connection(), "sec-1", 2); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.VersionDisabled"); + } + + [Fact] + public async Task Access_MissingSecret_DisambiguatesToSecretNotFound() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/ghost/versions/1/access")).UsingGet()) + .RespondWith(Json.Status(404, new { message = "not found" })); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/ghost/versions/1")).UsingGet()) + .RespondWith(Json.Status(404, new { message = "not found" })); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/ghost")).UsingGet()) + .RespondWith(Json.Status(404, new { message = "not found" })); + + var result = await harness.Versions.AccessAsync(harness.Connection(), "ghost", 1); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.SecretNotFound"); + } + + [Fact] + public async Task Access_MissingRevisionOnLiveSecret_IsVersionNotFound() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/9/access")).UsingGet()) + .RespondWith(Json.Status(404, new { message = "not found" })); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/9")).UsingGet()) + .RespondWith(Json.Status(404, new { message = "not found" })); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1")).UsingGet()) + .RespondWith(Json.Ok(new { id = "sec-1", name = "db", path = "/" })); + + var result = await harness.Versions.AccessAsync(harness.Connection(), "sec-1", 9); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.VersionNotFound"); + } + + [Fact] + public async Task Disable_NoOpTransition_IsIdempotentSuccess() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/1/disable")).UsingPost()) + .RespondWith(Json.Status(409, new { message = "version is already disabled" })); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/1")).UsingGet()) + .RespondWith(Json.Ok(new { revision = 1, status = "disabled" })); + + (await harness.Versions.DisableAsync(harness.Connection(), "sec-1", 1)).IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Destroy_HappyPath_And_NotFound() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/1")).UsingDelete()) + .RespondWith(Json.Ok(new { })); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/9")).UsingDelete()) + .RespondWith(Json.Status(404, new { message = "not found" })); + + (await harness.Versions.DestroyAsync(harness.Connection(), "sec-1", 1)).IsSuccess.Should().BeTrue(); + var missing = await harness.Versions.DestroyAsync(harness.Connection(), "sec-1", 9); + missing.IsFailure.Should().BeTrue(); + missing.Error.Code.Should().Be("SecretVault.VersionNotFound"); + } + + [Fact] + public async Task Access_InvalidBase64Payload_MapsToProviderRejected() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/1/access")).UsingGet()) + .RespondWith(Json.Ok(new { revision = 1, data = "not-base64!!!" })); + + var result = await harness.Versions.AccessAsync(harness.Connection(), "sec-1", 1); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.ProviderRejected"); + } + + [Fact] + public async Task ListVersions_MissingSecret_MapsToSecretNotFound() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/ghost/versions")).UsingGet()) + .RespondWith(Json.Status(404, new { message = "not found" })); + + var result = await harness.Versions.ListAsync(harness.Connection(), "ghost"); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.SecretNotFound"); + } + + [Fact] + public async Task Enable_FailureWithMismatchedState_ReturnsTheOriginalError() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/1/enable")).UsingPost()) + .RespondWith(Json.Status(500, new { message = "boom" })); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions/1")).UsingGet()) + .RespondWith(Json.Ok(new { revision = 1, status = "disabled" })); + + var result = await harness.Versions.EnableAsync(harness.Connection(), "sec-1", 1); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.ProviderRejected"); + } + + [Fact] + public async Task QuotaError_MapsToQuotaExceeded() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions")).UsingPost()) + .RespondWith(Json.Status(400, new { message = "quota of versions per secret exceeded" })); + + var result = await harness.Versions.AddAsync( + harness.Connection(), "sec-1", SecretMaterial.FromString("x")); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.QuotaExceeded"); + } + + [Fact] + public async Task NonJsonErrorBody_StillMapsTheStatusCode() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions")).UsingPost()) + .RespondWith(WireMock.ResponseBuilders.Response.Create() + .WithStatusCode(502).WithHeader("Content-Type", "text/html").WithBody("bad gateway")); + + var result = await harness.Versions.AddAsync( + harness.Connection(), "sec-1", SecretMaterial.FromString("x")); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.ProviderRejected"); + } + + [Fact] + public async Task NetworkFailure_MapsToProviderRejected_NotAnException() + { + using var harness = new ScalewayTestHarness(); + harness.Server.Stop(); + + var result = await harness.Versions.AccessAsync(harness.Connection(), "sec-1", 1); + + result.IsFailure.Should().BeTrue(); + result.Error.Code.Should().Be("SecretVault.ProviderRejected"); + } + + [Fact] + public async Task ListVersions_PaginatesAndOrdersByRevision() + { + using var harness = new ScalewayTestHarness(); + harness.Server + .Given(Request.Create().WithPath(ScalewayTestHarness.Api("secrets/sec-1/versions")).UsingGet() + .WithParam("page", "1")) + .RespondWith(Json.Ok(new + { + versions = new object[] + { + new { revision = 2, status = "enabled" }, + new { revision = 1, status = "deleted" }, + }, + total_count = 2, + })); + + var result = await harness.Versions.ListAsync(harness.Connection(), "sec-1"); + + result.IsSuccess.Should().BeTrue(result.IsFailure ? result.Error.Message : string.Empty); + result.Value.Select(v => v.Revision).Should().ContainInOrder(1L, 2L); + result.Value[0].Status.Should().Be(VaultSecretVersionStatus.Destroyed); + } +} diff --git a/tests/Unit/Compendium.Testing.Tests/Compendium.Testing.Tests.csproj b/tests/Unit/Compendium.Testing.Tests/Compendium.Testing.Tests.csproj index d2d5f2b..e7a74de 100644 --- a/tests/Unit/Compendium.Testing.Tests/Compendium.Testing.Tests.csproj +++ b/tests/Unit/Compendium.Testing.Tests/Compendium.Testing.Tests.csproj @@ -22,6 +22,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/Unit/Compendium.Testing.Tests/Contracts/InMemoryGitServerContractTests.cs b/tests/Unit/Compendium.Testing.Tests/Contracts/InMemoryGitServerContractTests.cs new file mode 100644 index 0000000..af232ac --- /dev/null +++ b/tests/Unit/Compendium.Testing.Tests/Contracts/InMemoryGitServerContractTests.cs @@ -0,0 +1,68 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using System.Text.Json; +using Compendium.Abstractions.Git; +using Compendium.Abstractions.Git.Connections; +using Compendium.Abstractions.Git.Repositories; +using Compendium.Abstractions.Git.Webhooks; +using Compendium.Testing.Git; + +namespace Compendium.Testing.Tests.Contracts; + +/// +/// Runs the git-server contract against from +/// inside the Testing test project. This intentionally duplicates the +/// subscription living in Compendium.Abstractions.Git.Tests: the CI coverage +/// gate max-merges ONE report per assembly, so this project must exercise the +/// full Compendium.Testing surface (both in-memory fakes and both contract +/// kits) for the assembly's coverage to be measured honestly. +/// +public sealed class InMemoryGitServerContractTests : GitServerContractTests +{ + private readonly InMemoryGitServer _server; + + public InMemoryGitServerContractTests() + { + _server = new InMemoryGitServer(); + _server.SeedInstallation(new GitInstallationInfo( + InstallationId: "inst-1", + AccountLogin: "acme", + AccountType: GitAccountType.Organization)); + _server.SeedRepository(new GitRepositoryRef("platform", "template-dotnet")); + } + + protected override IGitServer Server => _server; + + protected override GitConnection Connection => new() + { + Provider = InMemoryGitServer.ProviderName, + Credential = new GitCredential.AppInstallation("inst-1"), + }; + + protected override string Namespace => "acme"; + + protected override GitRepositoryRef TemplateRepository => new("platform", "template-dotnet"); + + protected override string WebhookSecret => InMemoryGitServer.WellKnownWebhookSecret; + + protected override GitWebhookDelivery CreateDelivery(bool validSignature) => new() + { + Body = JsonSerializer.Serialize(new + { + type = "push", + deliveryId = Guid.NewGuid().ToString(), + repository = "acme/some-repo", + reference = "refs/heads/main", + headCommitSha = "abc123", + }), + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X-InMemory-Signature"] = validSignature ? WebhookSecret : "not-the-secret", + }, + }; +} diff --git a/tests/Unit/Compendium.Testing.Tests/Contracts/InMemorySecretVaultContractTests.cs b/tests/Unit/Compendium.Testing.Tests/Contracts/InMemorySecretVaultContractTests.cs new file mode 100644 index 0000000..a87d7e7 --- /dev/null +++ b/tests/Unit/Compendium.Testing.Tests/Contracts/InMemorySecretVaultContractTests.cs @@ -0,0 +1,33 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) 2026 Sassy Solutions. Licensed under the MIT License. +// See LICENSE in the project root for license information. +// +// ----------------------------------------------------------------------- + +using Compendium.Abstractions.Secrets; +using Compendium.Abstractions.Secrets.Connections; +using Compendium.Testing.Secrets; + +namespace Compendium.Testing.Tests.Contracts; + +/// +/// Runs the secret-vault contract against +/// from inside the Testing test project (see the sibling git contract file +/// for why the duplication with Compendium.Abstractions.Secrets.Tests is +/// deliberate: one max-merged coverage report per assembly). +/// +public sealed class InMemorySecretVaultContractTests : SecretVaultContractTests +{ + private readonly InMemorySecretVault _vault = new(); + + /// + protected override ISecretVault Vault => _vault; + + /// + protected override SecretVaultConnection Connection { get; } = new() + { + Provider = InMemorySecretVault.ProviderName, + Credential = new SecretVaultCredential.None(), + }; +}