From 0eebffe7450d9eb8d2e6b6a0cd941485b97db97a Mon Sep 17 00:00:00 2001 From: kryptomrx Date: Sat, 16 May 2026 23:53:40 +0200 Subject: [PATCH] test: xUnit test suite and Forgejo CI pipeline - 43 tests across SlugGuard, ReplContext navigation, NotesCache.TimeAgo - SlugGuard moved to NozCli.Core.Security (shared between REPL and tests) - Bug fix: FoldersInView() now returns only actual sub-folders, not note segments (previously cd tab-completion suggested note names as navigable folders) - .forgejo/workflows/ci.yml: build + test on every push/PR --- .forgejo/workflows/ci.yml | 27 +++++ NozCli.Core/Security/SlugGuard.cs | 34 ++++++ NozCli.Tests/NotesCacheTests.cs | 23 +++++ NozCli.Tests/NozCli.Tests.csproj | 26 +++++ NozCli.Tests/ReplContextTests.cs | 165 ++++++++++++++++++++++++++++++ NozCli.Tests/SlugGuardTests.cs | 68 ++++++++++++ NozCli/Commands/PushCommand.cs | 2 +- NozCli/Commands/SyncCommand.cs | 2 +- NozCli/Repl/ReplContext.cs | 1 + noz-cli.slnx | 1 + 10 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 NozCli.Core/Security/SlugGuard.cs create mode 100644 NozCli.Tests/NotesCacheTests.cs create mode 100644 NozCli.Tests/NozCli.Tests.csproj create mode 100644 NozCli.Tests/ReplContextTests.cs create mode 100644 NozCli.Tests/SlugGuardTests.cs diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..ad284a7 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore -c Release + + - name: Test + run: dotnet test NozCli.Tests/NozCli.Tests.csproj --no-build -c Release --verbosity normal diff --git a/NozCli.Core/Security/SlugGuard.cs b/NozCli.Core/Security/SlugGuard.cs new file mode 100644 index 0000000..5aa3c7a --- /dev/null +++ b/NozCli.Core/Security/SlugGuard.cs @@ -0,0 +1,34 @@ +using System.Text.RegularExpressions; + +namespace NozCli.Core.Security; + +/// Validates slugs before they are sent to the server or written to disk. +public static partial class SlugGuard +{ + // Valid slug: lowercase alphanumeric + hyphens, optionally separated by / + // Examples: "hydroponik", "biosysteme/hydroponik", "a/b/c" + [GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*(?:/[a-z0-9][a-z0-9\-]*)*$")] + private static partial Regex ValidSlugRegex(); + + /// Returns true if the slug is safe to use (no path traversal, valid chars). + public static bool IsValid(string slug) => + !string.IsNullOrWhiteSpace(slug) && + !slug.Contains("..") && + !slug.Contains("//") && + ValidSlugRegex().IsMatch(slug); + + /// Resolves a slug to a file path inside baseDir. + /// Returns null if the resulting path would escape baseDir (path traversal). + public static string? ToSafePath(string baseDir, string slug, string extension = ".md") + { + var rel = slug.Replace('/', Path.DirectorySeparatorChar) + extension; + var fullPath = Path.GetFullPath(Path.Combine(baseDir, rel)); + var baseFull = Path.GetFullPath(baseDir); + + // Ensure the resolved path stays strictly inside baseDir + return fullPath.StartsWith(baseFull + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) + ? fullPath + : null; + } +} diff --git a/NozCli.Tests/NotesCacheTests.cs b/NozCli.Tests/NotesCacheTests.cs new file mode 100644 index 0000000..f5dcaef --- /dev/null +++ b/NozCli.Tests/NotesCacheTests.cs @@ -0,0 +1,23 @@ +using Xunit; +using NozCli.Repl; + +namespace NozCli.Tests; + +public class NotesCacheTests +{ + [Theory] + [InlineData(10, "gerade eben")] + [InlineData(59, "gerade eben")] + [InlineData(60, "vor 1 Min.")] + [InlineData(300, "vor 5 Min.")] + [InlineData(3599, "vor 59 Min.")] + [InlineData(3600, "vor 1 Std.")] + [InlineData(7200, "vor 2 Std.")] + [InlineData(86400, "vor 1 Tagen")] + public void TimeAgo_ReturnsCorrectLabel(int secondsAgo, string expected) + { + var dt = DateTimeOffset.UtcNow.AddSeconds(-secondsAgo); + var result = NotesCache.TimeAgo(dt); + Assert.Equal(expected, result); + } +} diff --git a/NozCli.Tests/NozCli.Tests.csproj b/NozCli.Tests/NozCli.Tests.csproj new file mode 100644 index 0000000..01bfa29 --- /dev/null +++ b/NozCli.Tests/NozCli.Tests.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + net10.0 + enable + enable + false + + false + + + diff --git a/NozCli.Tests/ReplContextTests.cs b/NozCli.Tests/ReplContextTests.cs new file mode 100644 index 0000000..e798245 --- /dev/null +++ b/NozCli.Tests/ReplContextTests.cs @@ -0,0 +1,165 @@ +using Xunit; +using NozCli.Core.Config; +using NozCli.Core.Models; +using NozCli.Repl; + +namespace NozCli.Tests; + +public class ReplContextTests +{ + // Builds a ReplContext with a fixed NoteCache — no API calls made. + private static ReplContext BuildCtx(params NoteInfo[] notes) + { + var cfg = new LocalConfig { ServerUrl = "http://localhost:9999", Token = "test" }; + var ctx = new ReplContext(null!, cfg); // Api is null — not used in navigation + ctx.NoteCache = [.. notes]; + return ctx; + } + + private static NoteInfo Note(string slug, string topic, string status = "seed") => + new(slug, $"Title {slug}", status, topic, "de", false, null); + + // ── CurrentPath ────────────────────────────────────────────────────────── + + [Fact] + public void CurrentPath_AtRoot_IsWave() + { + var ctx = BuildCtx(); + Assert.Equal("~", ctx.CurrentPath); + } + + [Fact] + public void CurrentPath_InTopic_ShowsTopic() + { + var ctx = BuildCtx(Note("psychologie/test", "psychologie")); + ctx.NavigateTo("psychologie"); + Assert.Equal("~/psychologie", ctx.CurrentPath); + } + + [Fact] + public void CurrentPath_InSubfolder_ShowsFullPath() + { + var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie")); + ctx.NavigateTo("psychologie"); + ctx.NavigateTo("sub"); + Assert.Equal("~/psychologie/sub", ctx.CurrentPath); + } + + // ── FoldersInView ──────────────────────────────────────────────────────── + + [Fact] + public void FoldersInView_AtRoot_ReturnsDistinctTopics() + { + var ctx = BuildCtx( + Note("psychologie/a", "psychologie"), + Note("psychologie/b", "psychologie"), + Note("biosysteme/c", "biosysteme")); + + var folders = ctx.FoldersInView().OrderBy(f => f).ToList(); + Assert.Equal(["biosysteme", "psychologie"], folders); + } + + [Fact] + public void FoldersInView_InTopic_ReturnsSubfolders() + { + var ctx = BuildCtx( + Note("psychologie/sub/note", "psychologie"), + Note("psychologie/flat-note", "psychologie")); + + ctx.NavigateTo("psychologie"); + var folders = ctx.FoldersInView().ToList(); + + Assert.Contains("sub", folders); + Assert.DoesNotContain("flat-note", folders); + } + + // ── NavigateTo / NavigateUp ────────────────────────────────────────────── + + [Fact] + public void NavigateTo_ValidTopic_ReturnsTrue() + { + var ctx = BuildCtx(Note("psychologie/test", "psychologie")); + Assert.True(ctx.NavigateTo("psychologie")); + Assert.Equal("psychologie", ctx.CurrentTopic); + } + + [Fact] + public void NavigateTo_UnknownTopic_ReturnsFalse() + { + var ctx = BuildCtx(Note("psychologie/test", "psychologie")); + Assert.False(ctx.NavigateTo("doesnotexist")); + Assert.Null(ctx.CurrentTopic); + } + + [Fact] + public void NavigateUp_FromTopic_ReturnsToRoot() + { + var ctx = BuildCtx(Note("psychologie/test", "psychologie")); + ctx.NavigateTo("psychologie"); + ctx.NavigateUp(); + Assert.Null(ctx.CurrentTopic); + Assert.Equal("~", ctx.CurrentPath); + } + + [Fact] + public void NavigateUp_FromSubfolder_ReturnsToTopic() + { + var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie")); + ctx.NavigateTo("psychologie"); + ctx.NavigateTo("sub"); + ctx.NavigateUp(); + + Assert.Equal("psychologie", ctx.CurrentTopic); + Assert.Null(ctx.CurrentSubPath); + } + + // ── SlugRelative ───────────────────────────────────────────────────────── + + [Fact] + public void SlugRelative_InTopic_StripsTopicPrefix() + { + var ctx = BuildCtx(Note("psychologie/flow", "psychologie")); + ctx.NavigateTo("psychologie"); + Assert.Equal("flow", ctx.SlugRelative("psychologie/flow")); + } + + [Fact] + public void SlugRelative_AtRoot_ReturnsFullSlug() + { + var ctx = BuildCtx(Note("psychologie/flow", "psychologie")); + Assert.Equal("psychologie/flow", ctx.SlugRelative("psychologie/flow")); + } + + // ── NotesInView ────────────────────────────────────────────────────────── + + [Fact] + public void NotesInView_InTopic_FiltersCorrectly() + { + var ctx = BuildCtx( + Note("psychologie/a", "psychologie"), + Note("biosysteme/b", "biosysteme")); + + ctx.NavigateTo("psychologie"); + var notes = ctx.NotesInView().ToList(); + + Assert.Single(notes); + Assert.Equal("psychologie/a", notes[0].Slug); + } + + // ── ResolveSlug ────────────────────────────────────────────────────────── + + [Fact] + public void ResolveSlug_ShortName_InTopic_ResolvesToFullSlug() + { + var ctx = BuildCtx(Note("psychologie/flow", "psychologie")); + ctx.NavigateTo("psychologie"); + Assert.Equal("psychologie/flow", ctx.ResolveSlug("flow")); + } + + [Fact] + public void ResolveSlug_AlreadyFullSlug_ReturnsUnchanged() + { + var ctx = BuildCtx(Note("psychologie/flow", "psychologie")); + Assert.Equal("psychologie/flow", ctx.ResolveSlug("psychologie/flow")); + } +} diff --git a/NozCli.Tests/SlugGuardTests.cs b/NozCli.Tests/SlugGuardTests.cs new file mode 100644 index 0000000..e8d7f69 --- /dev/null +++ b/NozCli.Tests/SlugGuardTests.cs @@ -0,0 +1,68 @@ +using Xunit; +using NozCli.Core.Security; + +namespace NozCli.Tests; + +public class SlugGuardTests +{ + // ── IsValid ────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("simple-note", true)] + [InlineData("psychologie/test", true)] + [InlineData("biosysteme/sub/deep", true)] + [InlineData("note123", true)] + [InlineData("a", true)] + public void IsValid_AcceptsValidSlugs(string slug, bool expected) + => Assert.Equal(expected, SlugGuard.IsValid(slug)); + + [Theory] + [InlineData("../../etc/passwd", false)] // path traversal + [InlineData("../escape", false)] // path traversal + [InlineData("note/../other", false)] // embedded traversal + [InlineData("UPPERCASE", false)] // uppercase not allowed + [InlineData("has space", false)] // spaces not allowed + [InlineData("", false)] // empty + [InlineData(" ", false)] // whitespace + [InlineData("note//double", false)] // double slash + [InlineData("/leading-slash", false)] // leading slash + [InlineData("trailing/", false)] // trailing slash + [InlineData("Sonderzeichen!", false)] // special chars + public void IsValid_RejectsInvalidSlugs(string slug, bool expected) + => Assert.Equal(expected, SlugGuard.IsValid(slug)); + + // ── ToSafePath ─────────────────────────────────────────────────────────── + + [Fact] + public void ToSafePath_ValidSlug_ReturnsPathInsideBase() + { + var baseDir = Path.Combine(Path.GetTempPath(), "noz-test"); + var result = SlugGuard.ToSafePath(baseDir, "psychologie/test"); + + Assert.NotNull(result); + Assert.StartsWith(baseDir, result); + Assert.EndsWith(".md", result); + Assert.Contains("psychologie", result); + } + + [Theory] + [InlineData("../../etc/passwd")] + [InlineData("../escape")] + [InlineData("sub/../../outside")] + public void ToSafePath_TraversalSlug_ReturnsNull(string slug) + { + var baseDir = Path.Combine(Path.GetTempPath(), "noz-test"); + Assert.Null(SlugGuard.ToSafePath(baseDir, slug)); + } + + [Fact] + public void ToSafePath_NestedSlug_BuildsCorrectPath() + { + var baseDir = "/tmp/vault"; + var result = SlugGuard.ToSafePath(baseDir, "a/b/c"); + + Assert.NotNull(result); + var expected = Path.Combine("/tmp/vault", "a", "b", "c") + ".md"; + Assert.Equal(expected, result); + } +} diff --git a/NozCli/Commands/PushCommand.cs b/NozCli/Commands/PushCommand.cs index 27fb9e6..7d9deac 100644 --- a/NozCli/Commands/PushCommand.cs +++ b/NozCli/Commands/PushCommand.cs @@ -1,5 +1,5 @@ using NozCli.Repl; -using NozCli.Security; +using NozCli.Core.Security; using Spectre.Console; namespace NozCli.Commands; diff --git a/NozCli/Commands/SyncCommand.cs b/NozCli/Commands/SyncCommand.cs index c9155be..584fa78 100644 --- a/NozCli/Commands/SyncCommand.cs +++ b/NozCli/Commands/SyncCommand.cs @@ -1,7 +1,7 @@ using NozCli.Core.Client; using NozCli.Core.Models; using NozCli.Repl; -using NozCli.Security; +using NozCli.Core.Security; using Spectre.Console; namespace NozCli.Commands; diff --git a/NozCli/Repl/ReplContext.cs b/NozCli/Repl/ReplContext.cs index 2b3089b..408460a 100644 --- a/NozCli/Repl/ReplContext.cs +++ b/NozCli/Repl/ReplContext.cs @@ -70,6 +70,7 @@ public class ReplContext var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length; return NotesInView() + .Where(n => SlugRelative(n.Slug).Split('/').Length > depth + 1) .Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth)) .Where(s => s is not null) .Distinct(StringComparer.OrdinalIgnoreCase)!; diff --git a/noz-cli.slnx b/noz-cli.slnx index 118ed83..662cec8 100644 --- a/noz-cli.slnx +++ b/noz-cli.slnx @@ -2,4 +2,5 @@ +