Compare commits
15
Commits
f41d12b7fd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e72c4bb83 | ||
|
|
d3a2794293 | ||
|
|
b6017e1222 | ||
|
|
2037d0de25 | ||
|
|
28aefc6822 | ||
|
|
c7eecdb451 | ||
|
|
8ab71a0e87 | ||
|
|
deb9930ce9 | ||
|
|
3618883192 | ||
|
|
b186c1e1ca | ||
|
|
716d79739c | ||
|
|
e3b4194653 | ||
|
|
29ba185c56 | ||
|
|
e6891a72ea | ||
|
|
7ad14767b6 |
No files matched your search
@@ -34,6 +34,9 @@ insert_final_newline = false
|
|||||||
#### .NET Coding Conventions ####
|
#### .NET Coding Conventions ####
|
||||||
[*.{cs,vb}]
|
[*.{cs,vb}]
|
||||||
|
|
||||||
|
# Analyzer settings
|
||||||
|
dotnet_diagnostic.CA1822.severity = none
|
||||||
|
|
||||||
# Organize usings
|
# Organize usings
|
||||||
dotnet_separate_import_directive_groups = true
|
dotnet_separate_import_directive_groups = true
|
||||||
dotnet_sort_system_directives_first = true
|
dotnet_sort_system_directives_first = true
|
||||||
|
|||||||
+21
-7
@@ -1,22 +1,36 @@
|
|||||||
Write-Host "Starting test execution..." -ForegroundColor Magenta
|
$RepoRoot = Resolve-Path (Join-Path -Path $PSScriptRoot -ChildPath "..")
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
dotnet test
|
Push-Location -Path $RepoRoot
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Host "Starting test execution..." -ForegroundColor Magenta
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
$TestExitCode = $LASTEXITCODE
|
$TestResultsPath = Join-Path -Path $RepoRoot -ChildPath "TestResults"
|
||||||
|
|
||||||
if ($TestExitCode -eq 0) {
|
if (Test-Path $TestResultsPath) {
|
||||||
|
Remove-Item -Path $TestResultsPath -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
dotnet test
|
||||||
|
|
||||||
|
$TestExitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
if ($TestExitCode -eq 0) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Tests passed. Generating code coverage HTML report..." -ForegroundColor Magenta
|
Write-Host "Tests passed. Generating code coverage HTML report..." -ForegroundColor Magenta
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
reportgenerator -reports:"**/*.cobertura.xml" -targetdir:"CoverageReport"
|
reportgenerator -reports:"**/*.cobertura.xml" -targetdir:"CoverageReport" -filefilters:"-*.g.cs"
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "SUCCESS: All tests passed and coverage report generated." -ForegroundColor Green
|
Write-Host "SUCCESS: All tests passed and coverage report generated." -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "FAILURE: Tests failed with exit code $TestExitCode. Skipping coverage report generation." -ForegroundColor Red
|
Write-Host "FAILURE: Tests failed with exit code $TestExitCode. Skipping coverage report generation." -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|
||||||
exit $TestExitCode
|
exit $TestExitCode
|
||||||
@@ -7,5 +7,6 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageVersion>
|
</PackageVersion>
|
||||||
|
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public class DidDocumentNotFoundException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public DidDocumentNotFoundException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public DidDocumentNotFoundException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public DidDocumentNotFoundException(string message, Exception innerException) : base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Exceptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public class DidNotFoundException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public DidNotFoundException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public DidNotFoundException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public DidNotFoundException(string message, Exception innerException) : base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace StevanFreeborn.Atproto.IdentityResolver;
|
|
||||||
|
|
||||||
internal class Greeter
|
|
||||||
{
|
|
||||||
public static string SayHello()
|
|
||||||
{
|
|
||||||
return "Hi!";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public interface IResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
Task<DidDocument> ResolveOrThrowAsync(AtprotoId atprotoId, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an identifier for the atproto network.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AtprotoId
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the string representation of the underlying id.
|
||||||
|
/// </summary>
|
||||||
|
public string Value { get; protected set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructs a strongly typed representation of the given id.
|
||||||
|
/// </summary>
|
||||||
|
public static AtprotoId From(string atprotoId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(atprotoId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Cannot be null or empty", nameof(atprotoId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsDomain(atprotoId))
|
||||||
|
{
|
||||||
|
return Handle.From(atprotoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Did.From(atprotoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDomain(string did)
|
||||||
|
{
|
||||||
|
var hostType = Uri.CheckHostName(did);
|
||||||
|
return hostType == UriHostNameType.Dns && did.Contains('.', StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a did on the atproto network.
|
||||||
|
/// </summary>
|
||||||
|
public class Did : AtprotoId
|
||||||
|
{
|
||||||
|
private static readonly string[] ValidPrefixes = ["did:web:", "did:plc:"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the method associated with the did.
|
||||||
|
/// </summary>
|
||||||
|
public string Method { get; private set; }
|
||||||
|
|
||||||
|
private Did(string did)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(did))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Cannot be null or empty", nameof(did));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryParseWithoutPrefix(did, out var prefix, out var didWithoutPrefix) is false)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"Must be did with one of these methods: {string.Join(',', ValidPrefixes)}", nameof(did));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(didWithoutPrefix))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Must not be null or empty without did method prefix", nameof(did));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method should probably be a strongly
|
||||||
|
// typed object
|
||||||
|
Method = prefix.Contains("web", StringComparison.OrdinalIgnoreCase) ? "web" : "plc";
|
||||||
|
Value = did;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructs a strongly-typed representation of the did.
|
||||||
|
/// </summary>
|
||||||
|
public static new Did From(string did)
|
||||||
|
{
|
||||||
|
return new(did);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseWithoutPrefix(
|
||||||
|
string did,
|
||||||
|
out string prefix,
|
||||||
|
out string didWithoutPrefix
|
||||||
|
)
|
||||||
|
{
|
||||||
|
prefix = string.Empty;
|
||||||
|
didWithoutPrefix = string.Empty;
|
||||||
|
|
||||||
|
foreach (var validPrefix in ValidPrefixes)
|
||||||
|
{
|
||||||
|
if (did.StartsWith(validPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
prefix = did[..validPrefix.Length];
|
||||||
|
didWithoutPrefix = did[validPrefix.Length..];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a handle for an id on the atproto network.
|
||||||
|
/// </summary>
|
||||||
|
public class Handle : AtprotoId
|
||||||
|
{
|
||||||
|
private Handle(string handle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(handle))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Cannot be null or empty", nameof(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handle.StartsWith('@'))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Cannot start with @", nameof(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
Value = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructs a strongly-typed representation of the handle.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="handle">The handle for the id you'd like to represent</param>
|
||||||
|
/// <returns>An instance of a <see cref="Handle"/></returns>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// Thrown when <paramref name="handle"></paramref> is null or empty.
|
||||||
|
/// Thrown when <paramref name="handle"></paramref> handle starts with '@'.
|
||||||
|
/// </exception>
|
||||||
|
public static new Handle From(string handle)
|
||||||
|
{
|
||||||
|
return new(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Json;
|
||||||
|
|
||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Identity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DidDocument
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("@context")]
|
||||||
|
[JsonConverter(typeof(StringToCollectionConverter))]
|
||||||
|
public Collection<string> Context { get; private set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("alsoKnownAs")]
|
||||||
|
public Collection<string> AlsoKnownAs { get; private set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("verificationMethod")]
|
||||||
|
public Collection<VerificationMethod> VerificatonMethod { get; private set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("service")]
|
||||||
|
public Collection<Service> Service { get; private set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public class VerificationMethod
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("controller")]
|
||||||
|
public string Controller { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("publicKeyMultiBase")]
|
||||||
|
public string PublicKeyMultiBase { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public class Service
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("serviceEndpoint")]
|
||||||
|
public string ServiceEndpoint { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Identity;
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Retrieval;
|
||||||
|
|
||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Json;
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(WriteIndented = true)]
|
||||||
|
[JsonSerializable(typeof(DnsQueryResponse))]
|
||||||
|
[JsonSerializable(typeof(Answer))]
|
||||||
|
[JsonSerializable(typeof(DidDocument))]
|
||||||
|
[JsonSerializable(typeof(VerificationMethod))]
|
||||||
|
[JsonSerializable(typeof(Service))]
|
||||||
|
internal partial class JsonContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Json;
|
||||||
|
|
||||||
|
internal sealed class StringToCollectionConverter : JsonConverter<Collection<string>>
|
||||||
|
{
|
||||||
|
public override Collection<string>? Read(
|
||||||
|
ref Utf8JsonReader reader,
|
||||||
|
Type typeToConvert,
|
||||||
|
JsonSerializerOptions options
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return reader.TokenType switch
|
||||||
|
{
|
||||||
|
JsonTokenType.Null => [],
|
||||||
|
JsonTokenType.String => [reader.GetString()!],
|
||||||
|
JsonTokenType.StartArray => JsonSerializer.Deserialize<Collection<string>>(ref reader, options),
|
||||||
|
_ => throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing {nameof(Collection<>)}.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(
|
||||||
|
Utf8JsonWriter writer,
|
||||||
|
Collection<string> value,
|
||||||
|
JsonSerializerOptions options
|
||||||
|
)
|
||||||
|
{
|
||||||
|
JsonSerializer.Serialize(writer, value, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class Resolver : IResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<DidDocument> ResolveOrThrowAsync(AtprotoId atprotoId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// you need to retrieve the did document based on what you got
|
||||||
|
// if you have domain
|
||||||
|
// you are going to try to retrieve the did
|
||||||
|
// either through using /.well-known/did.json
|
||||||
|
// or looking for _atproto.domain txt record
|
||||||
|
//
|
||||||
|
// you now for sure have a did
|
||||||
|
// check method
|
||||||
|
// if method is web
|
||||||
|
// retrieve doc from domain/.well-known/did.json
|
||||||
|
// if method is plc
|
||||||
|
// retrieve doc from plc.directory/{did}
|
||||||
|
// you now have the document
|
||||||
|
// extract alsoKnownAs which is the handle
|
||||||
|
// make sure you can get back the same did
|
||||||
|
// from the handle's domain at /.well-known/did.json
|
||||||
|
// or _atproto.domain txt record
|
||||||
|
|
||||||
|
return Task.FromResult(new DidDocument());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public class DidDocument();
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Exceptions;
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Identity;
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Json;
|
||||||
|
|
||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public class DefaultDidDocumentRetriever(HttpClient httpClient) : IDidDocumentRetriever
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public Task<DidDocument> GetByPlcOrThrowAsync(Did did, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (did is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(did));
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestUri = new Uri($"https://plc.directory/{did.Value}", UriKind.Absolute);
|
||||||
|
return GetOrThrowAsync(requestUri, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public Task<DidDocument> GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (handle is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestUri = new Uri($"https://{handle.Value}/.well-known/did.json", UriKind.Absolute);
|
||||||
|
return GetOrThrowAsync(requestUri, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DidDocument> GetOrThrowAsync(Uri requestUri, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await httpClient.GetAsync(requestUri, ct).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
var didDocument = await JsonSerializer
|
||||||
|
.DeserializeAsync(body, JsonContext.Default.DidDocument, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return didDocument ?? throw new InvalidOperationException("Deserializing response body resulted in null value.");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new DidDocumentNotFoundException("Unable to retrieve did document. See inner exception", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Exceptions;
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Json;
|
||||||
|
|
||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public class DefaultDidRetriever(HttpClient httpClient) : IDidRetriever
|
||||||
|
{
|
||||||
|
private static readonly MediaTypeWithQualityHeaderValue DnsJsonHeader = new("application/dns-json");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Did> GetByDnsOrThrowAsync(Handle handle, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (handle is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestUri = new Uri($"https://cloudflare-dns.com/dns-query?name=_atproto.{handle.Value}&type=TXT");
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
request.Headers.Accept.Add(DnsJsonHeader);
|
||||||
|
|
||||||
|
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var body = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||||
|
var dnsResponse = await JsonSerializer.DeserializeAsync(body, JsonContext.Default.DnsQueryResponse, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var didString = ExtractDidFromDnsResponse(dnsResponse);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(didString))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"No valid atproto DID TXT record found for handle '{handle.Value}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Did.From(didString);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new DidNotFoundException("Unable to get did. See inner exception", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Did> GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (handle is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestUri = new Uri($"https://{handle.Value}/.well-known/atproto-did");
|
||||||
|
|
||||||
|
using var response = await httpClient.GetAsync(requestUri, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var rawDid = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||||
|
var trimmedDid = rawDid?.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmedDid))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Endpoint '{requestUri}' returned an empty DID response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Did.From(trimmedDid);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new DidNotFoundException("Unable to retrieve did. See inner exception", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractDidFromDnsResponse(DnsQueryResponse? response)
|
||||||
|
{
|
||||||
|
if (response?.Answer is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var answer in response.Answer)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(answer.Data))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloudflare wraps TXT records in quotes (e.g., "\"did=did:plc:...\"")
|
||||||
|
var text = answer.Data.Trim('\"');
|
||||||
|
|
||||||
|
if (text.StartsWith("did=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return text.Substring(4).Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class DnsQueryResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Answer")]
|
||||||
|
public List<Answer> Answer { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Answer
|
||||||
|
{
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public string Data { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Identity;
|
||||||
|
|
||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public interface IDidDocumentRetriever
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
Task<DidDocument> GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
Task<DidDocument> GetByPlcOrThrowAsync(Did did, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Retrieval;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public interface IDidRetriever
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
Task<Did> GetByWellKnownOrThrowAsync(Handle handle, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
Task<Did> GetByDnsOrThrowAsync(Handle handle, CancellationToken ct);
|
||||||
|
}
|
||||||
+3
-1
@@ -1,9 +1,10 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFrameworks>netstandard2.1;net10.0</TargetFrameworks>
|
<TargetFrameworks>netstandard2.1</TargetFrameworks>
|
||||||
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
|
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsAotCompatible>
|
||||||
<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>
|
<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PackageId>StevanFreeborn.Atproto.IdentityResolver</PackageId>
|
<PackageId>StevanFreeborn.Atproto.IdentityResolver</PackageId>
|
||||||
@@ -33,5 +34,6 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="System.Text.Json" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
[*.cs]
|
[*.cs]
|
||||||
dotnet_diagnostic.CA1707.severity = none
|
dotnet_diagnostic.CA1707.severity = none
|
||||||
|
dotnet_diagnostic.CA2007.severity = none
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="AwesomeAssertions" Version="9.6.0" />
|
<PackageVersion Include="AwesomeAssertions" Version="9.6.0" />
|
||||||
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
|
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.10.0" />
|
||||||
|
<PackageVersion Include="RichardSzalay.MockHttp" Version="7.1.0" />
|
||||||
<PackageVersion Include="xunit.v3.mtp-v2" Version="4.0.0" />
|
<PackageVersion Include="xunit.v3.mtp-v2" Version="4.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Tests;
|
||||||
|
|
||||||
|
public class AtprotoIdTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("localhost")]
|
||||||
|
[InlineData("did:web:")]
|
||||||
|
[InlineData("did:plc:")]
|
||||||
|
[InlineData("did:mu:dxbmien5xsrkov5yhywgc4zu")]
|
||||||
|
[InlineData("@stevanfreeborn.com")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void From_WhenCalledWithInvalidDid_ItShouldThrowInvalidArgumentException(string? atprotoId)
|
||||||
|
{
|
||||||
|
var act = () => AtprotoId.From(atprotoId!);
|
||||||
|
|
||||||
|
act.Should().Throw<ArgumentException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace StevanFreeborn.Atproto.IdentityResolver.Tests;
|
|
||||||
|
|
||||||
public class GreeterTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void SayHello_WhenCalled_ItShouldReturnHi()
|
|
||||||
{
|
|
||||||
Greeter.SayHello().Should().Be("Hi!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Tests;
|
||||||
|
|
||||||
|
public class ResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_WhenCalledWithInvalidDid_ItShouldThrowInvalidArgumentException()
|
||||||
|
{
|
||||||
|
true.Should().BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -17,6 +17,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AwesomeAssertions" />
|
<PackageReference Include="AwesomeAssertions" />
|
||||||
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
|
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
|
||||||
|
<PackageReference Include="RichardSzalay.MockHttp" />
|
||||||
<PackageReference Include="xunit.v3.mtp-v2" />
|
<PackageReference Include="xunit.v3.mtp-v2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
using RichardSzalay.MockHttp;
|
||||||
|
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Retrieval;
|
||||||
|
|
||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit;
|
||||||
|
|
||||||
|
public sealed class DefaultDidDocumentRetrieverTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly MockHttpMessageHandler _mockHttpMessageHandler = new();
|
||||||
|
private readonly DefaultDidDocumentRetriever _sut;
|
||||||
|
|
||||||
|
public DefaultDidDocumentRetrieverTests()
|
||||||
|
{
|
||||||
|
_sut = new(_mockHttpMessageHandler.ToHttpClient());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_mockHttpMessageHandler.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByWellKnownOrThrowAsync_WhenResponse()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Exceptions;
|
||||||
|
|
||||||
|
using RichardSzalay.MockHttp;
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Retrieval;
|
||||||
|
|
||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit;
|
||||||
|
|
||||||
|
public sealed class DefaultDidRetrieverTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly MockHttpMessageHandler _mockHttpMessageHandler = new();
|
||||||
|
private readonly DefaultDidRetriever _sut;
|
||||||
|
|
||||||
|
public DefaultDidRetrieverTests()
|
||||||
|
{
|
||||||
|
_sut = new(_mockHttpMessageHandler.ToHttpClient());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_mockHttpMessageHandler.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByDnsOrThrowAsync_WhenHandleIsNull_ItShouldThrowArgumentNullException()
|
||||||
|
{
|
||||||
|
var act = async () => await _sut.GetByDnsOrThrowAsync(null!, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var e = await act.Should().ThrowAsync<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByDnsOrThrowAsync_WhenResponseIsNotSuccessful_ItShouldThrowDidNotFoundException()
|
||||||
|
{
|
||||||
|
var handle = "stevan.cc";
|
||||||
|
|
||||||
|
_mockHttpMessageHandler
|
||||||
|
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
|
||||||
|
.Respond(HttpStatusCode.InternalServerError);
|
||||||
|
|
||||||
|
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<DidNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButDeserialziationFails_ItShouldThrowDidNotFoundException()
|
||||||
|
{
|
||||||
|
var handle = "stevan.cc";
|
||||||
|
|
||||||
|
_mockHttpMessageHandler
|
||||||
|
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
|
||||||
|
.Respond("application/json", JsonSerializer.Serialize("null"));
|
||||||
|
|
||||||
|
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var e = await act.Should().ThrowAsync<DidNotFoundException>();
|
||||||
|
e.WithInnerException<JsonException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButBodyIsNull_ItShouldThrowDidNotFoundException()
|
||||||
|
{
|
||||||
|
var handle = "stevan.cc";
|
||||||
|
|
||||||
|
_mockHttpMessageHandler
|
||||||
|
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
|
||||||
|
.Respond("application/json", "null");
|
||||||
|
|
||||||
|
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<DidNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButNoAnswers_ItShouldThrowDidNotFoundException()
|
||||||
|
{
|
||||||
|
var handle = "stevan.cc";
|
||||||
|
var response = new DnsQueryResponse();
|
||||||
|
|
||||||
|
_mockHttpMessageHandler
|
||||||
|
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
|
||||||
|
.Respond("application/json", JsonSerializer.Serialize(response));
|
||||||
|
|
||||||
|
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<DidNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulButTextRecordValueFormattedIncorrectly_ItShouldThrowDidNotFoundException()
|
||||||
|
{
|
||||||
|
var handle = "stevan.cc";
|
||||||
|
var response = new DnsQueryResponse()
|
||||||
|
{
|
||||||
|
Answer = [new() { Data = $"did:web:{handle}" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockHttpMessageHandler
|
||||||
|
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
|
||||||
|
.Respond("application/json", JsonSerializer.Serialize(response));
|
||||||
|
|
||||||
|
var act = async () => await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<DidNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByDnsOrThrowAsync_WhenResponseIsSuccessfulAndTextRecordValueFormattedCorrectly_ItShouldReturnDid()
|
||||||
|
{
|
||||||
|
var handle = "stevan.cc";
|
||||||
|
var response = new DnsQueryResponse()
|
||||||
|
{
|
||||||
|
Answer = [new() { Data = $@"did=did:web:{handle}" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockHttpMessageHandler
|
||||||
|
.When(HttpMethod.Get, $"https://cloudflare-dns.com/dns-query?name=_atproto.{handle}&type=TXT")
|
||||||
|
.Respond("application/json", JsonSerializer.Serialize(response));
|
||||||
|
|
||||||
|
var result = await _sut.GetByDnsOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
result.Should().BeEquivalentTo(Did.From($"did:web:{handle}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByWellKnownOrThrowAsync_WhenResponseIsUnsuccessful_ItShouldThrowDidNotFoundException()
|
||||||
|
{
|
||||||
|
var handle = "stevan.cc";
|
||||||
|
var did = $"did:web:{handle}";
|
||||||
|
|
||||||
|
_mockHttpMessageHandler
|
||||||
|
.When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did")
|
||||||
|
.Respond(HttpStatusCode.InternalServerError);
|
||||||
|
|
||||||
|
var act = async () => await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<DidNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByWellKnownOrThrowAsync_WhenResponseIsSuccessfulAndButDoesNotContainDid_ItShouldThrowDidNotFoundException()
|
||||||
|
{
|
||||||
|
var handle = "stevan.cc";
|
||||||
|
var did = $"did:web:{handle}";
|
||||||
|
|
||||||
|
_mockHttpMessageHandler
|
||||||
|
.When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did")
|
||||||
|
.Respond("application/json", JsonSerializer.Serialize(new { did }));
|
||||||
|
|
||||||
|
var act = async () => await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<DidNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByWellKnownOrThrowAsync_WhenResponseIsSuccessfulAndContainsDid_ItShouldReturnDid()
|
||||||
|
{
|
||||||
|
var handle = "stevan.cc";
|
||||||
|
var did = $"did:web:{handle}";
|
||||||
|
|
||||||
|
_mockHttpMessageHandler
|
||||||
|
.When(HttpMethod.Get, $"https://{handle}/.well-known/atproto-did")
|
||||||
|
.Respond("text/plain", did);
|
||||||
|
|
||||||
|
var result = await _sut.GetByWellKnownOrThrowAsync(Handle.From(handle), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
result.Should().BeEquivalentTo(Did.From(did));
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using StevanFreeborn.Atproto.IdentityResolver.Json;
|
||||||
|
|
||||||
|
namespace StevanFreeborn.Atproto.IdentityResolver.Tests.Unit;
|
||||||
|
|
||||||
|
public sealed class StringToCollectionConverterTests
|
||||||
|
{
|
||||||
|
private readonly StringToCollectionConverter _converter = new();
|
||||||
|
private readonly JsonSerializerOptions _options = new();
|
||||||
|
|
||||||
|
private Collection<string>? ReadJson(string json)
|
||||||
|
{
|
||||||
|
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json));
|
||||||
|
reader.Read();
|
||||||
|
return _converter.Read(ref reader, typeof(Collection<string>), _options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string WriteJson(Collection<string> value)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using var writer = new Utf8JsonWriter(stream);
|
||||||
|
|
||||||
|
_converter.Write(writer, value, _options);
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(stream.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_WhenJsonTokenIsNull_ItShouldReturnEmptyCollection()
|
||||||
|
{
|
||||||
|
var result = ReadJson("null");
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_WhenJsonTokenIsString_ItShouldReturnCollectionWithSingleString()
|
||||||
|
{
|
||||||
|
var result = ReadJson("\"https://w3id.org/did/v1\"");
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Should().ContainSingle().Which.Should().Be("https://w3id.org/did/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_WhenJsonTokenIsEmptyString_ItShouldReturnCollectionWithEmptyString()
|
||||||
|
{
|
||||||
|
var result = ReadJson("\"\"");
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Should().ContainSingle().Which.Should().Be(string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_WhenJsonTokenIsArrayOfStrings_ItShouldReturnCollectionWithAllStrings()
|
||||||
|
{
|
||||||
|
var result = ReadJson("[\"https://w3id.org/did/v1\",\"https://w3id.org/security/suites/ed25519-2020/v1\"]");
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Should().HaveCount(2);
|
||||||
|
result.Should().ContainInOrder("https://w3id.org/did/v1", "https://w3id.org/security/suites/ed25519-2020/v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_WhenJsonTokenIsEmptyArray_ItShouldReturnEmptyCollection()
|
||||||
|
{
|
||||||
|
var result = ReadJson("[]");
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("123", "Number")]
|
||||||
|
[InlineData("true", "True")]
|
||||||
|
[InlineData("false", "False")]
|
||||||
|
[InlineData("{\"key\":\"value\"}", "StartObject")]
|
||||||
|
public void Read_WhenJsonTokenIsUnexpectedType_ItShouldThrowJsonException(string json, string expectedToken)
|
||||||
|
{
|
||||||
|
var act = () => ReadJson(json);
|
||||||
|
|
||||||
|
var e = act.Should().Throw<JsonException>();
|
||||||
|
|
||||||
|
e.WithMessage($"Unexpected token type '{expectedToken}' when deserializing Collection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_WhenCollectionContainsMultipleStrings_ItShouldSerializeAsJsonArray()
|
||||||
|
{
|
||||||
|
var collection = new Collection<string>
|
||||||
|
{
|
||||||
|
"https://w3id.org/did/v1",
|
||||||
|
"https://w3id.org/security/suites/ed25519-2020/v1"
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = WriteJson(collection);
|
||||||
|
|
||||||
|
json.Should().Be("[\"https://w3id.org/did/v1\",\"https://w3id.org/security/suites/ed25519-2020/v1\"]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_WhenCollectionContainsSingleString_ItShouldSerializeAsJsonArray()
|
||||||
|
{
|
||||||
|
var collection = new Collection<string>
|
||||||
|
{
|
||||||
|
"https://w3id.org/did/v1"
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = WriteJson(collection);
|
||||||
|
|
||||||
|
json.Should().Be("[\"https://w3id.org/did/v1\"]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_WhenCollectionIsEmpty_ItShouldSerializeAsEmptyJsonArray()
|
||||||
|
{
|
||||||
|
var collection = new Collection<string>();
|
||||||
|
|
||||||
|
var json = WriteJson(collection);
|
||||||
|
|
||||||
|
json.Should().Be("[]");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user