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.Repl;
|
||||||
using NozCli.Security;
|
using NozCli.Core.Security;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
namespace NozCli.Commands;
|
namespace NozCli.Commands;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using NozCli.Core.Client;
|
using NozCli.Core.Client;
|
||||||
using NozCli.Core.Models;
|
using NozCli.Core.Models;
|
||||||
using NozCli.Repl;
|
using NozCli.Repl;
|
||||||
using NozCli.Security;
|
using NozCli.Core.Security;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
namespace NozCli.Commands;
|
namespace NozCli.Commands;
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ public class ReplContext
|
||||||
|
|
||||||
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
|
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
|
||||||
return NotesInView()
|
return NotesInView()
|
||||||
|
.Where(n => SlugRelative(n.Slug).Split('/').Length > depth + 1)
|
||||||
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
|
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
|
||||||
.Where(s => s is not null)
|
.Where(s => s is not null)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)!;
|
.Distinct(StringComparer.OrdinalIgnoreCase)!;
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
<Project Path="NozCli.Core/NozCli.Core.csproj" />
|
<Project Path="NozCli.Core/NozCli.Core.csproj" />
|
||||||
<Project Path="NozCli/NozCli.csproj" />
|
<Project Path="NozCli/NozCli.csproj" />
|
||||||
<Project Path="Noz.PowerShell/Noz.PowerShell.csproj" />
|
<Project Path="Noz.PowerShell/Noz.PowerShell.csproj" />
|
||||||
|
<Project Path="NozCli.Tests/NozCli.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue