This repository has been archived on 2026-03-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
labmcp/internal/nixos/indexer_test.go
Torjus Håkestad 4ae92b4f85 chore: migrate module path from git.t-juice.club to code.t-juice.club
Update Go module path and all import references for Gitea to Forgejo
host migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:48:25 +01:00

282 lines
8.9 KiB
Go

package nixos
import (
"context"
"os/exec"
"testing"
"time"
"code.t-juice.club/torjus/labmcp/internal/database"
)
// TestNixpkgsRevision is the revision from flake.lock used for testing.
const TestNixpkgsRevision = "e6eae2ee2110f3d31110d5c222cd395303343b08"
// TestValidateRevision tests the revision validation function.
func TestValidateRevision(t *testing.T) {
tests := []struct {
name string
revision string
wantErr bool
}{
// Valid cases
{"valid git hash", "e6eae2ee2110f3d31110d5c222cd395303343b08", false},
{"valid short hash", "e6eae2e", false},
{"valid channel name", "nixos-unstable", false},
{"valid channel with version", "nixos-24.11", false},
{"valid underscore", "some_branch", false},
{"valid mixed", "release-24.05_beta", false},
// Invalid cases - injection attempts
{"injection semicolon", "foo; rm -rf /", true},
{"injection quotes", `"; builtins.readFile /etc/passwd; "`, true},
{"injection backticks", "foo`whoami`", true},
{"injection dollar", "foo$(whoami)", true},
{"injection newline", "foo\nbar", true},
{"injection space", "foo bar", true},
{"injection slash", "foo/bar", true},
{"injection backslash", "foo\\bar", true},
{"injection pipe", "foo|bar", true},
{"injection ampersand", "foo&bar", true},
{"injection redirect", "foo>bar", true},
{"injection less than", "foo<bar", true},
{"injection curly braces", "foo{bar}", true},
{"injection parens", "foo(bar)", true},
{"injection brackets", "foo[bar]", true},
// Edge cases
{"empty string", "", true},
{"too long", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
{"just dots", "...", false}, // dots are allowed, path traversal is handled elsewhere
{"single char", "a", false},
{"max length 64", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
{"65 chars", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateRevision(tt.revision)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateRevision(%q) error = %v, wantErr %v", tt.revision, err, tt.wantErr)
}
})
}
}
// BenchmarkIndexRevision benchmarks indexing a full nixpkgs revision.
// This is a slow benchmark that requires nix to be installed.
// Run with: go test -bench=BenchmarkIndexRevision -benchtime=1x -timeout=30m ./internal/nixos/...
func BenchmarkIndexRevision(b *testing.B) {
// Check if nix-build is available
if _, err := exec.LookPath("nix-build"); err != nil {
b.Skip("nix-build not found, skipping indexer benchmark")
}
// Use in-memory SQLite for the benchmark
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
b.Fatalf("Failed to create store: %v", err)
}
defer store.Close() //nolint:errcheck // benchmark/test cleanup
ctx := context.Background()
if err := store.Initialize(ctx); err != nil {
b.Fatalf("Failed to initialize store: %v", err)
}
indexer := NewIndexer(store)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Delete any existing revision first (for repeated runs)
if rev, _ := store.GetRevision(ctx, TestNixpkgsRevision); rev != nil {
_ = store.DeleteRevision(ctx, rev.ID) //nolint:errcheck // benchmark cleanup
}
result, err := indexer.IndexRevision(ctx, TestNixpkgsRevision)
if err != nil {
b.Fatalf("IndexRevision failed: %v", err)
}
b.ReportMetric(float64(result.OptionCount), "options")
if dur, ok := result.Duration.(time.Duration); ok {
b.ReportMetric(float64(dur.Milliseconds()), "ms")
}
}
}
// BenchmarkIndexRevisionWithFiles benchmarks indexing with file content storage.
// This downloads the full nixpkgs tarball and stores allowed file types.
// Run with: go test -bench=BenchmarkIndexRevisionWithFiles -benchtime=1x -timeout=60m ./internal/nixos/...
func BenchmarkIndexRevisionWithFiles(b *testing.B) {
// Check if nix-build is available
if _, err := exec.LookPath("nix-build"); err != nil {
b.Skip("nix-build not found, skipping indexer benchmark")
}
// Use in-memory SQLite for the benchmark
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
b.Fatalf("Failed to create store: %v", err)
}
defer store.Close() //nolint:errcheck // benchmark/test cleanup
ctx := context.Background()
if err := store.Initialize(ctx); err != nil {
b.Fatalf("Failed to initialize store: %v", err)
}
indexer := NewIndexer(store)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Delete any existing revision first
if rev, _ := store.GetRevision(ctx, TestNixpkgsRevision); rev != nil {
_ = store.DeleteRevision(ctx, rev.ID) //nolint:errcheck // benchmark cleanup
}
result, err := indexer.IndexRevision(ctx, TestNixpkgsRevision)
if err != nil {
b.Fatalf("IndexRevision failed: %v", err)
}
fileStart := time.Now()
fileCount, err := indexer.IndexFiles(ctx, result.Revision.ID, TestNixpkgsRevision)
if err != nil {
b.Fatalf("IndexFiles failed: %v", err)
}
fileDuration := time.Since(fileStart)
b.ReportMetric(float64(result.OptionCount), "options")
if dur, ok := result.Duration.(time.Duration); ok {
b.ReportMetric(float64(dur.Milliseconds()), "options_ms")
}
b.ReportMetric(float64(fileCount), "files")
b.ReportMetric(float64(fileDuration.Milliseconds()), "files_ms")
}
}
// BenchmarkIndexFilesOnly benchmarks only the file indexing phase.
// Assumes options are already indexed. Useful for measuring file indexing in isolation.
// Run with: go test -bench=BenchmarkIndexFilesOnly -benchtime=1x -timeout=60m ./internal/nixos/...
func BenchmarkIndexFilesOnly(b *testing.B) {
if _, err := exec.LookPath("nix-build"); err != nil {
b.Skip("nix-build not found, skipping indexer benchmark")
}
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
b.Fatalf("Failed to create store: %v", err)
}
defer store.Close() //nolint:errcheck // benchmark/test cleanup
ctx := context.Background()
if err := store.Initialize(ctx); err != nil {
b.Fatalf("Failed to initialize store: %v", err)
}
indexer := NewIndexer(store)
// Index options first (outside of benchmark timing)
result, err := indexer.IndexRevision(ctx, TestNixpkgsRevision)
if err != nil {
b.Fatalf("IndexRevision failed: %v", err)
}
b.Logf("Pre-indexed %d options", result.OptionCount)
b.ResetTimer()
for i := 0; i < b.N; i++ {
fileCount, err := indexer.IndexFiles(ctx, result.Revision.ID, TestNixpkgsRevision)
if err != nil {
b.Fatalf("IndexFiles failed: %v", err)
}
b.ReportMetric(float64(fileCount), "files")
}
}
// TestIndexRevision is an integration test for the indexer.
// Run with: go test -run=TestIndexRevision -timeout=30m ./internal/nixos/...
func TestIndexRevision(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Check if nix-build is available
if _, err := exec.LookPath("nix-build"); err != nil {
t.Skip("nix-build not found, skipping indexer test")
}
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close() //nolint:errcheck // benchmark/test cleanup
ctx := context.Background()
if err := store.Initialize(ctx); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
indexer := NewIndexer(store)
t.Logf("Indexing nixpkgs revision %s...", TestNixpkgsRevision[:12])
result, err := indexer.IndexRevision(ctx, TestNixpkgsRevision)
if err != nil {
t.Fatalf("IndexRevision failed: %v", err)
}
t.Logf("Indexed %d options in %s", result.OptionCount, result.Duration)
// Verify we got a reasonable number of options (NixOS has thousands)
if result.OptionCount < 1000 {
t.Errorf("Expected at least 1000 options, got %d", result.OptionCount)
}
// Verify revision was stored
rev, err := store.GetRevision(ctx, TestNixpkgsRevision)
if err != nil {
t.Fatalf("GetRevision failed: %v", err)
}
if rev == nil {
t.Fatal("Revision not found after indexing")
}
if rev.OptionCount != result.OptionCount {
t.Errorf("Stored option count %d != result count %d", rev.OptionCount, result.OptionCount)
}
// Test searching
options, err := store.SearchOptions(ctx, rev.ID, "nginx", database.SearchFilters{Limit: 10})
if err != nil {
t.Fatalf("SearchOptions failed: %v", err)
}
if len(options) == 0 {
t.Error("Expected to find nginx options")
}
t.Logf("Found %d nginx options", len(options))
// Test getting a specific option
opt, err := store.GetOption(ctx, rev.ID, "services.nginx.enable")
if err != nil {
t.Fatalf("GetOption failed: %v", err)
}
if opt == nil {
t.Error("services.nginx.enable not found")
} else {
t.Logf("services.nginx.enable: type=%s", opt.Type)
if opt.Type != "boolean" {
t.Errorf("Expected type 'boolean', got %q", opt.Type)
}
}
// Test getting children
children, err := store.GetChildren(ctx, rev.ID, "services.nginx")
if err != nil {
t.Fatalf("GetChildren failed: %v", err)
}
if len(children) == 0 {
t.Error("Expected services.nginx to have children")
}
t.Logf("services.nginx has %d direct children", len(children))
}