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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/keyfactor-starter-workflow.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Keyfactor Bootstrap Workflow

on:
workflow_dispatch:
pull_request:
types: [opened, closed, synchronize, edited, reopened]
push:
create:
branches:
- 'release-*.*'

jobs:
call-starter-workflow:
uses: keyfactor/actions/.github/workflows/starter.yml@v5
with:
command_token_url: ${{ vars.COMMAND_TOKEN_URL }}
command_hostname: ${{ vars.COMMAND_HOSTNAME }}
command_base_api_path: ${{ vars.COMMAND_API_PATH }}
secrets:
token: ${{ secrets.V2BUILDTOKEN}}
gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }}
gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }}
scan_token: ${{ secrets.SAST_TOKEN }}
entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }}
entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }}
command_client_id: ${{ secrets.COMMAND_CLIENT_ID }}
command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,7 @@ FodyWeavers.xsd
*.msix
*.msm
*.msp

# Claude Code / agent state, and local vendor secrets
.claude/
.secrets/
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
v1.0.0
- Inital Version
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Xunit;

namespace Keyfactor.Extensions.DomainValidator.DigitalOcean.Tests
{
public class DigitalOceanDomainValidatorTests
{
[Fact]
public void GetValidationType_ReturnsDns01()
{
var validator = new DigitalOceanDomainValidator();

Assert.Equal("dns-01", validator.GetValidationType());
}

[Fact]
public void GetDomainValidatorAnnotations_DeclaresApiToken()
{
var validator = new DigitalOceanDomainValidator();

var annotations = validator.GetDomainValidatorAnnotations();

Assert.True(annotations.ContainsKey("DigitalOcean_ApiToken"));
Assert.Equal("Secret", annotations["DigitalOcean_ApiToken"].Type);
Assert.True(annotations["DigitalOcean_ApiToken"].Hidden);
}

[Fact]
public async Task ValidateConfiguration_ThrowsWhenApiTokenMissing()
{
var validator = new DigitalOceanDomainValidator();
var config = new Dictionary<string, object>();

await Assert.ThrowsAsync<ArgumentException>(() => validator.ValidateConfiguration(config));
}

[Fact]
public async Task ValidateConfiguration_SucceedsWhenApiTokenPresent()
{
var validator = new DigitalOceanDomainValidator();
var config = new Dictionary<string, object> { ["DigitalOcean_ApiToken"] = "token" };

await validator.ValidateConfiguration(config);
}
}
}
259 changes: 259 additions & 0 deletions Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
using System.Net;
using Xunit;

namespace Keyfactor.Extensions.DomainValidator.DigitalOcean.Tests
{
public class DigitalOceanProviderTests
{
[Fact]
public void FindBestMatch_PicksLongestMatchingSuffix()
{
var zones = new[] { "com", "example.com", "other.com" };

var match = DigitalOceanProvider.FindBestMatch(zones, "_acme-challenge.www.example.com");

Assert.Equal("example.com", match);
}

[Fact]
public void FindBestMatch_MatchesExactZoneName()
{
var zones = new[] { "example.com" };

var match = DigitalOceanProvider.FindBestMatch(zones, "example.com");

Assert.Equal("example.com", match);
}

[Fact]
public void FindBestMatch_ReturnsNullWhenNoZoneMatches()
{
var zones = new[] { "example.com" };

var match = DigitalOceanProvider.FindBestMatch(zones, "unrelated-domain.net");

Assert.Null(match);
}

[Fact]
public void FindBestMatch_DoesNotMatchUnrelatedSuffixSubstring()
{
// "notexample.com" must not match zone "example.com" just because it ends with the same characters.
var zones = new[] { "example.com" };

var match = DigitalOceanProvider.FindBestMatch(zones, "notexample.com");

Assert.Null(match);
}

[Fact]
public void RelativeRecordName_StripsZoneSuffix()
{
var relative = DigitalOceanProvider.RelativeRecordName("example.com", "_acme-challenge.example.com");

Assert.Equal("_acme-challenge", relative);
}

[Fact]
public void RelativeRecordName_ReturnsApexMarkerForZoneRoot()
{
var relative = DigitalOceanProvider.RelativeRecordName("example.com", "example.com");

Assert.Equal("@", relative);
}

[Fact]
public async Task CreateRecordAsync_PostsTxtRecordToResolvedZone()
{
HttpRequestMessage postRequest = null;

var handler = new FakeHttpMessageHandler(req =>
{
if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?"))
{
return FakeHttpMessageHandler.Json(HttpStatusCode.OK,
"{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}");
}

if (req.Method == HttpMethod.Post)
{
postRequest = req;
return FakeHttpMessageHandler.Json(HttpStatusCode.Created,
"{\"domain_record\":{\"id\":123,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"abc123\",\"ttl\":300}}");
}

throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}");
});

var provider = new DigitalOceanProvider("token", handler);

var result = await provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT");

Assert.True(result);
Assert.NotNull(postRequest);
Assert.EndsWith("domains/example.com/records", postRequest.RequestUri.PathAndQuery);

var body = await postRequest.Content.ReadAsStringAsync();
Assert.Contains("\"name\":\"_acme-challenge\"", body);
Assert.Contains("\"type\":\"TXT\"", body);
Assert.Contains("\"data\":\"abc123\"", body);
}

[Fact]
public async Task CreateRecordAsync_ThrowsWithApiDetailsWhenApiRejects()
{
var handler = new FakeHttpMessageHandler(req =>
{
if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?"))
{
return FakeHttpMessageHandler.Json(HttpStatusCode.OK,
"{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}");
}

return FakeHttpMessageHandler.Json(HttpStatusCode.BadRequest, "{\"id\":\"invalid_request\",\"message\":\"invalid data\"}");
});

var provider = new DigitalOceanProvider("token", handler);

var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT"));

Assert.Contains("400", ex.Message);
Assert.Contains("example.com", ex.Message);
}

[Fact]
public async Task CreateRecordAsync_ThrowsWhenNoZoneMatches()
{
var handler = new FakeHttpMessageHandler(req =>
FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{\"domains\":[{\"name\":\"other.com\"}],\"links\":{}}"));

var provider = new DigitalOceanProvider("token", handler);

var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT"));

Assert.Contains("No DigitalOcean domain found", ex.Message);
}

[Fact]
public async Task CreateRecordAsync_ThrowsAuthErrorNamingLikelyCauseOn401()
{
var handler = new FakeHttpMessageHandler(req =>
FakeHttpMessageHandler.Json(HttpStatusCode.Unauthorized, "{\"id\":\"unauthorized\",\"message\":\"Unable to authenticate you.\"}"));

var provider = new DigitalOceanProvider("bad-token", handler);

var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT"));

Assert.Contains("401", ex.Message);
Assert.Contains("API token", ex.Message);
}

[Fact]
public async Task CreateRecordAsync_FollowsPaginationToFindZone()
{
var handler = new FakeHttpMessageHandler(req =>
{
if (req.RequestUri.PathAndQuery.Contains("page=2"))
{
return FakeHttpMessageHandler.Json(HttpStatusCode.OK,
"{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}");
}

if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?"))
{
return FakeHttpMessageHandler.Json(HttpStatusCode.OK,
"{\"domains\":[{\"name\":\"other.com\"}],\"links\":{\"pages\":{\"next\":\"https://api.digitalocean.com/v2/domains?page=2&per_page=200\"}}}");
}

if (req.Method == HttpMethod.Post)
{
return FakeHttpMessageHandler.Json(HttpStatusCode.Created,
"{\"domain_record\":{\"id\":123,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"abc123\",\"ttl\":300}}");
}

throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}");
});

var provider = new DigitalOceanProvider("token", handler);

var result = await provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT");

Assert.True(result);
}

[Fact]
public async Task DeleteRecordAsync_DeletesMatchingRecord()
{
HttpRequestMessage deleteRequest = null;

var handler = new FakeHttpMessageHandler(req =>
{
if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?"))
{
return FakeHttpMessageHandler.Json(HttpStatusCode.OK,
"{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}");
}

if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records"))
{
return FakeHttpMessageHandler.Json(HttpStatusCode.OK,
"{\"domain_records\":[{\"id\":7,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"abc123\",\"ttl\":300}]}");
}

if (req.Method == HttpMethod.Delete)
{
deleteRequest = req;
return new HttpResponseMessage(HttpStatusCode.NoContent);
}

throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}");
});

var provider = new DigitalOceanProvider("token", handler);

var result = await provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT");

Assert.True(result);
Assert.NotNull(deleteRequest);
Assert.EndsWith("domains/example.com/records/7", deleteRequest.RequestUri.PathAndQuery);
}

[Fact]
public async Task DeleteRecordAsync_IsIdempotentWhenRecordAlreadyGone()
{
var handler = new FakeHttpMessageHandler(req =>
{
if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?"))
{
return FakeHttpMessageHandler.Json(HttpStatusCode.OK,
"{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}");
}

if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records"))
{
return FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{\"domain_records\":[]}");
}

throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}");
});

var provider = new DigitalOceanProvider("token", handler);

var result = await provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT");

Assert.True(result);
}

[Theory]
[InlineData(null)]
[InlineData("")]
public void Constructor_ThrowsOnMissingApiToken(string apiToken)
{
Assert.Throws<ArgumentException>(() => new DigitalOceanProvider(apiToken, new FakeHttpMessageHandler(_ =>
throw new InvalidOperationException("Should not make HTTP calls"))));
}
}
}
35 changes: 35 additions & 0 deletions Keyfactor.DnsProvider.DigitalOcean.Tests/FakeHttpMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Net;
using System.Net.Http;

namespace Keyfactor.Extensions.DomainValidator.DigitalOcean.Tests
{
/// <summary>
/// Routes requests to a caller-supplied responder so DigitalOceanProvider can be
/// exercised end-to-end without touching the real DigitalOcean API.
/// </summary>
internal class FakeHttpMessageHandler : HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = new();

private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;

public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
_responder = responder;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(_responder(request));
}

public static HttpResponseMessage Json(HttpStatusCode status, string body)
{
return new HttpResponseMessage(status)
{
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json")
};
}
}
}
Loading
Loading