Add //nolint:errcheck comments to intentionally unchecked error returns: - defer X.Close() calls: errors from closing read-only resources, rows after iteration, files, response bodies, and gzip readers are not actionable and don't affect correctness - defer tx.Rollback(): standard Go pattern where rollback after successful commit returns an error, which is expected behavior - defer stmt.Close(): statements are closed with their transactions - Cleanup operations: DeleteRevision on failure and os.RemoveAll for temp directories are best-effort cleanup - HTTP response encoding: if JSON encoding fails at response time, there's nothing useful we can do - Test/benchmark code: unchecked errors in test setup/cleanup where failures will surface through test assertions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
257 lines
7.8 KiB
Go
257 lines
7.8 KiB
Go
package homemanager
|
|
|
|
import (
|
|
"context"
|
|
"os/exec"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.t-juice.club/torjus/labmcp/internal/database"
|
|
)
|
|
|
|
// TestHomeManagerRevision is a known release branch for testing.
|
|
const TestHomeManagerRevision = "release-24.11"
|
|
|
|
// TestValidateRevision tests the revision validation function.
|
|
func TestValidateRevision(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
revision string
|
|
wantErr bool
|
|
}{
|
|
// Valid cases
|
|
{"valid git hash", "abc123def456abc123def456abc123def456abc1", false},
|
|
{"valid short hash", "abc123d", false},
|
|
{"valid channel name", "hm-unstable", false},
|
|
{"valid release", "release-24.11", false},
|
|
{"valid master", "master", 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResolveRevision tests channel alias resolution.
|
|
func TestResolveRevision(t *testing.T) {
|
|
store, err := database.NewSQLiteStore(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close() //nolint:errcheck // benchmark/test cleanup
|
|
|
|
indexer := NewIndexer(store)
|
|
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"hm-unstable", "master"},
|
|
{"hm-stable", "release-24.11"},
|
|
{"master", "master"},
|
|
{"release-24.11", "release-24.11"},
|
|
{"release-24.05", "release-24.05"},
|
|
{"release-23.11", "release-23.11"},
|
|
{"abc123def", "abc123def"}, // Git hash passes through
|
|
{"unknown-channel", "unknown-channel"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
result := indexer.ResolveRevision(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("ResolveRevision(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetChannelName tests channel name lookup.
|
|
func TestGetChannelName(t *testing.T) {
|
|
store, err := database.NewSQLiteStore(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close() //nolint:errcheck // benchmark/test cleanup
|
|
|
|
indexer := NewIndexer(store)
|
|
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"hm-unstable", "hm-unstable"},
|
|
{"hm-stable", "hm-stable"},
|
|
{"master", "master"}, // "master" is both an alias and a ref
|
|
{"release-24.11", "release-24.11"},
|
|
{"abc123def", ""}, // Git hash has no channel name
|
|
{"unknown", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
result := indexer.GetChannelName(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("GetChannelName(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// BenchmarkIndexRevision benchmarks indexing a full home-manager revision.
|
|
// This is a slow benchmark that requires nix to be installed.
|
|
// Run with: go test -bench=BenchmarkIndexRevision -benchtime=1x -timeout=30m ./internal/homemanager/...
|
|
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, TestHomeManagerRevision); rev != nil {
|
|
_ = store.DeleteRevision(ctx, rev.ID) //nolint:errcheck // benchmark cleanup
|
|
}
|
|
|
|
result, err := indexer.IndexRevision(ctx, TestHomeManagerRevision)
|
|
if err != nil {
|
|
b.Fatalf("IndexRevision failed: %v", err)
|
|
}
|
|
|
|
b.ReportMetric(float64(result.OptionCount), "options")
|
|
b.ReportMetric(float64(result.Duration.(time.Duration).Milliseconds()), "ms")
|
|
}
|
|
}
|
|
|
|
// TestIndexRevision is an integration test for the indexer.
|
|
// Run with: go test -run=TestIndexRevision -timeout=30m ./internal/homemanager/...
|
|
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 home-manager revision %s...", TestHomeManagerRevision)
|
|
result, err := indexer.IndexRevision(ctx, TestHomeManagerRevision)
|
|
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 (Home Manager has hundreds)
|
|
if result.OptionCount < 100 {
|
|
t.Errorf("Expected at least 100 options, got %d", result.OptionCount)
|
|
}
|
|
|
|
// Verify revision was stored
|
|
rev, err := store.GetRevision(ctx, TestHomeManagerRevision)
|
|
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 for git options (programs.git is a common HM option)
|
|
options, err := store.SearchOptions(ctx, rev.ID, "git", database.SearchFilters{Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("SearchOptions failed: %v", err)
|
|
}
|
|
if len(options) == 0 {
|
|
t.Error("Expected to find git options")
|
|
}
|
|
t.Logf("Found %d git options", len(options))
|
|
|
|
// Test getting a specific option
|
|
opt, err := store.GetOption(ctx, rev.ID, "programs.git.enable")
|
|
if err != nil {
|
|
t.Fatalf("GetOption failed: %v", err)
|
|
}
|
|
if opt == nil {
|
|
t.Error("programs.git.enable not found")
|
|
} else {
|
|
t.Logf("programs.git.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, "programs.git")
|
|
if err != nil {
|
|
t.Fatalf("GetChildren failed: %v", err)
|
|
}
|
|
if len(children) == 0 {
|
|
t.Error("Expected programs.git to have children")
|
|
}
|
|
t.Logf("programs.git has %d direct children", len(children))
|
|
}
|