Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ body:
- DNS Made Easy
- Gandi LiveDNS
- GoDaddy
- Infomaniak DNS
Comment thread
LeoC-SYD marked this conversation as resolved.
- Google Cloud DNS
- TransIP DNS
- Other
Expand Down
24 changes: 24 additions & 0 deletions src/Acmebot.App.Tests/Acmebot.App.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Acmebot.App\Acmebot.App.csproj" />
</ItemGroup>

</Project>
Comment on lines +1 to +24
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new test project isn't referenced by the repo solution file (src/Acmebot.slnx currently lists only Acmebot.Acme.Tests, Acmebot.Acme, and Acmebot.App). CI builds dotnet build -c Release ./src, which will build the solution in that folder, so this project may not be built/validated in CI and may also be skipped by dotnet format. Consider adding Acmebot.App.Tests/Acmebot.App.Tests.csproj to src/Acmebot.slnx (and, if intended, adding a dotnet test step to CI).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

271 changes: 271 additions & 0 deletions src/Acmebot.App.Tests/InfomaniakProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

using Acmebot.App.Providers;

using Xunit;

namespace Acmebot.App.Tests;

/// <summary>
/// Tests for InfomaniakProvider using a fake HTTP handler — no real domain required.
/// Each test enqueues mock JSON responses matching the Infomaniak REST API v1 format.
/// </summary>
public sealed class InfomaniakProviderTests
{
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------

/// <summary>Builds a fake 200 response with an Infomaniak-style success envelope.</summary>
private static HttpResponseMessage OkJson(object data)
{
var payload = JsonSerializer.Serialize(new { result = "success", data });
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
}

/// <summary>Builds a fake 200 response with an empty data array.</summary>
private static HttpResponseMessage OkEmpty() => OkJson(Array.Empty<object>());

/// <summary>Creates a provider backed by the given recording handler.</summary>
private static (InfomaniakProvider Provider, RecordingHandler Handler) CreateProvider()
{
var handler = new RecordingHandler();
var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.infomaniak.com/1/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
return (new InfomaniakProvider(http), handler);
}

// -------------------------------------------------------------------------
// ListZonesAsync
// -------------------------------------------------------------------------

[Fact]
public async Task ListZonesAsync_ReturnsMappedZones()
{
var (provider, handler) = CreateProvider();

handler.Enqueue(_ => OkJson(new[]
{
new { id = 1, customer_name = "example.com" },
new { id = 2, customer_name = "other.net" }
}));

var zones = await provider.ListZonesAsync(TestContext.Current.CancellationToken);

Assert.Equal(2, zones.Count);
Assert.Equal("1", zones[0].Id);
Assert.Equal("example.com", zones[0].Name);
Assert.Equal("2", zones[1].Id);
Assert.Equal("other.net", zones[1].Name);
}

[Fact]
public async Task ListZonesAsync_EmptyResponse_ReturnsEmptyList()
{
var (provider, handler) = CreateProvider();
handler.Enqueue(_ => OkEmpty());

var zones = await provider.ListZonesAsync(TestContext.Current.CancellationToken);

Assert.Empty(zones);
}

[Fact]
public async Task ListZonesAsync_RequestHasBearerToken()
{
var (provider, handler) = CreateProvider();
handler.Enqueue(_ => OkEmpty());

await provider.ListZonesAsync(TestContext.Current.CancellationToken);

var req = Assert.Single(handler.Requests);
Assert.True(req.Headers.TryGetValue("Authorization", out var auth));
Assert.Equal("Bearer test-token", auth[0]);
}

// -------------------------------------------------------------------------
// CreateTxtRecordAsync
// -------------------------------------------------------------------------

[Fact]
public async Task CreateTxtRecordAsync_SendsOneRequestPerValue()
{
var (provider, handler) = CreateProvider();

// Two values → two POST requests
handler.Enqueue(_ => OkJson(new { id = "r1" }));
handler.Enqueue(_ => OkJson(new { id = "r2" }));

var zone = new DnsZone(provider) { Id = "42", Name = "example.com" };

await provider.CreateTxtRecordAsync(
zone,
"_acme-challenge",
["token-a", "token-b"],
TestContext.Current.CancellationToken);

Assert.Equal(2, handler.Requests.Count);
Assert.All(handler.Requests, r => Assert.Equal(HttpMethod.Post, r.Method));
}

[Fact]
public async Task CreateTxtRecordAsync_PostBodyContainsExpectedFields()
{
var (provider, handler) = CreateProvider();
handler.Enqueue(_ => OkJson(new { id = "r1" }));

var zone = new DnsZone(provider) { Id = "42", Name = "example.com" };

await provider.CreateTxtRecordAsync(
zone,
"_acme-challenge",
["my-token"],
TestContext.Current.CancellationToken);

var req = Assert.Single(handler.Requests);
var body = JsonDocument.Parse(req.Content!);

Assert.Equal("TXT", body.RootElement.GetProperty("type").GetString());
Assert.Equal("_acme-challenge", body.RootElement.GetProperty("source").GetString());
Assert.Equal("my-token", body.RootElement.GetProperty("target").GetString());
Assert.Equal(60, body.RootElement.GetProperty("ttl").GetInt32());
}

// -------------------------------------------------------------------------
// DeleteTxtRecordAsync
// -------------------------------------------------------------------------

[Fact]
public async Task DeleteTxtRecordAsync_DeletesEachRecord()
{
var (provider, handler) = CreateProvider();

// First: list records → two results
handler.Enqueue(_ => OkJson(new[]
{
new { id = "r1", source = "_acme-challenge", target = "token-a" },
new { id = "r2", source = "_acme-challenge", target = "token-b" }
}));

// Then: two DELETE requests
handler.Enqueue(_ => OkJson(new { }));
handler.Enqueue(_ => OkJson(new { }));

var zone = new DnsZone(provider) { Id = "42", Name = "example.com" };

await provider.DeleteTxtRecordAsync(
zone,
"_acme-challenge",
TestContext.Current.CancellationToken);

var deletes = handler.Requests.Where(r => r.Method == HttpMethod.Delete).ToList();
Assert.Equal(2, deletes.Count);
Assert.Contains(deletes, r => r.RequestUri!.ToString().EndsWith("/r1"));
Assert.Contains(deletes, r => r.RequestUri!.ToString().EndsWith("/r2"));
}

[Fact]
public async Task DeleteTxtRecordAsync_NoRecords_SendsNoDeleteRequest()
{
var (provider, handler) = CreateProvider();
handler.Enqueue(_ => OkEmpty()); // list returns nothing

var zone = new DnsZone(provider) { Id = "42", Name = "example.com" };

await provider.DeleteTxtRecordAsync(
zone,
"_acme-challenge",
TestContext.Current.CancellationToken);

Assert.DoesNotContain(handler.Requests, r => r.Method == HttpMethod.Delete);
}

[Fact]
public async Task DeleteTxtRecordAsync_404OnDelete_IsIgnored()
{
var (provider, handler) = CreateProvider();

handler.Enqueue(_ => OkJson(new[]
{
new { id = "r1", source = "_acme-challenge", target = "token-a" }
}));

// Simulate a 404 — should not throw
handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.NotFound));

var zone = new DnsZone(provider) { Id = "42", Name = "example.com" };

await provider.DeleteTxtRecordAsync(
zone,
"_acme-challenge",
TestContext.Current.CancellationToken);

// If we reach here without exception, the test passes
}

// -------------------------------------------------------------------------
// Provider metadata
// -------------------------------------------------------------------------

[Fact]
public void Name_IsInfomaniak()
{
var (provider, _) = CreateProvider();
Assert.Equal("Infomaniak", provider.Name);
}

[Fact]
public void PropagationDelay_IsPositive()
{
var (provider, _) = CreateProvider();
Assert.True(provider.PropagationDelay > TimeSpan.Zero);
}
}

// ---------------------------------------------------------------------------
// Minimal recording handler (mirrors Acmebot.Acme.Tests.RecordingHandler)
// ---------------------------------------------------------------------------

internal sealed class RecordingHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responses = new();

public List<RecordedRequest> Requests { get; } = [];

/// <summary>Enqueue a response factory that will be dequeued on the next HTTP call.</summary>
public void Enqueue(Func<HttpRequestMessage, HttpResponseMessage> factory)
=> _responses.Enqueue(factory);

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(await RecordedRequest.CreateAsync(request, cancellationToken));

return _responses.TryDequeue(out var factory)
? factory(request)
: throw new InvalidOperationException("No response was configured for this HTTP request.");
}
}

/// <summary>Snapshot of an outgoing HTTP request for assertion purposes.</summary>
internal sealed record RecordedRequest(
HttpMethod Method,
Uri? RequestUri,
IReadOnlyDictionary<string, IReadOnlyList<string>> Headers,
string? Content)
{
public static async Task<RecordedRequest> CreateAsync(HttpRequestMessage req, CancellationToken ct) =>
new(
req.Method,
req.RequestUri,
req.Headers.ToDictionary(
h => h.Key,
h => (IReadOnlyList<string>)h.Value.ToArray(),
StringComparer.OrdinalIgnoreCase),
req.Content is null ? null : await req.Content.ReadAsStringAsync(ct));
}
6 changes: 6 additions & 0 deletions src/Acmebot.App/Acmebot.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@
<ProjectReference Include="..\Acmebot.Acme\Acmebot.Acme.csproj" />
</ItemGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Acmebot.App.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

<ItemGroup>
<None Update="wwwroot\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
Expand Down
2 changes: 2 additions & 0 deletions src/Acmebot.App/Options/AcmebotOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ public class AcmebotOptions

public GoogleDnsOptions? GoogleDns { get; set; }

public InfomaniakOptions? Infomaniak { get; set; }

public IonosDnsOptions? IonosDns { get; set; }

public Route53Options? Route53 { get; set; }
Expand Down
11 changes: 11 additions & 0 deletions src/Acmebot.App/Options/InfomaniakOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Acmebot.App.Options;

/// <summary>
/// Configuration options for the Infomaniak DNS provider.
/// Requires an API token generated from the Infomaniak Manager with the "domain" scope.
/// </summary>
public class InfomaniakOptions
{
/// <summary>OAuth2 Bearer token with DNS management permissions.</summary>
public required string ApiToken { get; set; }
}
1 change: 1 addition & 0 deletions src/Acmebot.App/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
dnsProviders.TryAdd(options.GandiLiveDns, o => new GandiLiveDnsProvider(o));
dnsProviders.TryAdd(options.GoDaddy, o => new GoDaddyProvider(o));
dnsProviders.TryAdd(options.GoogleDns, o => new GoogleDnsProvider(o));
dnsProviders.TryAdd(options.Infomaniak, o => new InfomaniakProvider(o));
dnsProviders.TryAdd(options.IonosDns, o => new IonosDnsProvider(o));
dnsProviders.TryAdd(options.Route53, o => new Route53Provider(o));
dnsProviders.TryAdd(options.TransIp, o => new TransIpProvider(options, o, credential));
Expand Down
Loading