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
This commit is contained in:
kryptomrx 2026-05-16 23:53:40 +02:00
parent db9aabd2b7
commit 0eebffe745
10 changed files with 347 additions and 2 deletions

27
.forgejo/workflows/ci.yml Normal file
View file

@ -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

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\NozCli.Core\NozCli.Core.csproj" />
<ProjectReference Include="..\NozCli\NozCli.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!-- Disable AOT for the test project even though NozCli has PublishAot=true -->
<PublishAot>false</PublishAot>
</PropertyGroup>
</Project>

View file

@ -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"));
}
}

View file

@ -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);
}
}

View file

@ -1,5 +1,5 @@
using NozCli.Repl;
using NozCli.Security;
using NozCli.Core.Security;
using Spectre.Console;
namespace NozCli.Commands;

View file

@ -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;

View file

@ -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)!;

View file

@ -2,4 +2,5 @@
<Project Path="NozCli.Core/NozCli.Core.csproj" />
<Project Path="NozCli/NozCli.csproj" />
<Project Path="Noz.PowerShell/Noz.PowerShell.csproj" />
<Project Path="NozCli.Tests/NozCli.Tests.csproj" />
</Solution>