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:
parent
db9aabd2b7
commit
0eebffe745
10 changed files with 347 additions and 2 deletions
27
.forgejo/workflows/ci.yml
Normal file
27
.forgejo/workflows/ci.yml
Normal 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
|
||||
34
NozCli.Core/Security/SlugGuard.cs
Normal file
34
NozCli.Core/Security/SlugGuard.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
23
NozCli.Tests/NotesCacheTests.cs
Normal file
23
NozCli.Tests/NotesCacheTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
26
NozCli.Tests/NozCli.Tests.csproj
Normal file
26
NozCli.Tests/NozCli.Tests.csproj
Normal 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>
|
||||
165
NozCli.Tests/ReplContextTests.cs
Normal file
165
NozCli.Tests/ReplContextTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
68
NozCli.Tests/SlugGuardTests.cs
Normal file
68
NozCli.Tests/SlugGuardTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
using NozCli.Repl;
|
||||
using NozCli.Security;
|
||||
using NozCli.Core.Security;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace NozCli.Commands;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)!;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue