From b3280531528f8dcc326db11a91c32df47527fa62 Mon Sep 17 00:00:00 2001 From: Timm Bremekamp Date: Thu, 11 Jun 2026 12:15:03 +0200 Subject: [PATCH] feat: add typed subdomain management for the KAS API Wrap the KAS subdomain actions (get_subdomains, add_subdomain, update_subdomain, delete_subdomain, move_subdomain) with the same typed surface as the DNS actions: AddSubdomain/UpdateSubdomain input models, the Subdomain read model, and the RedirectStatus/WebalizerVersion/WebalizerLanguage enums. Parameters verified against the official KAS phpdoc; response field shapes and the add return scalar verified live against a real account. Notable findings: add_subdomain returns the created host name as the ReturnInfo scalar (despite the docs only mentioning TRUE); the response field is subdomain_redirect_status (the request uses redirect_status); and KAS provisions a new subdomain asynchronously (in_progress = TRUE), rejecting updates until that clears. --- CHANGELOG.md | 1 + KASserver.Client/IKasClient.cs | 50 +++++ KASserver.Client/KasClient.cs | 77 ++++++++ KASserver.Client/Models/AddSubdomain.cs | 75 ++++++++ KASserver.Client/Models/RedirectStatus.cs | 21 ++ KASserver.Client/Models/Subdomain.cs | 84 ++++++++ .../Models/SubdomainEnumExtensions.cs | 74 +++++++ KASserver.Client/Models/UpdateSubdomain.cs | 56 ++++++ KASserver.Client/Models/WebalizerLanguage.cs | 14 ++ KASserver.Client/Models/WebalizerVersion.cs | 20 ++ README.md | 36 +++- .../AddSubdomainTests.cs | 135 +++++++++++++ .../KasClientContractTests.Subdomain.cs | 155 +++++++++++++++ .../KASserver.Client.Tests/SubdomainTests.cs | 180 ++++++++++++++++++ .../UpdateSubdomainTests.cs | 96 ++++++++++ 15 files changed, 1073 insertions(+), 1 deletion(-) create mode 100644 KASserver.Client/Models/AddSubdomain.cs create mode 100644 KASserver.Client/Models/RedirectStatus.cs create mode 100644 KASserver.Client/Models/Subdomain.cs create mode 100644 KASserver.Client/Models/SubdomainEnumExtensions.cs create mode 100644 KASserver.Client/Models/UpdateSubdomain.cs create mode 100644 KASserver.Client/Models/WebalizerLanguage.cs create mode 100644 KASserver.Client/Models/WebalizerVersion.cs create mode 100644 tests/KASserver.Client.Tests/AddSubdomainTests.cs create mode 100644 tests/KASserver.Client.Tests/KasClientContractTests.Subdomain.cs create mode 100644 tests/KASserver.Client.Tests/SubdomainTests.cs create mode 100644 tests/KASserver.Client.Tests/UpdateSubdomainTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d2050ea..4b9ba79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Account settings/superuser actions: `UpdateAccountSettingsAsync`, `UpdateSuperuserSettingsAsync`, `UpdateChownAsync` - DNS zone-record actions: `GetDnsRecordsAsync`, `GetDnsRecordAsync`, `AddDnsRecordAsync`, `UpdateDnsRecordAsync`, `DeleteDnsRecordAsync`, `ResetDnsSettingsAsync` — with the `DnsRecord` read model, the `DnsRecordType` enum plus a raw-type escape, and automatic `zone_host` trailing-dot normalization. Note: `ResetDnsSettingsAsync` is destructive — it discards all custom records of the zone - DynDNS user actions: `GetDynDnsUsersAsync`, `GetDynDnsUserAsync`, `AddDynDnsUserAsync`, `UpdateDynDnsUserAsync`, `DeleteDynDnsUserAsync` — with the `DynDnsUser` read model (`add_ddnsuser`/`get_ddnsusers`/`update_ddnsuser`/`delete_ddnsuser`) +- Subdomain actions: `GetSubdomainsAsync`, `GetSubdomainAsync`, `AddSubdomainAsync`, `UpdateSubdomainAsync`, `DeleteSubdomainAsync`, `MoveSubdomainAsync` — with the `Subdomain` read model and the `RedirectStatus`/`WebalizerVersion`/`WebalizerLanguage` enums (`add_subdomain`/`get_subdomains`/`update_subdomain`/`delete_subdomain`/`move_subdomain`). `AddSubdomainAsync` returns the created host name. Note: KAS provisions a new subdomain asynchronously (`in_progress = TRUE`) and rejects updates until that clears - Generic escape hatch `RequestAsync(action, params)` for any KAS action not yet wrapped - Dependency-injection integration via `AddKasServer(...)` - Multi-target support for net8.0, net9.0, and net10.0 diff --git a/KASserver.Client/IKasClient.cs b/KASserver.Client/IKasClient.cs index 1ba1a0e..2c5267d 100644 --- a/KASserver.Client/IKasClient.cs +++ b/KASserver.Client/IKasClient.cs @@ -263,4 +263,54 @@ Task RequestAsync( /// A cancellation token. /// The KAS API returned a fault or an uninterpretable response. Task DeleteDynDnsUserAsync(string dyndnsLogin, CancellationToken cancellationToken = default); + + /// Lists the subdomains on the account (get_subdomains). + /// A cancellation token. + /// The subdomains on the account. + /// The KAS API returned a fault or an uninterpretable response. + Task> GetSubdomainsAsync(CancellationToken cancellationToken = default); + + /// + /// Reads a single subdomain by its host name (get_subdomains with a subdomain_name filter). + /// + /// The subdomain host name, e.g. shop.example.com. + /// A cancellation token. + /// The matching subdomain, or null when no subdomain matches the name. + /// The KAS API returned a fault or an uninterpretable response. + Task GetSubdomainAsync(string subdomainName, CancellationToken cancellationToken = default); + + /// + /// Creates a subdomain (add_subdomain). The new host is {SubdomainName}.{DomainName}. + /// + /// + /// KAS provisions the subdomain asynchronously: it reports in_progress = TRUE for a short + /// while after creation, and is rejected with an + /// in_progress fault until that clears. + /// + /// The subdomain to create. + /// A cancellation token. + /// The full host name of the created subdomain ({SubdomainName}.{DomainName}), used to address it in subsequent update/delete/get calls. + /// The KAS API returned a fault or did not return a subdomain name. + Task AddSubdomainAsync(AddSubdomain subdomain, CancellationToken cancellationToken = default); + + /// Edits a subdomain (update_subdomain). Identified by its host name. + /// The subdomain host name, e.g. shop.example.com. + /// The fields to change; unset fields are left unchanged. + /// A cancellation token. + /// The KAS API returned a fault or an uninterpretable response. + Task UpdateSubdomainAsync(string subdomainName, UpdateSubdomain changes, CancellationToken cancellationToken = default); + + /// Deletes a subdomain (delete_subdomain). Identified by its host name. + /// The subdomain host name, e.g. shop.example.com. + /// A cancellation token. + /// The KAS API returned a fault or an uninterpretable response. + Task DeleteSubdomainAsync(string subdomainName, CancellationToken cancellationToken = default); + + /// Moves a subdomain between accounts (move_subdomain). Identified by its host name. + /// The subdomain host name, e.g. shop.example.com. + /// The source account the subdomain currently belongs to (source_account). + /// The target account the subdomain is moved to (target_account). + /// A cancellation token. + /// The KAS API returned a fault or an uninterpretable response. + Task MoveSubdomainAsync(string subdomainName, string sourceAccount, string targetAccount, CancellationToken cancellationToken = default); } diff --git a/KASserver.Client/KasClient.cs b/KASserver.Client/KasClient.cs index 6fa0d23..c156dbd 100644 --- a/KASserver.Client/KasClient.cs +++ b/KASserver.Client/KasClient.cs @@ -353,4 +353,81 @@ public Task DeleteDynDnsUserAsync(string dyndnsLogin, CancellationToken cancella var parameters = new Dictionary { ["dyndns_login"] = dyndnsLogin }; return _transport.CallAsync("delete_ddnsuser", parameters, cancellationToken); } + + /// + public async Task> GetSubdomainsAsync(CancellationToken cancellationToken = default) + { + var response = await _transport.CallAsync("get_subdomains", null, cancellationToken).ConfigureAwait(false); + return response.AsList().Select(Subdomain.FromMap).ToList(); + } + + /// + public async Task GetSubdomainAsync(string subdomainName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subdomainName); + + var parameters = new Dictionary { ["subdomain_name"] = subdomainName }; + var response = await _transport.CallAsync("get_subdomains", parameters, cancellationToken).ConfigureAwait(false); + return response.AsList().Select(Subdomain.FromMap).FirstOrDefault(); + } + + /// + public async Task AddSubdomainAsync(AddSubdomain subdomain, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(subdomain); + + var response = await _transport.CallAsync("add_subdomain", subdomain.ToParameters(), cancellationToken).ConfigureAwait(false); + return ExtractSubdomainName(response); + } + + // add_subdomain returns the full host name (subdomain_name.domain_name) as the ReturnInfo scalar + // (verified live; the docs only state it returns "TRUE", but ReturnInfo carries the host name). + // The map branch is a defensive fallback in case KAS ever wraps it in a "subdomain_name" field. + internal static string ExtractSubdomainName(KasResponse response) + { + var name = response.ReturnInfo switch + { + string scalar => scalar, + IReadOnlyDictionary map => map.GetValueOrDefault("subdomain_name") as string, + _ => null, + }; + + return !string.IsNullOrWhiteSpace(name) + ? name + : throw new KasApiException("add_subdomain did not return a subdomain name.", action: "add_subdomain"); + } + + /// + public Task UpdateSubdomainAsync(string subdomainName, UpdateSubdomain changes, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subdomainName); + ArgumentNullException.ThrowIfNull(changes); + + return _transport.CallAsync("update_subdomain", changes.ToParameters(subdomainName), cancellationToken); + } + + /// + public Task DeleteSubdomainAsync(string subdomainName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subdomainName); + + var parameters = new Dictionary { ["subdomain_name"] = subdomainName }; + return _transport.CallAsync("delete_subdomain", parameters, cancellationToken); + } + + /// + public Task MoveSubdomainAsync(string subdomainName, string sourceAccount, string targetAccount, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subdomainName); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceAccount); + ArgumentException.ThrowIfNullOrWhiteSpace(targetAccount); + + var parameters = new Dictionary + { + ["subdomain_name"] = subdomainName, + ["source_account"] = sourceAccount, + ["target_account"] = targetAccount, + }; + return _transport.CallAsync("move_subdomain", parameters, cancellationToken); + } } diff --git a/KASserver.Client/Models/AddSubdomain.cs b/KASserver.Client/Models/AddSubdomain.cs new file mode 100644 index 0000000..29698ae --- /dev/null +++ b/KASserver.Client/Models/AddSubdomain.cs @@ -0,0 +1,75 @@ +namespace KASserver; + +/// +/// Parameters for creating a subdomain (add_subdomain). The new host is +/// {SubdomainName}.{DomainName}. Only the two name parts are required; every other property is +/// optional and only sent when set, leaving the KAS defaults in place +/// (redirect_status = no redirect, statistic_version = 5, statistic_language = de, +/// php_version = 7.1). +/// +public sealed class AddSubdomain +{ + /// The subdomain label (subdomain_name), e.g. shop for shop.example.com. Required. + public required string SubdomainName { get; set; } + + /// The domain the label is added to (domain_name), e.g. example.com. Required. + public required string DomainName { get; set; } + + /// + /// The host path within the account, or — when a redirect is set — a target FQDN or WBK + /// (subdomain_path). Examples: /path/, http://domain.tld, wbk:wbk000001. + /// Optional. + /// + public string? SubdomainPath { get; set; } + + /// The redirect behaviour (redirect_status); defaults to no redirect on the server. Optional. + public RedirectStatus? Redirect { get; set; } + + /// The webalizer statistics version (statistic_version); defaults to 5 on the server. Optional. + public WebalizerVersion? StatisticVersion { get; set; } + + /// The webalizer statistics language (statistic_language); defaults to de on the server. Optional. + public WebalizerLanguage? StatisticLanguage { get; set; } + + /// + /// The PHP version (php_version), a raw KAS version string such as 8.3; defaults to + /// 7.1 on the server. The documented 5.X|7.X format is outdated — current accounts + /// also accept 8.x. Optional. + /// + public string? PhpVersion { get; set; } + + internal IReadOnlyDictionary ToParameters() + { + ArgumentException.ThrowIfNullOrWhiteSpace(SubdomainName); + ArgumentException.ThrowIfNullOrWhiteSpace(DomainName); + + var parameters = new Dictionary + { + ["subdomain_name"] = SubdomainName, + ["domain_name"] = DomainName, + }; + + if (SubdomainPath is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(SubdomainPath); + parameters["subdomain_path"] = SubdomainPath; + } + + if (Redirect is not null) + parameters["redirect_status"] = Redirect.Value.ToKasValue(); + + if (StatisticVersion is not null) + parameters["statistic_version"] = StatisticVersion.Value.ToKasValue(); + + if (StatisticLanguage is not null) + parameters["statistic_language"] = StatisticLanguage.Value.ToKasValue(); + + if (PhpVersion is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(PhpVersion); + parameters["php_version"] = PhpVersion; + } + + return parameters; + } +} diff --git a/KASserver.Client/Models/RedirectStatus.cs b/KASserver.Client/Models/RedirectStatus.cs new file mode 100644 index 0000000..3bf6fde --- /dev/null +++ b/KASserver.Client/Models/RedirectStatus.cs @@ -0,0 +1,21 @@ +namespace KASserver; + +/// +/// The redirect behaviour of a subdomain (KAS redirect_status). When the +/// subdomain serves the host path; otherwise it issues an HTTP redirect to the FQDN/WBK given as the +/// subdomain path. +/// +public enum RedirectStatus +{ + /// No redirect (0); the subdomain serves its host path. + None, + + /// Permanent redirect, HTTP 301. + MovedPermanently, + + /// Temporary redirect, HTTP 302. + Found, + + /// Temporary redirect, HTTP 307. + TemporaryRedirect, +} diff --git a/KASserver.Client/Models/Subdomain.cs b/KASserver.Client/Models/Subdomain.cs new file mode 100644 index 0000000..bb153c7 --- /dev/null +++ b/KASserver.Client/Models/Subdomain.cs @@ -0,0 +1,84 @@ +namespace KASserver; + +/// +/// A subdomain as returned by get_subdomains. Typed convenience properties cover the common +/// fields; exposes the complete map for everything else. +/// +/// +/// Field shape verified live against the KAS API (get_subdomains on famgmbh.de). Note that the +/// response field names differ from the request parameter names: the redirect status is returned as +/// subdomain_redirect_status (the request uses redirect_status), while +/// subdomain_name is the full host name, e.g. shop.example.com. Freshly created +/// subdomains report in_progress = TRUE while KAS provisions them; updates are rejected with an +/// in_progress fault until that clears. +/// +public sealed class Subdomain +{ + /// The complete raw field map as returned by the KAS API. + public required IReadOnlyDictionary Raw { get; init; } + + /// The subdomain host name (KAS field subdomain_name), e.g. shop.example.com. + public string? SubdomainName => Raw.GetValueOrDefault("subdomain_name") as string; + + /// The host path or redirect target (KAS field subdomain_path). + public string? SubdomainPath => Raw.GetValueOrDefault("subdomain_path") as string; + + /// The raw redirect-status string as returned by KAS (KAS field subdomain_redirect_status). + public string? RawRedirectStatus => Raw.GetValueOrDefault("subdomain_redirect_status") as string; + + /// + /// The redirect behaviour as a , or null when KAS returned a + /// value not covered by the enum (the raw string is then available via ). + /// + public RedirectStatus? Redirect => SubdomainEnumExtensions.TryParseRedirectStatus(RawRedirectStatus); + + /// The PHP version (KAS field php_version), a raw version string such as 8.5. + public string? PhpVersion => Raw.GetValueOrDefault("php_version") as string; + + /// Whether the configured PHP version is deprecated (KAS field php_deprecated, Y/N). + public bool PhpDeprecated => IsYes("php_deprecated"); + + /// The raw webalizer-version string as returned by KAS (KAS field statistic_version). + public string? RawStatisticVersion => Raw.GetValueOrDefault("statistic_version") as string; + + /// The webalizer statistics version as a , or null for an unknown/missing value. + public WebalizerVersion? StatisticVersion => SubdomainEnumExtensions.TryParseWebalizerVersion(RawStatisticVersion); + + /// The raw webalizer-language string as returned by KAS (KAS field statistic_language). + public string? RawStatisticLanguage => Raw.GetValueOrDefault("statistic_language") as string; + + /// The webalizer statistics language as a , or null for an unknown/missing value. + public WebalizerLanguage? StatisticLanguage => SubdomainEnumExtensions.TryParseWebalizerLanguage(RawStatisticLanguage); + + /// The account the subdomain belongs to (KAS field subdomain_account). + public string? Account => Raw.GetValueOrDefault("subdomain_account") as string; + + /// The server hosting the subdomain (KAS field subdomain_server). + public string? Server => Raw.GetValueOrDefault("subdomain_server") as string; + + /// Whether the subdomain is active (KAS field is_active, Y/N). + public bool IsActive => IsYes("is_active"); + + /// + /// Whether KAS is still provisioning the subdomain (KAS field in_progress, TRUE/FALSE). + /// Updates are rejected with an in_progress fault while this is true. + /// + public bool InProgress => string.Equals(Raw.GetValueOrDefault("in_progress") as string, "TRUE", StringComparison.OrdinalIgnoreCase); + + /// Whether FrontPage Server Extensions are active (KAS field fpse_active, Y/N). + public bool FpseActive => IsYes("fpse_active"); + + /// Whether the SSL proxy is enabled (KAS field ssl_proxy, Y/N). + public bool SslProxy => IsYes("ssl_proxy"); + + /// Whether an IP-based SSL certificate is present (KAS field ssl_certificate_ip, Y/N). + public bool SslCertificateIp => IsYes("ssl_certificate_ip"); + + /// Whether an SNI-based SSL certificate is present (KAS field ssl_certificate_sni, Y/N). + public bool SslCertificateSni => IsYes("ssl_certificate_sni"); + + private bool IsYes(string key) => + string.Equals(Raw.GetValueOrDefault(key) as string, "Y", StringComparison.OrdinalIgnoreCase); + + internal static Subdomain FromMap(IReadOnlyDictionary map) => new() { Raw = map }; +} diff --git a/KASserver.Client/Models/SubdomainEnumExtensions.cs b/KASserver.Client/Models/SubdomainEnumExtensions.cs new file mode 100644 index 0000000..42674a9 --- /dev/null +++ b/KASserver.Client/Models/SubdomainEnumExtensions.cs @@ -0,0 +1,74 @@ +namespace KASserver; + +/// +/// Maps the subdomain enums (, , +/// ) to and from their KAS string values, kept in one place so the +/// add_subdomain/update_subdomain requests and the read model +/// stay consistent. +/// +internal static class SubdomainEnumExtensions +{ + internal static string ToKasValue(this RedirectStatus status) => status switch + { + RedirectStatus.None => "0", + RedirectStatus.MovedPermanently => "301", + RedirectStatus.Found => "302", + RedirectStatus.TemporaryRedirect => "307", + _ => throw new ArgumentOutOfRangeException(nameof(status)), + }; + + /// + /// Parses a KAS redirect-status value back to a (the request sends it + /// as redirect_status, the get_subdomains response returns it as + /// subdomain_redirect_status). Returns null for an unknown or missing value — read + /// models must not throw on unexpected values. + /// + internal static RedirectStatus? TryParseRedirectStatus(string? raw) => raw?.Trim() switch + { + "0" => RedirectStatus.None, + "301" => RedirectStatus.MovedPermanently, + "302" => RedirectStatus.Found, + "307" => RedirectStatus.TemporaryRedirect, + _ => null, + }; + + internal static string ToKasValue(this WebalizerVersion version) => version switch + { + WebalizerVersion.None => "0", + WebalizerVersion.Version4 => "4", + WebalizerVersion.Version5 => "5", + WebalizerVersion.Version7 => "7", + _ => throw new ArgumentOutOfRangeException(nameof(version)), + }; + + /// + /// Parses a KAS statistic_version string back to a . Returns + /// null for an unknown or missing value. + /// + internal static WebalizerVersion? TryParseWebalizerVersion(string? raw) => raw?.Trim() switch + { + "0" => WebalizerVersion.None, + "4" => WebalizerVersion.Version4, + "5" => WebalizerVersion.Version5, + "7" => WebalizerVersion.Version7, + _ => null, + }; + + internal static string ToKasValue(this WebalizerLanguage language) => language switch + { + WebalizerLanguage.German => "de", + WebalizerLanguage.English => "en", + _ => throw new ArgumentOutOfRangeException(nameof(language)), + }; + + /// + /// Parses a KAS statistic_language string back to a . Returns + /// null for an unknown or missing value. + /// + internal static WebalizerLanguage? TryParseWebalizerLanguage(string? raw) => raw?.Trim().ToLowerInvariant() switch + { + "de" => WebalizerLanguage.German, + "en" => WebalizerLanguage.English, + _ => null, + }; +} diff --git a/KASserver.Client/Models/UpdateSubdomain.cs b/KASserver.Client/Models/UpdateSubdomain.cs new file mode 100644 index 0000000..42f2f31 --- /dev/null +++ b/KASserver.Client/Models/UpdateSubdomain.cs @@ -0,0 +1,56 @@ +namespace KASserver; + +/// +/// Parameters for editing a subdomain (update_subdomain). Only properties that are set +/// (non-null) are sent; everything left null stays unchanged on the server. The subdomain is +/// identified by its host name, passed separately to . +/// At least one field must be set. update_subdomain only accepts subdomain_path, +/// redirect_status and php_version — unlike , the webalizer +/// statistics fields cannot be changed here. +/// +public sealed class UpdateSubdomain +{ + /// + /// The host path within the account, or — when a redirect is set — a target FQDN or WBK + /// (subdomain_path). Examples: /path/, http://domain.tld, wbk:wbk000001. + /// Optional. + /// + public string? SubdomainPath { get; set; } + + /// The redirect behaviour (redirect_status). Optional. + public RedirectStatus? Redirect { get; set; } + + /// + /// The PHP version (php_version), a raw KAS version string such as 8.3. The + /// documented 5.X|7.X format is outdated — current accounts also accept 8.x. Optional. + /// + public string? PhpVersion { get; set; } + + internal IReadOnlyDictionary ToParameters(string subdomainName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(subdomainName); + + var parameters = new Dictionary { ["subdomain_name"] = subdomainName }; + + if (SubdomainPath is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(SubdomainPath); + parameters["subdomain_path"] = SubdomainPath; + } + + if (Redirect is not null) + parameters["redirect_status"] = Redirect.Value.ToKasValue(); + + if (PhpVersion is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(PhpVersion); + parameters["php_version"] = PhpVersion; + } + + // subdomain_name alone is a no-op that KAS rejects with "nothing_to_do" — fail fast on the client. + if (parameters.Count == 1) + throw new ArgumentException($"At least one field on {nameof(UpdateSubdomain)} must be set."); + + return parameters; + } +} diff --git a/KASserver.Client/Models/WebalizerLanguage.cs b/KASserver.Client/Models/WebalizerLanguage.cs new file mode 100644 index 0000000..73e238d --- /dev/null +++ b/KASserver.Client/Models/WebalizerLanguage.cs @@ -0,0 +1,14 @@ +namespace KASserver; + +/// +/// The webalizer statistics language of a subdomain (KAS statistic_language: de|en, +/// default de). +/// +public enum WebalizerLanguage +{ + /// German (de), the KAS default. + German, + + /// English (en). + English, +} diff --git a/KASserver.Client/Models/WebalizerVersion.cs b/KASserver.Client/Models/WebalizerVersion.cs new file mode 100644 index 0000000..2d9247f --- /dev/null +++ b/KASserver.Client/Models/WebalizerVersion.cs @@ -0,0 +1,20 @@ +namespace KASserver; + +/// +/// The webalizer statistics version of a subdomain (KAS statistic_version: 0|4|5|7, +/// default 5). disables the statistics. +/// +public enum WebalizerVersion +{ + /// Statistics disabled (0). + None, + + /// Webalizer version 4 (4). + Version4, + + /// Webalizer version 5 (5), the KAS default. + Version5, + + /// Webalizer version 7 (7). + Version7, +} diff --git a/README.md b/README.md index 793f31f..577e1ed 100644 --- a/README.md +++ b/README.md @@ -154,9 +154,43 @@ public class DynDnsProvisioning(IKasClient kas) } ``` +### Subdomains + +```csharp +public class SubdomainProvisioning(IKasClient kas) +{ + public async Task Example() + { + // Create shop.example.com (returns the full host name, used to address it afterwards) + var host = await kas.AddSubdomainAsync(new AddSubdomain + { + SubdomainName = "shop", + DomainName = "example.com", + SubdomainPath = "/shop/", + PhpVersion = "8.3", // optional; KAS defaults apply when omitted + }); + + // Turn it into a 301 redirect to another host. Note: a freshly created subdomain is still + // provisioning (Subdomain.InProgress) and rejects updates until that clears — in real code, + // poll GetSubdomainAsync(host) until !InProgress before updating (see the note below). + await kas.UpdateSubdomainAsync(host, new UpdateSubdomain + { + SubdomainPath = "https://www.example.org", + Redirect = RedirectStatus.MovedPermanently, + }); + + // List subdomains, then remove it again (update/delete/get work on the host name) + var subdomains = await kas.GetSubdomainsAsync(); + await kas.DeleteSubdomainAsync(host); + } +} +``` + +> KAS provisions a new subdomain asynchronously: it reports `Subdomain.InProgress == true` for a short while after creation, and `UpdateSubdomainAsync` faults with `in_progress` until that clears. + ## Status -Early scaffold. Implemented: authentication, session handling, automatic flood throttling, raw-SOAP transport with `Map` parsing, the mailbox/forward read & write actions, the account-management actions (subaccounts, account/superuser settings, ownership), the DNS zone-record actions, and the DynDNS user actions. The remaining KAS actions (databases, FTP, cronjobs, …) follow the same `RequestAsync(action, params)` mechanism and are being added incrementally. +Early scaffold. Implemented: authentication, session handling, automatic flood throttling, raw-SOAP transport with `Map` parsing, the mailbox/forward read & write actions, the account-management actions (subaccounts, account/superuser settings, ownership), the DNS zone-record actions, the DynDNS user actions, and the subdomain actions. The remaining KAS actions (databases, FTP, cronjobs, …) follow the same `RequestAsync(action, params)` mechanism and are being added incrementally. ## Documentation diff --git a/tests/KASserver.Client.Tests/AddSubdomainTests.cs b/tests/KASserver.Client.Tests/AddSubdomainTests.cs new file mode 100644 index 0000000..63e8a0c --- /dev/null +++ b/tests/KASserver.Client.Tests/AddSubdomainTests.cs @@ -0,0 +1,135 @@ +using KASserver; + +namespace KASserver.Client.Tests; + +public class AddSubdomainTests +{ + private static AddSubdomain Valid() => new() + { + SubdomainName = "shop", + DomainName = "example.com", + }; + + [Test] + public void ToParameters_RequiredFieldsAreSent() + { + var parameters = Valid().ToParameters(); + + Assert.Multiple(() => + { + Assert.That(parameters["subdomain_name"], Is.EqualTo("shop")); + Assert.That(parameters["domain_name"], Is.EqualTo("example.com")); + }); + } + + [Test] + public void ToParameters_OptionalFields_AreOmittedWhenUnset() + { + var parameters = Valid().ToParameters(); + + Assert.Multiple(() => + { + Assert.That(parameters.ContainsKey("subdomain_path"), Is.False); + Assert.That(parameters.ContainsKey("redirect_status"), Is.False); + Assert.That(parameters.ContainsKey("statistic_version"), Is.False); + Assert.That(parameters.ContainsKey("statistic_language"), Is.False); + Assert.That(parameters.ContainsKey("php_version"), Is.False); + }); + } + + [Test] + public void ToParameters_OptionalFields_AreSentWhenSet() + { + var subdomain = Valid(); + subdomain.SubdomainPath = "/shop/"; + subdomain.Redirect = RedirectStatus.MovedPermanently; + subdomain.StatisticVersion = WebalizerVersion.Version7; + subdomain.StatisticLanguage = WebalizerLanguage.English; + subdomain.PhpVersion = "8.1"; + + var parameters = subdomain.ToParameters(); + + Assert.Multiple(() => + { + Assert.That(parameters["subdomain_path"], Is.EqualTo("/shop/")); + Assert.That(parameters["redirect_status"], Is.EqualTo("301")); + Assert.That(parameters["statistic_version"], Is.EqualTo("7")); + Assert.That(parameters["statistic_language"], Is.EqualTo("en")); + Assert.That(parameters["php_version"], Is.EqualTo("8.1")); + }); + } + + [Test] + [TestCase(RedirectStatus.None, "0")] + [TestCase(RedirectStatus.MovedPermanently, "301")] + [TestCase(RedirectStatus.Found, "302")] + [TestCase(RedirectStatus.TemporaryRedirect, "307")] + public void ToParameters_Redirect_MapsToKasValue(RedirectStatus status, string expected) + { + var subdomain = Valid(); + subdomain.Redirect = status; + Assert.That(subdomain.ToParameters()["redirect_status"], Is.EqualTo(expected)); + } + + [Test] + [TestCase(WebalizerVersion.None, "0")] + [TestCase(WebalizerVersion.Version4, "4")] + [TestCase(WebalizerVersion.Version5, "5")] + [TestCase(WebalizerVersion.Version7, "7")] + public void ToParameters_StatisticVersion_MapsToKasValue(WebalizerVersion version, string expected) + { + var subdomain = Valid(); + subdomain.StatisticVersion = version; + Assert.That(subdomain.ToParameters()["statistic_version"], Is.EqualTo(expected)); + } + + [Test] + [TestCase(WebalizerLanguage.German, "de")] + [TestCase(WebalizerLanguage.English, "en")] + public void ToParameters_StatisticLanguage_MapsToKasValue(WebalizerLanguage language, string expected) + { + var subdomain = Valid(); + subdomain.StatisticLanguage = language; + Assert.That(subdomain.ToParameters()["statistic_language"], Is.EqualTo(expected)); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + public void ToParameters_BlankSubdomainName_Throws(string name) + { + var subdomain = Valid(); + subdomain.SubdomainName = name; + Assert.Throws(() => subdomain.ToParameters()); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + public void ToParameters_BlankDomainName_Throws(string domain) + { + var subdomain = Valid(); + subdomain.DomainName = domain; + Assert.Throws(() => subdomain.ToParameters()); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + public void ToParameters_BlankSubdomainPath_Throws(string path) + { + var subdomain = Valid(); + subdomain.SubdomainPath = path; + Assert.Throws(() => subdomain.ToParameters()); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + public void ToParameters_BlankPhpVersion_Throws(string version) + { + var subdomain = Valid(); + subdomain.PhpVersion = version; + Assert.Throws(() => subdomain.ToParameters()); + } +} diff --git a/tests/KASserver.Client.Tests/KasClientContractTests.Subdomain.cs b/tests/KASserver.Client.Tests/KasClientContractTests.Subdomain.cs new file mode 100644 index 0000000..0f63549 --- /dev/null +++ b/tests/KASserver.Client.Tests/KasClientContractTests.Subdomain.cs @@ -0,0 +1,155 @@ +using KASserver.Client.Tests.Fakes; + +namespace KASserver.Client.Tests; + +/// +/// Verifies the action name and parameter map that the subdomain wrappers send through the transport +/// seam. Covers the KAS-specific quirks: get_subdomains with/without the subdomain_name +/// filter, add_subdomain returning no id (addressed by host name), and the move_subdomain +/// source/target accounts. +/// +public class KasClientContractSubdomainTests +{ + [Test] + public async Task GetSubdomainsAsync_SendsActionAndProjectsList() + { + var fake = new RecordingKasTransport().EnqueueList( + new Dictionary { ["subdomain_name"] = "shop.example.com", ["php_version"] = "8.1" }); + var client = new KasClient(fake); + + var subdomains = await client.GetSubdomainsAsync(); + + Assert.Multiple(() => + { + Assert.That(fake.LastAction, Is.EqualTo("get_subdomains")); + Assert.That(fake.LastParameters, Is.Null); + Assert.That(subdomains[0].SubdomainName, Is.EqualTo("shop.example.com")); + Assert.That(subdomains[0].PhpVersion, Is.EqualTo("8.1")); + }); + } + + [Test] + public async Task GetSubdomainAsync_SendsSubdomainNameFilter() + { + var fake = new RecordingKasTransport().EnqueueList( + new Dictionary { ["subdomain_name"] = "shop.example.com" }); + var client = new KasClient(fake); + + var subdomain = await client.GetSubdomainAsync("shop.example.com"); + + Assert.Multiple(() => + { + Assert.That(fake.LastAction, Is.EqualTo("get_subdomains")); + Assert.That(fake.LastParameters!["subdomain_name"], Is.EqualTo("shop.example.com")); + Assert.That(subdomain!.SubdomainName, Is.EqualTo("shop.example.com")); + }); + } + + [Test] + public async Task AddSubdomainAsync_SendsRequiredFieldsAndExtractsHostName() + { + var fake = new RecordingKasTransport().EnqueueScalar("shop.example.com"); + var client = new KasClient(fake); + + var host = await client.AddSubdomainAsync(new AddSubdomain + { + SubdomainName = "shop", + DomainName = "example.com", + }); + + Assert.Multiple(() => + { + Assert.That(fake.LastAction, Is.EqualTo("add_subdomain")); + Assert.That(fake.LastParameters!["subdomain_name"], Is.EqualTo("shop")); + Assert.That(fake.LastParameters!["domain_name"], Is.EqualTo("example.com")); + // Every unset optional is omitted entirely, not sent as a default/empty value. + Assert.That(fake.LastParameters!.ContainsKey("subdomain_path"), Is.False); + Assert.That(fake.LastParameters!.ContainsKey("redirect_status"), Is.False); + Assert.That(fake.LastParameters!.ContainsKey("statistic_version"), Is.False); + Assert.That(fake.LastParameters!.ContainsKey("statistic_language"), Is.False); + Assert.That(fake.LastParameters!.ContainsKey("php_version"), Is.False); + // add_subdomain returns the full host name as the ReturnInfo scalar (verified live). + Assert.That(host, Is.EqualTo("shop.example.com")); + }); + } + + [Test] + public async Task AddSubdomainAsync_SendsOptionalFieldsWhenSet() + { + var fake = new RecordingKasTransport().EnqueueScalar("shop.example.com"); + var client = new KasClient(fake); + + await client.AddSubdomainAsync(new AddSubdomain + { + SubdomainName = "shop", + DomainName = "example.com", + SubdomainPath = "http://target.tld", + Redirect = RedirectStatus.MovedPermanently, + StatisticVersion = WebalizerVersion.Version7, + StatisticLanguage = WebalizerLanguage.English, + PhpVersion = "8.1", + }); + + Assert.Multiple(() => + { + Assert.That(fake.LastParameters!["subdomain_path"], Is.EqualTo("http://target.tld")); + Assert.That(fake.LastParameters!["redirect_status"], Is.EqualTo("301")); + Assert.That(fake.LastParameters!["statistic_version"], Is.EqualTo("7")); + Assert.That(fake.LastParameters!["statistic_language"], Is.EqualTo("en")); + Assert.That(fake.LastParameters!["php_version"], Is.EqualTo("8.1")); + }); + } + + [Test] + public async Task UpdateSubdomainAsync_SendsSubdomainNameAndFields() + { + var fake = new RecordingKasTransport().EnqueueSuccess(); + var client = new KasClient(fake); + + await client.UpdateSubdomainAsync("shop.example.com", new UpdateSubdomain { Redirect = RedirectStatus.Found }); + + Assert.Multiple(() => + { + Assert.That(fake.LastAction, Is.EqualTo("update_subdomain")); + Assert.That(fake.LastParameters!["subdomain_name"], Is.EqualTo("shop.example.com")); + Assert.That(fake.LastParameters!["redirect_status"], Is.EqualTo("302")); + // Unset fields are omitted; update_subdomain never carries the add-only statistic fields. + Assert.That(fake.LastParameters!.ContainsKey("subdomain_path"), Is.False); + Assert.That(fake.LastParameters!.ContainsKey("php_version"), Is.False); + Assert.That(fake.LastParameters!.ContainsKey("statistic_version"), Is.False); + Assert.That(fake.LastParameters!.ContainsKey("statistic_language"), Is.False); + }); + } + + [Test] + public async Task DeleteSubdomainAsync_SendsActionAndSubdomainName() + { + var fake = new RecordingKasTransport().EnqueueSuccess(); + var client = new KasClient(fake); + + await client.DeleteSubdomainAsync("shop.example.com"); + + Assert.Multiple(() => + { + Assert.That(fake.LastAction, Is.EqualTo("delete_subdomain")); + Assert.That(fake.LastParameters!["subdomain_name"], Is.EqualTo("shop.example.com")); + }); + } + + [Test] + public async Task MoveSubdomainAsync_SendsSourceAndTargetAccounts() + { + var fake = new RecordingKasTransport().EnqueueSuccess(); + var client = new KasClient(fake); + + await client.MoveSubdomainAsync("shop.example.com", "w00aaaaa", "w00bbbbb"); + + Assert.Multiple(() => + { + Assert.That(fake.LastAction, Is.EqualTo("move_subdomain")); + Assert.That(fake.LastParameters!["subdomain_name"], Is.EqualTo("shop.example.com")); + Assert.That(fake.LastParameters!["source_account"], Is.EqualTo("w00aaaaa")); + Assert.That(fake.LastParameters!["target_account"], Is.EqualTo("w00bbbbb")); + }); + } +} diff --git a/tests/KASserver.Client.Tests/SubdomainTests.cs b/tests/KASserver.Client.Tests/SubdomainTests.cs new file mode 100644 index 0000000..326a5b2 --- /dev/null +++ b/tests/KASserver.Client.Tests/SubdomainTests.cs @@ -0,0 +1,180 @@ +using KASserver; + +namespace KASserver.Client.Tests; + +public class SubdomainTests +{ + // Field shape verified live against the KAS API (get_subdomains on famgmbh.de): the redirect status + // is returned as subdomain_redirect_status (the request uses redirect_status), subdomain_name is the + // full host name, plus is_active / in_progress / ssl_* / php_deprecated / subdomain_account / + // subdomain_server. + [Test] + public void FromMap_ExposesConvenienceProperties() + { + var subdomain = Subdomain.FromMap(new Dictionary + { + ["subdomain_name"] = "shop.example.com", + ["subdomain_path"] = "/shop/", + ["subdomain_redirect_status"] = "301", + ["php_version"] = "8.5", + ["php_deprecated"] = "N", + ["statistic_version"] = "7", + ["statistic_language"] = "en", + ["subdomain_account"] = "w021a9f9", + ["subdomain_server"] = "dd17708", + ["is_active"] = "Y", + ["in_progress"] = "FALSE", + ["fpse_active"] = "N", + ["ssl_proxy"] = "N", + ["ssl_certificate_ip"] = "N", + ["ssl_certificate_sni"] = "Y", + }); + + Assert.Multiple(() => + { + Assert.That(subdomain.SubdomainName, Is.EqualTo("shop.example.com")); + Assert.That(subdomain.SubdomainPath, Is.EqualTo("/shop/")); + Assert.That(subdomain.RawRedirectStatus, Is.EqualTo("301")); + Assert.That(subdomain.Redirect, Is.EqualTo(RedirectStatus.MovedPermanently)); + Assert.That(subdomain.PhpVersion, Is.EqualTo("8.5")); + Assert.That(subdomain.PhpDeprecated, Is.False); + Assert.That(subdomain.StatisticVersion, Is.EqualTo(WebalizerVersion.Version7)); + Assert.That(subdomain.StatisticLanguage, Is.EqualTo(WebalizerLanguage.English)); + Assert.That(subdomain.Account, Is.EqualTo("w021a9f9")); + Assert.That(subdomain.Server, Is.EqualTo("dd17708")); + Assert.That(subdomain.IsActive, Is.True); + Assert.That(subdomain.InProgress, Is.False); + Assert.That(subdomain.FpseActive, Is.False); + Assert.That(subdomain.SslProxy, Is.False); + Assert.That(subdomain.SslCertificateIp, Is.False); + Assert.That(subdomain.SslCertificateSni, Is.True); + }); + } + + // A freshly created subdomain reports in_progress = TRUE while KAS provisions it (verified live). + [Test] + public void FromMap_FreshlyCreated_IsInProgress() + { + var subdomain = Subdomain.FromMap(new Dictionary + { + ["subdomain_name"] = "new.example.com", + ["in_progress"] = "TRUE", + }); + + Assert.That(subdomain.InProgress, Is.True); + } + + [Test] + public void Redirect_UnknownValue_IsNullButRawKept() + { + var subdomain = Subdomain.FromMap(new Dictionary { ["subdomain_redirect_status"] = "308" }); + + Assert.Multiple(() => + { + Assert.That(subdomain.Redirect, Is.Null); + Assert.That(subdomain.RawRedirectStatus, Is.EqualTo("308")); + }); + } + + [Test] + public void StatisticVersion_UnknownValue_IsNullButRawKept() + { + var subdomain = Subdomain.FromMap(new Dictionary { ["statistic_version"] = "9" }); + + Assert.Multiple(() => + { + Assert.That(subdomain.StatisticVersion, Is.Null); + Assert.That(subdomain.RawStatisticVersion, Is.EqualTo("9")); + }); + } + + [Test] + public void StatisticLanguage_UnknownValue_IsNullButRawKept() + { + var subdomain = Subdomain.FromMap(new Dictionary { ["statistic_language"] = "fr" }); + + Assert.Multiple(() => + { + Assert.That(subdomain.StatisticLanguage, Is.Null); + Assert.That(subdomain.RawStatisticLanguage, Is.EqualTo("fr")); + }); + } + + [Test] + public void MissingFields_ReadAsNullOrFalse() + { + var subdomain = Subdomain.FromMap(new Dictionary()); + + Assert.Multiple(() => + { + Assert.That(subdomain.SubdomainName, Is.Null); + Assert.That(subdomain.SubdomainPath, Is.Null); + Assert.That(subdomain.Redirect, Is.Null); + Assert.That(subdomain.StatisticVersion, Is.Null); + Assert.That(subdomain.StatisticLanguage, Is.Null); + Assert.That(subdomain.PhpVersion, Is.Null); + Assert.That(subdomain.IsActive, Is.False); + Assert.That(subdomain.InProgress, Is.False); + }); + } + + [Test] + public void Raw_IsPreservedVerbatim() + { + var map = new Dictionary { ["some_future_field"] = "value" }; + var subdomain = Subdomain.FromMap(map); + + Assert.That(subdomain.Raw["some_future_field"], Is.EqualTo("value")); + } + + [Test] + public void ExtractSubdomainName_ScalarReturnInfo_ReturnsName() + { + var response = new KasResponse { ReturnInfo = "shop.example.com" }; + Assert.That(KasClient.ExtractSubdomainName(response), Is.EqualTo("shop.example.com")); + } + + [Test] + public void ExtractSubdomainName_MapWithSubdomainName_ReturnsName() + { + var response = new KasResponse + { + ReturnInfo = new Dictionary { ["subdomain_name"] = "shop.example.com" }, + }; + Assert.That(KasClient.ExtractSubdomainName(response), Is.EqualTo("shop.example.com")); + } + + [Test] + public void ExtractSubdomainName_NullReturnInfo_Throws() + { + var response = new KasResponse { ReturnInfo = null }; + Assert.Throws(() => KasClient.ExtractSubdomainName(response)); + } + + [Test] + public void ExtractSubdomainName_WhitespaceScalar_Throws() + { + var response = new KasResponse { ReturnInfo = " " }; + Assert.Throws(() => KasClient.ExtractSubdomainName(response)); + } + + [Test] + public void ExtractSubdomainName_MapWithoutSubdomainName_Throws() + { + var response = new KasResponse + { + ReturnInfo = new Dictionary { ["something_else"] = "x" }, + }; + Assert.Throws(() => KasClient.ExtractSubdomainName(response)); + } + + [Test] + public void ExtractSubdomainName_MapWithBlankSubdomainName_Throws() + { + var response = new KasResponse + { + ReturnInfo = new Dictionary { ["subdomain_name"] = " " }, + }; + Assert.Throws(() => KasClient.ExtractSubdomainName(response)); + } +} diff --git a/tests/KASserver.Client.Tests/UpdateSubdomainTests.cs b/tests/KASserver.Client.Tests/UpdateSubdomainTests.cs new file mode 100644 index 0000000..8e1ed1c --- /dev/null +++ b/tests/KASserver.Client.Tests/UpdateSubdomainTests.cs @@ -0,0 +1,96 @@ +using KASserver; + +namespace KASserver.Client.Tests; + +public class UpdateSubdomainTests +{ + [Test] + public void ToParameters_SendsSubdomainNameAndSetFields() + { + var parameters = new UpdateSubdomain + { + SubdomainPath = "http://target.tld", + Redirect = RedirectStatus.Found, + PhpVersion = "8.2", + }.ToParameters("shop.example.com"); + + Assert.Multiple(() => + { + Assert.That(parameters["subdomain_name"], Is.EqualTo("shop.example.com")); + Assert.That(parameters["subdomain_path"], Is.EqualTo("http://target.tld")); + Assert.That(parameters["redirect_status"], Is.EqualTo("302")); + Assert.That(parameters["php_version"], Is.EqualTo("8.2")); + }); + } + + [Test] + public void ToParameters_OmitsUnsetFields() + { + var parameters = new UpdateSubdomain { PhpVersion = "8.2" }.ToParameters("shop.example.com"); + + Assert.Multiple(() => + { + Assert.That(parameters.ContainsKey("subdomain_path"), Is.False); + Assert.That(parameters.ContainsKey("redirect_status"), Is.False); + Assert.That(parameters["php_version"], Is.EqualTo("8.2")); + }); + } + + [Test] + public void ToParameters_Redirect_MapsToKasValue() + { + var parameters = new UpdateSubdomain { Redirect = RedirectStatus.TemporaryRedirect } + .ToParameters("shop.example.com"); + + Assert.That(parameters["redirect_status"], Is.EqualTo("307")); + } + + // update_subdomain only accepts subdomain_path/redirect_status/php_version per the official phpdoc; + // the webalizer statistics fields are add-only and must never leak into an update payload. + [Test] + public void ToParameters_NeverSendsStatisticFields() + { + var parameters = new UpdateSubdomain + { + SubdomainPath = "/x/", + Redirect = RedirectStatus.MovedPermanently, + PhpVersion = "8.3", + }.ToParameters("shop.example.com"); + + Assert.Multiple(() => + { + Assert.That(parameters.ContainsKey("statistic_version"), Is.False); + Assert.That(parameters.ContainsKey("statistic_language"), Is.False); + }); + } + + [Test] + public void ToParameters_NoFieldsSet_Throws() + { + Assert.Throws(() => new UpdateSubdomain().ToParameters("shop.example.com")); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + public void ToParameters_BlankSubdomainName_Throws(string name) + { + Assert.Throws(() => new UpdateSubdomain { PhpVersion = "8.2" }.ToParameters(name)); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + public void ToParameters_BlankSubdomainPath_Throws(string path) + { + Assert.Throws(() => new UpdateSubdomain { SubdomainPath = path }.ToParameters("shop.example.com")); + } + + [Test] + [TestCase("")] + [TestCase(" ")] + public void ToParameters_BlankPhpVersion_Throws(string version) + { + Assert.Throws(() => new UpdateSubdomain { PhpVersion = version }.ToParameters("shop.example.com")); + } +}