Log
+
+@if (_notFound)
+{
+
+}
+else if (_private)
+{
+ @(English ? "Private article" : "Privater Artikel")
+
+
+ 🔒
+ @(English ? "This article is private — log in" : "Dieser Artikel ist privat — anmelden")
+
+}
+else if (_article is not null && _rendered is not null)
+{
+ @_article.Title
+
+ @_article.Title
+
+
+ @if (_infobox is not null)
+ {
+ @((MarkupString)_infobox)
+ }
+
+ @if (_rendered.Toc.Count > 0)
+ {
+
+ }
+
+ @((MarkupString)_rendered.Html)
+}
+
+@code {
+ [CascadingParameter]
+ private HttpContext HttpContext { get; set; } = default!;
+
+ [Parameter]
+ public string Lang { get; set; } = "";
+
+ [Parameter]
+ public string Slug { get; set; } = "";
+
+ [Parameter]
+ public string? Rest { get; set; }
+
+ private bool _notFound;
+ private bool _private;
+ private bool _signedIn;
+ private bool _otherExists;
+ private Article? _article;
+ private RenderedArticle? _rendered;
+ private string? _infobox;
+
+ private bool English => Lang == "en";
+ private string Other => English ? "de" : "en";
+ private string SelfPath => $"/house/{Lang}/{Slug}";
+
+ protected override async Task OnInitializedAsync()
+ {
+ var ct = HttpContext.RequestAborted;
+ if (Rest is not null || !HouseEndpoints.IsLanguage(Lang) || !HouseEndpoints.IsSlug(Slug)
+ || await Articles.GetAsync(Lang, Slug, ct) is not { } article)
+ {
+ _notFound = true;
+ HouseEndpoints.RespondNotFound(HttpContext.Response);
+ return;
+ }
+ _signedIn = HouseEndpoints.IsHuman(HttpContext.User);
+ if (!_signedIn && await Articles.IsPrivateAsync(Slug, ct))
+ {
+ _private = true;
+ return;
+ }
+ var titles = await Articles.VisibleTitlesAsync(Lang, _signedIn, ct);
+ _article = article;
+ _rendered = ArticleMarkdown.Render(article.Body, Lang, Slug, _signedIn, titles, Log);
+ _infobox = WithTitleBar(_rendered.InfoboxHtml, article.Title);
+ _otherExists = await Articles.GetAsync(Other, Slug, ct) is not null;
+ }
+
+ /// Puts a title bar first into the infobox, after its opening tag.
+ private static string? WithTitleBar(string? infoboxHtml, string title)
+ {
+ if (infoboxHtml is null) return null;
+ var open = infoboxHtml.IndexOf('>') + 1;
+ return infoboxHtml.Insert(open, $"\n{WebUtility.HtmlEncode(title)}
");
+ }
+}
diff --git a/src/Wiki.Host/Components/Shared/HouseNotFound.razor b/src/Wiki.Host/Components/Shared/HouseNotFound.razor
new file mode 100644
index 0000000..1e0a3f6
--- /dev/null
+++ b/src/Wiki.Host/Components/Shared/HouseNotFound.razor
@@ -0,0 +1,12 @@
+@* The house's own 404 body; the page sets the status. *@
+@(English ? "Article not found" : "Artikel nicht gefunden")
+
+@(English ? "Article not found" : "Artikel nicht gefunden")
+@(English ? "To the front page" : "Zur Startseite")
+
+@code {
+ [Parameter]
+ public string? Lang { get; set; }
+
+ private bool English => Lang == "en";
+}
diff --git a/src/Wiki.Host/Components/_Imports.razor b/src/Wiki.Host/Components/_Imports.razor
index 712ea26..4f41bc6 100644
--- a/src/Wiki.Host/Components/_Imports.razor
+++ b/src/Wiki.Host/Components/_Imports.razor
@@ -1,4 +1,5 @@
@using System.Net.Http
+@using Microsoft.AspNetCore.Authorization
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs
index 501832f..4f87c50 100644
--- a/src/Wiki.Host/Program.cs
+++ b/src/Wiki.Host/Program.cs
@@ -145,6 +145,8 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(new HouseMediaCache());
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
@@ -167,14 +169,17 @@ builder.Services.AddMudServices();
var app = builder.Build();
if (encyclopediaConfigWarning is not null) app.Logger.LogWarning("{Warning}", encyclopediaConfigWarning);
+// Outermost, so the headers also reach a response the exception handler rewrites.
+app.UseHouseHeaders();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}
// The not-found page is for the UI only: an API 401/404 must stay a JSON status, not be
// re-executed as a POST against a Blazor page (which fails antiforgery with an empty 400).
+// House 404s keep their own response: /not-found needs a login, which would turn them into a redirect.
app.UseWhen(
- ctx => HttpMethods.IsGet(ctx.Request.Method) && !ctx.Request.Path.StartsWithSegments("/api"),
+ ctx => HttpMethods.IsGet(ctx.Request.Method) && !ctx.Request.Path.StartsWithSegments("/api") && !ctx.Request.Path.StartsWithSegments("/house"),
branch => branch.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true));
app.UseAuthentication();
app.UseAuthorization();
@@ -183,8 +188,9 @@ app.UseAntiforgery();
app.MapHealthEndpoints();
app.MapIndexEndpoints();
app.MapApiV1();
+app.MapHouseEndpoints();
-app.MapStaticAssets();
+app.MapStaticAssets().AllowAnonymous();
app.MapRazorComponents().AddInteractiveServerRenderMode();
app.Run();
diff --git a/src/Wiki.Host/Rendering/ArticleMarkdown.cs b/src/Wiki.Host/Rendering/ArticleMarkdown.cs
index 8975771..c7bd936 100644
--- a/src/Wiki.Host/Rendering/ArticleMarkdown.cs
+++ b/src/Wiki.Host/Rendering/ArticleMarkdown.cs
@@ -28,7 +28,7 @@ public sealed record RenderedArticle(string Html, string? InfoboxHtml, IReadOnly
/// readers and is dropped for signed-in ones.
/// Raw HTML is disabled. Link targets are limited to http:, https:, mailto:, #… and
/// /house/… pages the reader may see; other links keep only their text. Link titles are removed. Images are
-/// served by the media endpoint by index into ; every other image is dropped.
+/// served by the media endpoint by index into ; every other image is dropped.
///
public static partial class ArticleMarkdown
{
@@ -75,9 +75,21 @@ public static partial class ArticleMarkdown
IReadOnlyDictionary visibleTitles, ILogger? logger = null) =>
RenderVisible(signedIn ? body : ArticleBody.RedactPrivate(body), lang, slug, signedIn, visibleTitles, logger);
+ ///
+ /// The raw/ image paths the media endpoint may serve for this reader: index n is the image
+ /// rewrote to /house/media/{lang}/{slug}/{n} with the same arguments. Empty when the render is locked.
+ ///
+ public static IReadOnlyList VisibleImagePaths(string body, string lang, string slug, bool signedIn,
+ IReadOnlyDictionary visibleTitles) =>
+ Process(signedIn ? body : ArticleBody.RedactPrivate(body), lang, slug, signedIn, visibleTitles, logger: null).Images;
+
/// Renders a body that is already what this reader may see.
internal static RenderedArticle RenderVisible(string visibleBody, string lang, string slug, bool signedIn,
- IReadOnlyDictionary visibleTitles, ILogger? logger = null)
+ IReadOnlyDictionary visibleTitles, ILogger? logger = null) =>
+ Process(visibleBody, lang, slug, signedIn, visibleTitles, logger).Article;
+
+ private static (RenderedArticle Article, IReadOnlyList Images) Process(string visibleBody, string lang, string slug,
+ bool signedIn, IReadOnlyDictionary visibleTitles, ILogger? logger)
{
var slugs = new List();
var doc = Parse(visibleBody, slugs);
@@ -86,13 +98,14 @@ public static partial class ArticleMarkdown
if (doc.Descendants().Any(c => c.Info is not (Infobox or Locked)))
{
logger?.LogWarning("Article {Slug} ({Lang}) has an unexpected container after redaction; rendering it locked", slug, lang);
- return LockedArticle(lang, slug);
+ return (LockedArticle(lang, slug), []);
}
foreach (var c in doc.Descendants().Where(c => c.Info is not (Infobox or Locked)).ToList())
c.Parent?.Remove(c);
}
- SanitizeLinks(doc, lang, slug, visibleTitles);
+ var images = new List();
+ SanitizeLinks(doc, lang, slug, visibleTitles, images);
var headings = ResolveWikiLinks(doc, lang, slugs, visibleTitles);
var leftovers = doc.Descendants().Where(HasLeftover).ToList();
@@ -101,7 +114,7 @@ public static partial class ArticleMarkdown
if (!signedIn)
{
logger?.LogWarning("Article {Slug} ({Lang}) has unresolved link text after rendering; rendering it locked", slug, lang);
- return LockedArticle(lang, slug);
+ return (LockedArticle(lang, slug), []);
}
foreach (var leaf in leftovers) StripLeftover(leaf, headings);
}
@@ -116,19 +129,13 @@ public static partial class ArticleMarkdown
.Select(h => new TocEntry(h.Level, h.GetAttributes().Id!, PlainText(h.Inline, includeCode: true).Trim()))
.ToList();
- return new RenderedArticle(
+ var article = new RenderedArticle(
ToHtml(doc, lang, slug, signedIn),
infobox is null ? null : ToHtml(infobox, lang, slug, signedIn),
toc);
+ return (article, images);
}
- /// The valid raw/ image paths of a body, in order; the media index n of points into this list.
- public static IReadOnlyList ImagePaths(string visibleBody) =>
- Parse(visibleBody, []).Descendants()
- .Where(l => l.IsImage && l.Url is not null && RawImage.IsMatch(l.Url))
- .Select(l => l.Url!)
- .ToList();
-
private static RenderedArticle LockedArticle(string lang, string slug) =>
new(ToHtml(Parse($"{ArticleBody.PrivateLocked}\n{ArticleBody.Close}", []), lang, slug, signedIn: false), null, []);
@@ -217,12 +224,12 @@ public static partial class ArticleMarkdown
}
///
- /// Rewrites valid raw/ images to the media endpoint and drops every other image; keeps only the text of links
- /// whose target is not allowed; removes all link titles.
+ /// Rewrites valid raw/ images to the media endpoint, appending each path to at its index,
+ /// and drops every other image; keeps only the text of links whose target is not allowed; removes all link titles.
///
- private static void SanitizeLinks(MarkdownDocument doc, string lang, string slug, IReadOnlyDictionary visibleTitles)
+ private static void SanitizeLinks(MarkdownDocument doc, string lang, string slug, IReadOnlyDictionary visibleTitles,
+ List images)
{
- var index = 0;
foreach (var obj in doc.Descendants().ToList())
{
if (obj is AutolinkInline auto)
@@ -241,7 +248,10 @@ public static partial class ArticleMarkdown
if (link.IsImage)
{
if (link.GetDynamicUrl is null && link.Url is not null && RawImage.IsMatch(link.Url))
- link.Url = $"/house/media/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}/{index++}";
+ {
+ images.Add(link.Url);
+ link.Url = $"/house/media/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}/{images.Count - 1}";
+ }
else
link.Remove();
continue;
diff --git a/src/Wiki.Host/wwwroot/house.css b/src/Wiki.Host/wwwroot/house.css
index 34eebbe..2560440 100644
--- a/src/Wiki.Host/wwwroot/house.css
+++ b/src/Wiki.Host/wwwroot/house.css
@@ -1,3 +1,27 @@
+/* House encyclopedia pages: header, then the article column. */
+.house-body { margin: 0; background: #f6f6f6; }
+
+.house-page { max-width: 64rem; margin: 0 auto; }
+
+.house-header {
+ padding: .6rem 1.75rem;
+ border-bottom: 1px solid #a2a9b1;
+ background: #fff;
+ font-family: sans-serif;
+}
+.house-header .house-brand { color: #202122; font-size: 1.15em; text-decoration: none; }
+
+.house-article .house-from { font-family: sans-serif; font-size: .85em; color: #54595d; margin-bottom: 1rem; }
+.house-article .house-index { columns: 2 16rem; padding-left: 1.2rem; }
+.house-article .infobox .infobox-title {
+ background: #cee0f2;
+ text-align: center;
+ font-weight: bold;
+ padding: 6px;
+ font-size: 1.1em;
+}
+.house-article .toc .toc-level-3 { margin-left: 1rem; }
+
/* House encyclopedia article, Wikipedia look. */
.house-article {
font-family: Georgia, "Linux Libertine", serif;
@@ -113,5 +137,6 @@
@media (max-width: 720px) {
.house-article { padding: 1rem; }
+ .house-header { padding: .6rem 1rem; }
.house-article .infobox { float: none; width: auto; margin: 0 0 1rem; }
}
diff --git a/test/Wiki.Host.Tests/ArticleMarkdownTests.cs b/test/Wiki.Host.Tests/ArticleMarkdownTests.cs
index ce4c6f8..b29a966 100644
--- a/test/Wiki.Host.Tests/ArticleMarkdownTests.cs
+++ b/test/Wiki.Host.Tests/ArticleMarkdownTests.cs
@@ -218,12 +218,12 @@ public class ArticleMarkdownTests
Assert.DoesNotContain("raw/", anonymous.Html);
Assert.DoesNotContain("example.com", anonymous.Html);
Assert.DoesNotContain("Keller", anonymous.Html);
- Assert.Equal(["raw/taster-1.png"], ArticleMarkdown.ImagePaths(Wiki.Core.Parsing.ArticleBody.RedactPrivate(body)));
+ Assert.Equal(["raw/taster-1.png"], ArticleMarkdown.VisibleImagePaths(body, "de", "switches", signedIn: false, Titles));
var signedIn = SignedIn(body);
Assert.Contains("
", signedIn.Html);
Assert.Contains("
", signedIn.Html);
- Assert.Equal(["raw/keller.jpg", "raw/taster-1.png"], ArticleMarkdown.ImagePaths(body));
+ Assert.Equal(["raw/keller.jpg", "raw/taster-1.png"], ArticleMarkdown.VisibleImagePaths(body, "de", "switches", signedIn: true, Titles));
}
[Fact]
@@ -237,11 +237,11 @@ public class ArticleMarkdownTests
}
[Fact]
- public void ImagePaths_RejectsTraversalAndNonImages()
+ public void VisibleImagePaths_RejectsTraversalAndNonImages()
{
var body = "     \n\n```\n\n```";
- Assert.Equal(["raw/zaehler-1.jpg"], ArticleMarkdown.ImagePaths(body));
+ Assert.Equal(["raw/zaehler-1.jpg"], ArticleMarkdown.VisibleImagePaths(body, "de", "switches", signedIn: true, Titles));
}
[Theory]
diff --git a/test/Wiki.Host.Tests/Fakes.cs b/test/Wiki.Host.Tests/Fakes.cs
index 0634a53..f961085 100644
--- a/test/Wiki.Host.Tests/Fakes.cs
+++ b/test/Wiki.Host.Tests/Fakes.cs
@@ -72,10 +72,16 @@ public sealed class FakeStore : INoteStore
public Task ReadTextAsync(string path, string commit, CancellationToken ct) =>
Task.FromResult(Files.TryGetValue(path, out var text) ? new StoredText(text, BlobSha(text)) : null);
- public Task ReadBytesAsync(string path, string commit, CancellationToken ct) =>
- Task.FromResult(Binaries.TryGetValue(path, out var bytes) ? bytes
+ /// Every path was asked for, in order.
+ public List ByteReads { get; } = [];
+
+ public Task ReadBytesAsync(string path, string commit, CancellationToken ct)
+ {
+ lock (ByteReads) ByteReads.Add(path);
+ return Task.FromResult(Binaries.TryGetValue(path, out var bytes) ? bytes
: Files.TryGetValue(path, out var text) ? Encoding.UTF8.GetBytes(text)
: throw new FileNotFoundException(path));
+ }
private static string BlobSha(string text) => Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(text)));
diff --git a/test/Wiki.Host.Tests/HouseTests.cs b/test/Wiki.Host.Tests/HouseTests.cs
new file mode 100644
index 0000000..5babc85
--- /dev/null
+++ b/test/Wiki.Host.Tests/HouseTests.cs
@@ -0,0 +1,532 @@
+using System.Net;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Wiki.Core.Model;
+using Wiki.Core.Ports;
+
+namespace Wiki.Host.Tests;
+
+///
+/// The house encyclopedia: anonymous readers get the public view and nothing else, signed-in humans
+/// everything, and every other route keeps requiring a login.
+///
+public class HouseTests(WikiHostFactory factory) : IClassFixture
+{
+ private static readonly byte[] TasterPng = [0x89, 0x50, 0x4e, 0x47, 1, 2, 3];
+ private static readonly byte[] GeheimPng = [0x89, 0x50, 0x4e, 0x47, 9, 9, 9];
+
+ private const string SchalterBody =
+ "::: infobox\n| Eigenschaft | Wert |\n|---|---|\n| Typ | Taster |\n| Siehe | [[network-internals]] |\n:::\n\n" +
+ "Die **Schalter** im Haus sind Taster. Mehr unter [[network-internals]].\n\n" +
+ "\n\n" +
+ "## Bedienung\n\nEinmal drücken schaltet das Licht.\n\n" +
+ "::: private\nGEHEIM steht hier. \n:::\n";
+
+ private static string ArticleText(string lang, string slug, string title, string visibility, string body, string category = "device") =>
+ $"---\ntitle: {title}\nlang: {lang}\nsubject: {slug}\ncategory: {category}\nvisibility: {visibility}\nsources: []\ngenerated: 2026-10-24\nmodel: test\n---\n\n{body}";
+
+ private void Put(string slug, string titleDe, string titleEn, string visibilityDe, string visibilityEn, string body, string category = "device")
+ {
+ factory.Store.Files[$"articles/de/{slug}.md"] = ArticleText("de", slug, titleDe, visibilityDe, body, category);
+ factory.Store.Files[$"articles/en/{slug}.md"] = ArticleText("en", slug, titleEn, visibilityEn, body, category);
+ }
+
+ private async Task SeedAsync()
+ {
+ Put("schalter", "Wandschalter", "Wall switches", "public", "public", SchalterBody);
+ Put("network-internals", "Netzwerk-Interna", "Network internals", "private", "private", "Das VLAN-Layout.", "system");
+ Put("heizung", "Heizung", "Heating", "public", "private", "Die Wärmepumpe.", "system");
+ Put("x", "Pfad", "Path", "public", "public", "Bild: ");
+ Put("y", "Notiz", "Note", "public", "public", "Bild: ");
+ Put("z", "Zwischenspeicher", "Cache", "public", "public", "Bild: ");
+ // escaped brackets survive as link text, so the anonymous render locks the whole body
+ Put("locked", "Gesperrt", "Locked", "public", "public", "Öffentlich.\n\n\\[\\[network-internals]]\n\n");
+ factory.Store.Binaries["raw/taster.png"] = TasterPng;
+ factory.Store.Binaries["raw/geheim.png"] = GeheimPng;
+ factory.Store.Binaries["raw/cache.png"] = [1, 2, 3];
+ factory.Store.Binaries["raw/locked.png"] = [4, 5, 6];
+ factory.Store.Files["raw/notes.md"] = "---\ntitle: N\ntype: raw\n---\n\nnotes\n";
+
+ var sync = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync?force=true");
+ sync.Headers.Add("X-Api-Key", WikiHostFactory.ApiKey);
+ Assert.Equal(HttpStatusCode.OK, (await factory.CreateClient().SendAsync(sync)).StatusCode);
+ }
+
+ private HttpClient Browser() => factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ HandleCookies = true,
+ BaseAddress = new Uri("https://localhost"),
+ });
+
+ /// A browser with a session: the code flow by hand, started from the house login.
+ private async Task SignedInAsync()
+ {
+ var browser = Browser();
+ var challenge = await browser.GetAsync("/house/login?returnUrl=%2Fhouse%2Fde");
+ Assert.StartsWith(factory.Idp.Configuration.AuthorizationEndpoint, challenge.Headers.Location!.ToString());
+ var query = QueryHelpers.ParseQuery(challenge.Headers.Location!.Query);
+ factory.Idp.Username = "alice";
+ factory.Idp.Nonce = query["nonce"];
+ var callback = await browser.PostAsync("/signin-oidc", new FormUrlEncodedContent(new Dictionary
+ {
+ ["code"] = "the-code",
+ ["state"] = query["state"].ToString(),
+ }));
+ Assert.Equal(HttpStatusCode.Redirect, callback.StatusCode);
+ return browser;
+ }
+
+ private static async Task Html(HttpResponseMessage response) => await response.Content.ReadAsStringAsync();
+
+ /// The page as text: static SSR writes every non-ASCII character as an entity.
+ private static string Text(string html) => WebUtility.HtmlDecode(html);
+
+ [Fact]
+ public async Task PublicArticle_Anonymous_200_NoPrivateText()
+ {
+ await SeedAsync();
+
+ var page = await Browser().GetAsync("/house/de/schalter");
+
+ Assert.Equal(HttpStatusCode.OK, page.StatusCode);
+ var html = await Html(page);
+ Assert.Contains("Wandschalter", html);
+ Assert.Contains("Einmal drücken", html);
+ Assert.Contains("Aus der Hausenzyklopädie", Text(html));
+ Assert.Contains("2026-10-24", html);
+ Assert.Contains("Wandschalter
", html);
+ Assert.Contains("href=\"#bedienung\"", html);
+ Assert.Contains("href=\"/house/en/schalter\"", html);
+ Assert.Contains("href=\"/house/login?returnUrl=%2Fhouse%2Fde%2Fschalter\"", html);
+ Assert.Contains("
Netzwerk-Interna", html);
+ Assert.Contains("/house/media/de/schalter/1", html);
+ Assert.DoesNotContain("/house/login", Text(html));
+ }
+
+ [Fact]
+ public async Task PublicArticle_MachineIdentity_SeesNoPrivateText()
+ {
+ await SeedAsync();
+ var keyed = Browser();
+ keyed.DefaultRequestHeaders.Add("X-Api-Key", WikiHostFactory.ApiKey);
+
+ var html = await Html(await keyed.GetAsync("/house/de/schalter"));
+ Assert.DoesNotContain("GEHEIM", Text(html));
+ Assert.DoesNotContain("Netzwerk-Interna", Text(html));
+
+ var basic = Browser();
+ basic.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String("test:test"u8.ToArray()));
+ Assert.DoesNotContain("Netzwerk-Interna", Text(await Html(await basic.GetAsync("/house/de/network-internals"))));
+ Assert.Equal(HttpStatusCode.NotFound, (await basic.GetAsync("/house/media/de/schalter/1")).StatusCode);
+ }
+
+ [Fact]
+ public async Task PrivateArticle_Anonymous_ShowsNoTitle()
+ {
+ await SeedAsync();
+
+ var page = await Browser().GetAsync("/house/de/network-internals");
+
+ Assert.Equal(HttpStatusCode.OK, page.StatusCode);
+ var html = await Html(page);
+ Assert.Contains("Dieser Artikel ist privat — anmelden", Text(html));
+ Assert.Contains("href=\"/house/login?returnUrl=%2Fhouse%2Fde%2Fnetwork-internals\"", html);
+ Assert.DoesNotContain("Netzwerk-Interna", Text(html));
+ Assert.DoesNotContain("Network internals", Text(html));
+ Assert.DoesNotContain("VLAN", Text(html));
+ }
+
+ [Fact]
+ public async Task PrivateArticle_SignedIn_ShowsBody()
+ {
+ await SeedAsync();
+ var browser = await SignedInAsync();
+
+ var html = await Html(await browser.GetAsync("/house/de/network-internals"));
+
+ Assert.Contains("Netzwerk-Interna", html);
+ Assert.Contains("VLAN", html);
+ }
+
+ [Fact]
+ public async Task PublicArticle_Anonymous_NoPrivateArticleTitle()
+ {
+ await SeedAsync();
+
+ var html = await Html(await Browser().GetAsync("/house/de/schalter"));
+
+ Assert.DoesNotContain("network-internals", Text(html));
+ Assert.DoesNotContain("Netzwerk-Interna", Text(html));
+ }
+
+ [Fact]
+ public async Task FrontPage_Anonymous_ListsOnlyPublic()
+ {
+ await SeedAsync();
+
+ var page = await Browser().GetAsync("/house/de");
+
+ Assert.Equal(HttpStatusCode.OK, page.StatusCode);
+ var html = await Html(page);
+ Assert.Contains("Wandschalter", html);
+ Assert.Contains("Geräte", Text(html));
+ Assert.DoesNotContain("network-internals", Text(html));
+ Assert.DoesNotContain("Netzwerk-Interna", Text(html));
+ }
+
+ [Fact]
+ public async Task FrontPage_Anonymous_HidesArticlePrivateInOneLanguage()
+ {
+ await SeedAsync();
+
+ var html = await Html(await Browser().GetAsync("/house/de"));
+ Assert.DoesNotContain("/house/de/heizung", Text(html));
+ Assert.DoesNotContain("Heizung", Text(html));
+ Assert.Equal(HttpStatusCode.OK, (await Browser().GetAsync("/house/de/heizung")).StatusCode);
+ Assert.DoesNotContain("Die Wärmepumpe", Text(await Html(await Browser().GetAsync("/house/de/heizung"))));
+ }
+
+ [Fact]
+ public async Task FrontPage_SignedIn_ListsEverything()
+ {
+ await SeedAsync();
+ var browser = await SignedInAsync();
+
+ var html = await Html(await browser.GetAsync("/house/de"));
+
+ Assert.Contains("Netzwerk-Interna", html);
+ Assert.Contains("Heizung", html);
+ Assert.Contains("Systeme", html);
+ }
+
+ [Fact]
+ public async Task HouseRoot_RedirectsToGerman()
+ {
+ var root = await Browser().GetAsync("/house");
+
+ Assert.Equal(HttpStatusCode.Redirect, root.StatusCode);
+ Assert.Equal(new Uri("https://localhost/house/de"), new Uri(new Uri("https://localhost"), root.Headers.Location!));
+ }
+
+ [Fact]
+ public async Task MissingArticle_Anonymous_Is404NotRedirect()
+ {
+ await SeedAsync();
+
+ var page = await Browser().GetAsync("/house/de/does-not-exist");
+
+ Assert.Equal(HttpStatusCode.NotFound, page.StatusCode);
+ Assert.Null(page.Headers.Location);
+ var html = await Html(page);
+ Assert.Contains("Artikel nicht gefunden", html);
+ Assert.Contains("class=\"house-page\"", html);
+ Assert.Contains("href=\"/house/de\"", html);
+
+ var english = await Browser().GetAsync("/house/en/does-not-exist");
+ Assert.Equal(HttpStatusCode.NotFound, english.StatusCode);
+ Assert.Contains("Article not found", await Html(english));
+ }
+
+ [Theory]
+ [InlineData("/house/fr")]
+ [InlineData("/house/fr/schalter")]
+ [InlineData("/house/de/Schalter")]
+ [InlineData("/house/de/schalter--x")]
+ [InlineData("/house/de/schalter/extra")]
+ [InlineData("/house/de/a.b")]
+ public async Task UnknownLanguageOrBadSlug_Anonymous_Is404InPage(string path)
+ {
+ await SeedAsync();
+
+ var page = await Browser().GetAsync(path);
+
+ Assert.Equal(HttpStatusCode.NotFound, page.StatusCode);
+ Assert.Null(page.Headers.Location);
+ Assert.Contains("class=\"house-page\"", await Html(page));
+ }
+
+ [Fact]
+ public async Task HousePageException_Anonymous_IsNoLoginRedirect()
+ {
+ using var broken = factory.WithWebHostBuilder(b => b.ConfigureServices(s =>
+ {
+ s.RemoveAll();
+ s.AddSingleton(new ThrowingCatalog());
+ }));
+ var browser = broken.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") });
+
+ var page = await browser.GetAsync("/house/de/schalter");
+
+ Assert.Equal(HttpStatusCode.InternalServerError, page.StatusCode);
+ Assert.Null(page.Headers.Location);
+ Assert.DoesNotContain("Wandschalter", await Html(page));
+
+ var media = await browser.GetAsync("/house/media/de/schalter/0");
+ Assert.Equal(HttpStatusCode.InternalServerError, media.StatusCode);
+ Assert.Null(media.Headers.Location);
+ }
+
+ [Fact]
+ public async Task House_SendsNoindexAndNoStore()
+ {
+ await SeedAsync();
+
+ foreach (var path in new[] { "/house/de/schalter", "/house/de", "/house/de/does-not-exist", "/house/media/de/schalter/0" })
+ {
+ var response = await Browser().GetAsync(path);
+ Assert.Equal(["noindex"], response.Headers.GetValues("X-Robots-Tag"));
+ Assert.True(response.Headers.CacheControl?.NoStore, path);
+ }
+ Assert.Contains(" p == "raw/cache.png");
+ }
+
+ [Fact]
+ public async Task StaticAssets_Anonymous200()
+ {
+ await SeedAsync();
+ var css = await Browser().GetAsync("/house.css");
+
+ Assert.Equal(HttpStatusCode.OK, css.StatusCode);
+
+ // the assets an article page references, fingerprinted
+ var html = await Html(await Browser().GetAsync("/house/de/schalter"));
+ var assets = System.Text.RegularExpressions.Regex.Matches(html, "(?:href|src)=\"(/[^\"]+\\.(?:css|js))\"").Select(m => m.Groups[1].Value).ToList();
+ Assert.Contains(assets, a => a.Contains("mermaid.min", StringComparison.Ordinal));
+ Assert.Contains(assets, a => a.StartsWith("/house.", StringComparison.Ordinal));
+ foreach (var asset in assets)
+ Assert.Equal(HttpStatusCode.OK, (await Browser().GetAsync(asset)).StatusCode);
+ }
+
+ [Fact]
+ public async Task MediaCache_EvictsOldestBeyondLimit()
+ {
+ var cache = new Wiki.Host.Api.HouseMediaCache(maxBytes: 10);
+ var loads = new List();
+ Task Load(string path, int size)
+ {
+ loads.Add(path);
+ return Task.FromResult(new byte[size]);
+ }
+
+ await cache.GetAsync("c1", "a", _ => Load("a", 4), CancellationToken.None);
+ await cache.GetAsync("c1", "b", _ => Load("b", 4), CancellationToken.None);
+ await cache.GetAsync("c1", "a", _ => Load("a", 4), CancellationToken.None);
+ await cache.GetAsync("c1", "c", _ => Load("c", 4), CancellationToken.None);
+ await cache.GetAsync("c1", "b", _ => Load("b", 4), CancellationToken.None);
+ await cache.GetAsync("c1", "a", _ => Load("a", 4), CancellationToken.None);
+ await cache.GetAsync("c2", "b", _ => Load("b", 4), CancellationToken.None);
+
+ Assert.Equal(["a", "b", "c", "a", "b"], loads);
+ }
+
+ private int Reads()
+ {
+ lock (factory.Store.ByteReads) return factory.Store.ByteReads.Count;
+ }
+
+ private async Task SeedSyncOnlyAsync()
+ {
+ var sync = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync?force=true");
+ sync.Headers.Add("X-Api-Key", WikiHostFactory.ApiKey);
+ Assert.Equal(HttpStatusCode.OK, (await factory.CreateClient().SendAsync(sync)).StatusCode);
+ }
+
+ private sealed class ThrowingCatalog : IDocumentCatalog
+ {
+ public string Name => "throwing-catalog";
+ private static Exception Boom => new InvalidOperationException("catalog down");
+ public Task GetStateAsync(CancellationToken ct) => throw Boom;
+ public Task SetStateAsync(CatalogState state, CancellationToken ct) => throw Boom;
+ public Task UpsertAsync(IReadOnlyList documents, CancellationToken ct) => throw Boom;
+ public Task RemoveAsync(IReadOnlyList references, CancellationToken ct) => throw Boom;
+ public Task GetAsync(DocumentKind kind, string slug, CancellationToken ct) => throw Boom;
+ public Task> ListAsync(DocumentKind kind, CancellationToken ct) => throw Boom;
+ public Task> ListDocumentsAsync(DocumentKind kind, CancellationToken ct) => throw Boom;
+ public Task> BacklinksAsync(string slug, CancellationToken ct) => throw Boom;
+ public Task> OutgoingLinksAsync(string slug, CancellationToken ct) => throw Boom;
+ public Task> TagCountsAsync(CancellationToken ct) => throw Boom;
+ public Task FlushAsync(CancellationToken ct) => throw Boom;
+ public Task CheckAsync(CancellationToken ct) => throw Boom;
+ }
+}