Merge branch 'feat/adapter-gitea' into feat/skeleton
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Wiki.Contracts.Gitea;
|
||||
|
||||
/// <summary>
|
||||
/// A minimal Gitea API double, in front of an in-memory copy of <see cref="VaultFixture.Files"/>.
|
||||
/// Reproduces the quirks the real Gitea note-store adapter has to tolerate: the tree listing pages
|
||||
/// with "tree": null past the last page, and may report truncated=true even on a page that turns
|
||||
/// out to be the last one with data. Blob shas are a SHA-1 hex of the file's content, so they are
|
||||
/// stable across repeated calls and distinct per distinct content, exactly what the note-store
|
||||
/// contract requires.
|
||||
/// </summary>
|
||||
public sealed class FakeGiteaHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<string, string> _files;
|
||||
private readonly string _repo;
|
||||
private readonly string _branch;
|
||||
private readonly string _commit;
|
||||
private readonly int _pageSize;
|
||||
|
||||
/// <summary>Every file created via POST .../contents/{path}, path -> decoded text, in call order.</summary>
|
||||
public List<(string Path, string Content, string Branch, string Message)> Created { get; } = [];
|
||||
|
||||
public FakeGiteaHandler(string repository = "k.renner/notes", string branch = "main", int pageSize = 3)
|
||||
{
|
||||
_repo = repository;
|
||||
_branch = branch;
|
||||
_commit = VaultFixture.Commit;
|
||||
_pageSize = pageSize;
|
||||
_files = new Dictionary<string, string>(VaultFixture.Files);
|
||||
}
|
||||
|
||||
/// <summary>Adds a file to the fake as if it already existed at HEAD, e.g. to force the
|
||||
/// fact sink into its -2 suffix path.</summary>
|
||||
public void SeedFile(string path, string content) => _files[path] = content;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
var path = request.RequestUri!.AbsolutePath;
|
||||
var prefix = $"/api/v1/repos/{_repo}/";
|
||||
if (!path.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return Task.FromResult(NotFound());
|
||||
|
||||
var rest = path[prefix.Length..];
|
||||
|
||||
if (request.Method == HttpMethod.Get && rest == $"branches/{_branch}")
|
||||
return Task.FromResult(Json(new JsonObject
|
||||
{
|
||||
["commit"] = new JsonObject
|
||||
{
|
||||
["id"] = _commit,
|
||||
["timestamp"] = "2026-09-20T12:00:00Z",
|
||||
},
|
||||
}));
|
||||
|
||||
if (request.Method == HttpMethod.Get && rest.StartsWith($"git/trees/{_commit}", StringComparison.Ordinal))
|
||||
return Task.FromResult(HandleTree(request.RequestUri));
|
||||
|
||||
if (request.Method == HttpMethod.Get && rest.StartsWith("raw/", StringComparison.Ordinal))
|
||||
return Task.FromResult(HandleRaw(rest["raw/".Length..]));
|
||||
|
||||
if (request.Method == HttpMethod.Get && rest.StartsWith("contents/", StringComparison.Ordinal))
|
||||
return Task.FromResult(HandleContentsExists(rest["contents/".Length..]));
|
||||
|
||||
if (request.Method == HttpMethod.Post && rest.StartsWith("contents/", StringComparison.Ordinal))
|
||||
return HandleContentsCreateAsync(rest["contents/".Length..], request, ct);
|
||||
|
||||
return Task.FromResult(NotFound());
|
||||
}
|
||||
|
||||
private HttpResponseMessage HandleTree(Uri requestUri)
|
||||
{
|
||||
var page = QueryInt(requestUri, "page", 1);
|
||||
var entries = BuildEntries();
|
||||
var start = (page - 1) * _pageSize;
|
||||
|
||||
var body = new JsonObject
|
||||
{
|
||||
["sha"] = _commit,
|
||||
["total_count"] = entries.Count,
|
||||
};
|
||||
|
||||
if (start >= entries.Count)
|
||||
{
|
||||
// Gitea answers "tree": null once you page past the end.
|
||||
body["tree"] = null;
|
||||
return Json(body);
|
||||
}
|
||||
|
||||
var slice = entries.Skip(start).Take(_pageSize).ToList();
|
||||
var tree = new JsonArray();
|
||||
foreach (var e in slice)
|
||||
{
|
||||
tree.Add(new JsonObject
|
||||
{
|
||||
["path"] = e.Path,
|
||||
["type"] = e.Type,
|
||||
["sha"] = e.Sha,
|
||||
});
|
||||
}
|
||||
body["tree"] = tree;
|
||||
// Gitea sets this even when this page happens to be the last one with data; callers must
|
||||
// not use it to decide whether to stop.
|
||||
body["truncated"] = true;
|
||||
return Json(body);
|
||||
}
|
||||
|
||||
private HttpResponseMessage HandleRaw(string encodedPathAndQuery)
|
||||
{
|
||||
var filePath = StripQuery(encodedPathAndQuery);
|
||||
return _files.TryGetValue(filePath, out var content)
|
||||
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(content, Encoding.UTF8, "text/plain") }
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
private HttpResponseMessage HandleContentsExists(string encodedPathAndQuery)
|
||||
{
|
||||
var filePath = StripQuery(encodedPathAndQuery);
|
||||
return _files.ContainsKey(filePath)
|
||||
? Json(new JsonObject { ["path"] = filePath })
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> HandleContentsCreateAsync(string encodedPathAndQuery, HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
var filePath = StripQuery(encodedPathAndQuery);
|
||||
var payload = await request.Content!.ReadFromJsonAsync<JsonObject>(ct).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("empty create-file request body");
|
||||
var base64 = payload["content"]!.GetValue<string>();
|
||||
var branch = payload["branch"]?.GetValue<string>() ?? _branch;
|
||||
var message = payload["message"]?.GetValue<string>() ?? "";
|
||||
var text = Encoding.UTF8.GetString(Convert.FromBase64String(base64));
|
||||
|
||||
if (_files.ContainsKey(filePath))
|
||||
return NotFound(HttpStatusCode.UnprocessableEntity, $"{filePath} already exists");
|
||||
|
||||
_files[filePath] = text;
|
||||
Created.Add((filePath, text, branch, message));
|
||||
|
||||
return Json(new JsonObject
|
||||
{
|
||||
["commit"] = new JsonObject { ["sha"] = Sha1Hex(text) },
|
||||
});
|
||||
}
|
||||
|
||||
private static string StripQuery(string encodedPathAndQuery)
|
||||
{
|
||||
var raw = encodedPathAndQuery.Contains('?') ? encodedPathAndQuery[..encodedPathAndQuery.IndexOf('?')] : encodedPathAndQuery;
|
||||
return Uri.UnescapeDataString(raw);
|
||||
}
|
||||
|
||||
private static int QueryInt(Uri uri, string key, int fallback)
|
||||
{
|
||||
var query = uri.Query.TrimStart('?');
|
||||
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var kv = pair.Split('=', 2);
|
||||
if (kv.Length == 2 && kv[0] == key && int.TryParse(Uri.UnescapeDataString(kv[1]), out var value))
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private List<(string Path, string Type, string Sha)> BuildEntries()
|
||||
{
|
||||
var dirs = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var filePath in _files.Keys)
|
||||
{
|
||||
var slash = filePath.LastIndexOf('/');
|
||||
while (slash > 0)
|
||||
{
|
||||
dirs.Add(filePath[..slash]);
|
||||
slash = filePath.LastIndexOf('/', slash - 1);
|
||||
}
|
||||
}
|
||||
|
||||
var entries = new List<(string Path, string Type, string Sha)>();
|
||||
foreach (var dir in dirs)
|
||||
entries.Add((dir, "tree", Sha1Hex($"dir:{dir}")));
|
||||
foreach (var (filePath, content) in _files)
|
||||
entries.Add((filePath, "blob", Sha1Hex(content)));
|
||||
|
||||
return entries.OrderBy(e => e.Path, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static string Sha1Hex(string content)
|
||||
{
|
||||
var bytes = SHA1.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(JsonNode? body) =>
|
||||
new(HttpStatusCode.OK) { Content = new StringContent(body?.ToJsonString() ?? "null", Encoding.UTF8, "application/json") };
|
||||
|
||||
private static HttpResponseMessage NotFound(HttpStatusCode status = HttpStatusCode.NotFound, string message = "not found") =>
|
||||
new(status) { Content = new StringContent(JsonSerializer.Serialize(new { message }), Encoding.UTF8, "application/json") };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Wiki.Adapters.Gitea;
|
||||
using Wiki.Core.Model;
|
||||
|
||||
namespace Wiki.Contracts.Gitea;
|
||||
|
||||
public sealed class GiteaFactSinkTests
|
||||
{
|
||||
private const string Repository = "k.renner/notes";
|
||||
private const string Branch = "main";
|
||||
|
||||
private static readonly RawSource Fact = new(
|
||||
Title: "UDM intercepts DNS: a follow-up",
|
||||
Source: "https://example.com/thread#5",
|
||||
Content: "The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.",
|
||||
ObservedAt: new DateTimeOffset(2026, 9, 20, 14, 30, 0, TimeSpan.Zero),
|
||||
Tags: ["dns", "udm"]);
|
||||
|
||||
private static readonly Identity By = new("agent-x", IdentityKind.Machine);
|
||||
private static readonly DateTimeOffset Now = new(2026, 9, 23, 8, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private const string ExpectedPath = "raw/2026-09-23-udm-intercepts-dns-a-follow-up.md";
|
||||
|
||||
private static readonly string ExpectedText =
|
||||
"---\n" +
|
||||
"title: \"UDM intercepts DNS: a follow-up\"\n" +
|
||||
"type: raw\n" +
|
||||
"source: \"https://example.com/thread#5\"\n" +
|
||||
"captured: 2026-09-23\n" +
|
||||
"captured_by: agent-x\n" +
|
||||
"observed: 2026-09-20T14:30:00+00:00\n" +
|
||||
"tags: [dns, udm]\n" +
|
||||
"---\n" +
|
||||
"\n" +
|
||||
"The UDM answers every UDP/53 query itself, independent of what upstream is configured elsewhere.\n";
|
||||
|
||||
private static (FakeGiteaHandler Handler, GiteaFactSink Sink) CreateSink()
|
||||
{
|
||||
var handler = new FakeGiteaHandler(Repository, Branch, pageSize: 3);
|
||||
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://gitea.test") };
|
||||
var options = Options.Create(new GiteaOptions
|
||||
{
|
||||
BaseUrl = "https://gitea.test",
|
||||
Repository = Repository,
|
||||
Branch = Branch,
|
||||
Token = "test-token",
|
||||
});
|
||||
return (handler, new GiteaFactSink(httpClient, options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WritesTheExpectedPathAndFrontmatter()
|
||||
{
|
||||
var (handler, sink) = CreateSink();
|
||||
|
||||
var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None);
|
||||
|
||||
Assert.Equal(ExpectedPath, receipt.Path);
|
||||
Assert.Equal(Now, receipt.StoredAt);
|
||||
|
||||
var created = Assert.Single(handler.Created);
|
||||
Assert.Equal(ExpectedPath, created.Path);
|
||||
Assert.Equal(ExpectedText, created.Content);
|
||||
Assert.Equal(Branch, created.Branch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsTheCommitShaFromTheResponse()
|
||||
{
|
||||
var (_, sink) = CreateSink();
|
||||
|
||||
var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None);
|
||||
|
||||
Assert.Equal(Sha1Hex(ExpectedText), receipt.Commit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendsANumberedSuffixWhenThePathAlreadyExists()
|
||||
{
|
||||
var (handler, sink) = CreateSink();
|
||||
handler.SeedFile(ExpectedPath, "already here");
|
||||
|
||||
var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None);
|
||||
|
||||
Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md", receipt.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KeepsNumberingWhileCandidatesCollide()
|
||||
{
|
||||
var (handler, sink) = CreateSink();
|
||||
handler.SeedFile(ExpectedPath, "already here");
|
||||
handler.SeedFile("raw/2026-09-23-udm-intercepts-dns-a-follow-up-2.md", "also here");
|
||||
|
||||
var receipt = await sink.AppendAsync(Fact, By, Now, CancellationToken.None);
|
||||
|
||||
Assert.Equal("raw/2026-09-23-udm-intercepts-dns-a-follow-up-3.md", receipt.Path);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "source", "content long enough to pass the minimum length check, easily.")]
|
||||
[InlineData("title", "", "content long enough to pass the minimum length check, easily.")]
|
||||
[InlineData("title", "source", "too short")]
|
||||
public async Task RejectsAnInvalidRawSourceBeforeCallingGitea(string title, string source, string content)
|
||||
{
|
||||
var (handler, sink) = CreateSink();
|
||||
var invalid = new RawSource(title, source, content, Now, []);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => sink.AppendAsync(invalid, By, Now, CancellationToken.None));
|
||||
|
||||
Assert.Empty(handler.Created);
|
||||
}
|
||||
|
||||
private static string Sha1Hex(string content)
|
||||
{
|
||||
var bytes = SHA1.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Wiki.Adapters.Gitea;
|
||||
using Wiki.Core.Ports;
|
||||
|
||||
namespace Wiki.Contracts.Gitea;
|
||||
|
||||
public sealed class GiteaNoteStoreContractTests : NoteStoreContract
|
||||
{
|
||||
private const string Repository = "k.renner/notes";
|
||||
private const string Branch = "main";
|
||||
|
||||
protected override Task<INoteStore> CreateStoreAsync()
|
||||
{
|
||||
var handler = new FakeGiteaHandler(Repository, Branch, pageSize: 3);
|
||||
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://gitea.test") };
|
||||
var options = Options.Create(new GiteaOptions
|
||||
{
|
||||
BaseUrl = "https://gitea.test",
|
||||
Repository = Repository,
|
||||
Branch = Branch,
|
||||
Token = "test-token",
|
||||
});
|
||||
return Task.FromResult<INoteStore>(new GiteaNoteStore(httpClient, options));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user