diff --git a/pkg/io/sigil/crypto_sigil_test.go b/pkg/io/sigil/crypto_sigil_test.go new file mode 100644 index 0000000..c87a368 --- /dev/null +++ b/pkg/io/sigil/crypto_sigil_test.go @@ -0,0 +1,536 @@ +package sigil + +import ( + "bytes" + "crypto/rand" + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ── XORObfuscator ────────────────────────────────────────────────── + +func TestXORObfuscator_Good_RoundTrip(t *testing.T) { + ob := &XORObfuscator{} + data := []byte("the axioms are in the weights") + entropy := []byte("deterministic-nonce-24bytes!") + + obfuscated := ob.Obfuscate(data, entropy) + assert.NotEqual(t, data, obfuscated) + assert.Len(t, obfuscated, len(data)) + + restored := ob.Deobfuscate(obfuscated, entropy) + assert.Equal(t, data, restored) +} + +func TestXORObfuscator_Good_DifferentEntropyDifferentOutput(t *testing.T) { + ob := &XORObfuscator{} + data := []byte("same plaintext") + + out1 := ob.Obfuscate(data, []byte("entropy-a")) + out2 := ob.Obfuscate(data, []byte("entropy-b")) + assert.NotEqual(t, out1, out2) +} + +func TestXORObfuscator_Good_Deterministic(t *testing.T) { + ob := &XORObfuscator{} + data := []byte("reproducible") + entropy := []byte("fixed-seed") + + out1 := ob.Obfuscate(data, entropy) + out2 := ob.Obfuscate(data, entropy) + assert.Equal(t, out1, out2) +} + +func TestXORObfuscator_Good_LargeData(t *testing.T) { + ob := &XORObfuscator{} + // Larger than one SHA-256 block (32 bytes) to test multi-block key stream. + data := make([]byte, 256) + for i := range data { + data[i] = byte(i) + } + entropy := []byte("test-entropy") + + obfuscated := ob.Obfuscate(data, entropy) + restored := ob.Deobfuscate(obfuscated, entropy) + assert.Equal(t, data, restored) +} + +func TestXORObfuscator_Good_EmptyData(t *testing.T) { + ob := &XORObfuscator{} + result := ob.Obfuscate([]byte{}, []byte("entropy")) + assert.Equal(t, []byte{}, result) + + result = ob.Deobfuscate([]byte{}, []byte("entropy")) + assert.Equal(t, []byte{}, result) +} + +func TestXORObfuscator_Good_SymmetricProperty(t *testing.T) { + ob := &XORObfuscator{} + data := []byte("XOR is its own inverse") + entropy := []byte("nonce") + + // XOR is symmetric: Obfuscate(Obfuscate(x)) == x + double := ob.Obfuscate(ob.Obfuscate(data, entropy), entropy) + assert.Equal(t, data, double) +} + +// ── ShuffleMaskObfuscator ────────────────────────────────────────── + +func TestShuffleMaskObfuscator_Good_RoundTrip(t *testing.T) { + ob := &ShuffleMaskObfuscator{} + data := []byte("shuffle and mask protect patterns") + entropy := []byte("deterministic-entropy") + + obfuscated := ob.Obfuscate(data, entropy) + assert.NotEqual(t, data, obfuscated) + assert.Len(t, obfuscated, len(data)) + + restored := ob.Deobfuscate(obfuscated, entropy) + assert.Equal(t, data, restored) +} + +func TestShuffleMaskObfuscator_Good_DifferentEntropy(t *testing.T) { + ob := &ShuffleMaskObfuscator{} + data := []byte("same data") + + out1 := ob.Obfuscate(data, []byte("entropy-1")) + out2 := ob.Obfuscate(data, []byte("entropy-2")) + assert.NotEqual(t, out1, out2) +} + +func TestShuffleMaskObfuscator_Good_Deterministic(t *testing.T) { + ob := &ShuffleMaskObfuscator{} + data := []byte("reproducible shuffle") + entropy := []byte("fixed") + + out1 := ob.Obfuscate(data, entropy) + out2 := ob.Obfuscate(data, entropy) + assert.Equal(t, out1, out2) +} + +func TestShuffleMaskObfuscator_Good_LargeData(t *testing.T) { + ob := &ShuffleMaskObfuscator{} + data := make([]byte, 512) + for i := range data { + data[i] = byte(i % 256) + } + entropy := []byte("large-data-test") + + obfuscated := ob.Obfuscate(data, entropy) + restored := ob.Deobfuscate(obfuscated, entropy) + assert.Equal(t, data, restored) +} + +func TestShuffleMaskObfuscator_Good_EmptyData(t *testing.T) { + ob := &ShuffleMaskObfuscator{} + result := ob.Obfuscate([]byte{}, []byte("entropy")) + assert.Equal(t, []byte{}, result) + + result = ob.Deobfuscate([]byte{}, []byte("entropy")) + assert.Equal(t, []byte{}, result) +} + +func TestShuffleMaskObfuscator_Good_SingleByte(t *testing.T) { + ob := &ShuffleMaskObfuscator{} + data := []byte{0x42} + entropy := []byte("single") + + obfuscated := ob.Obfuscate(data, entropy) + restored := ob.Deobfuscate(obfuscated, entropy) + assert.Equal(t, data, restored) +} + +// ── NewChaChaPolySigil ───────────────────────────────────────────── + +func TestNewChaChaPolySigil_Good(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, err := NewChaChaPolySigil(key) + require.NoError(t, err) + assert.NotNil(t, s) + assert.Equal(t, key, s.Key) + assert.NotNil(t, s.Obfuscator) +} + +func TestNewChaChaPolySigil_Good_KeyIsCopied(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + original := make([]byte, 32) + copy(original, key) + + s, err := NewChaChaPolySigil(key) + require.NoError(t, err) + + // Mutating the original key should not affect the sigil. + key[0] ^= 0xFF + assert.Equal(t, original, s.Key) +} + +func TestNewChaChaPolySigil_Bad_ShortKey(t *testing.T) { + _, err := NewChaChaPolySigil([]byte("too short")) + assert.ErrorIs(t, err, ErrInvalidKey) +} + +func TestNewChaChaPolySigil_Bad_LongKey(t *testing.T) { + _, err := NewChaChaPolySigil(make([]byte, 64)) + assert.ErrorIs(t, err, ErrInvalidKey) +} + +func TestNewChaChaPolySigil_Bad_EmptyKey(t *testing.T) { + _, err := NewChaChaPolySigil(nil) + assert.ErrorIs(t, err, ErrInvalidKey) +} + +// ── NewChaChaPolySigilWithObfuscator ─────────────────────────────── + +func TestNewChaChaPolySigilWithObfuscator_Good(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + ob := &ShuffleMaskObfuscator{} + s, err := NewChaChaPolySigilWithObfuscator(key, ob) + require.NoError(t, err) + assert.Equal(t, ob, s.Obfuscator) +} + +func TestNewChaChaPolySigilWithObfuscator_Good_NilObfuscator(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, err := NewChaChaPolySigilWithObfuscator(key, nil) + require.NoError(t, err) + // Falls back to default XORObfuscator. + assert.IsType(t, &XORObfuscator{}, s.Obfuscator) +} + +func TestNewChaChaPolySigilWithObfuscator_Bad_InvalidKey(t *testing.T) { + _, err := NewChaChaPolySigilWithObfuscator([]byte("bad"), &XORObfuscator{}) + assert.ErrorIs(t, err, ErrInvalidKey) +} + +// ── ChaChaPolySigil In/Out (encrypt/decrypt) ─────────────────────── + +func TestChaChaPolySigil_Good_RoundTrip(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, err := NewChaChaPolySigil(key) + require.NoError(t, err) + + plaintext := []byte("consciousness does not merely avoid causing harm") + ciphertext, err := s.In(plaintext) + require.NoError(t, err) + assert.NotEqual(t, plaintext, ciphertext) + assert.Greater(t, len(ciphertext), len(plaintext)) // nonce + tag overhead + + decrypted, err := s.Out(ciphertext) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +func TestChaChaPolySigil_Good_WithShuffleMask(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, err := NewChaChaPolySigilWithObfuscator(key, &ShuffleMaskObfuscator{}) + require.NoError(t, err) + + plaintext := []byte("shuffle mask pre-obfuscation layer") + ciphertext, err := s.In(plaintext) + require.NoError(t, err) + + decrypted, err := s.Out(ciphertext) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +func TestChaChaPolySigil_Good_NilData(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, err := NewChaChaPolySigil(key) + require.NoError(t, err) + + enc, err := s.In(nil) + require.NoError(t, err) + assert.Nil(t, enc) + + dec, err := s.Out(nil) + require.NoError(t, err) + assert.Nil(t, dec) +} + +func TestChaChaPolySigil_Good_EmptyPlaintext(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, err := NewChaChaPolySigil(key) + require.NoError(t, err) + + ciphertext, err := s.In([]byte{}) + require.NoError(t, err) + assert.NotEmpty(t, ciphertext) // Has nonce + tag even for empty plaintext. + + decrypted, err := s.Out(ciphertext) + require.NoError(t, err) + assert.Equal(t, []byte{}, decrypted) +} + +func TestChaChaPolySigil_Good_DifferentCiphertextsPerCall(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, err := NewChaChaPolySigil(key) + require.NoError(t, err) + + plaintext := []byte("same input") + ct1, _ := s.In(plaintext) + ct2, _ := s.In(plaintext) + + // Different nonces → different ciphertexts. + assert.NotEqual(t, ct1, ct2) +} + +func TestChaChaPolySigil_Bad_NoKey(t *testing.T) { + s := &ChaChaPolySigil{} + + _, err := s.In([]byte("data")) + assert.ErrorIs(t, err, ErrNoKeyConfigured) + + _, err = s.Out([]byte("data")) + assert.ErrorIs(t, err, ErrNoKeyConfigured) +} + +func TestChaChaPolySigil_Bad_WrongKey(t *testing.T) { + key1 := make([]byte, 32) + key2 := make([]byte, 32) + _, _ = rand.Read(key1) + _, _ = rand.Read(key2) + + s1, _ := NewChaChaPolySigil(key1) + s2, _ := NewChaChaPolySigil(key2) + + ciphertext, err := s1.In([]byte("secret")) + require.NoError(t, err) + + _, err = s2.Out(ciphertext) + assert.ErrorIs(t, err, ErrDecryptionFailed) +} + +func TestChaChaPolySigil_Bad_TruncatedCiphertext(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, _ := NewChaChaPolySigil(key) + _, err := s.Out([]byte("too short")) + assert.ErrorIs(t, err, ErrCiphertextTooShort) +} + +func TestChaChaPolySigil_Bad_TamperedCiphertext(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, _ := NewChaChaPolySigil(key) + ciphertext, _ := s.In([]byte("authentic data")) + + // Flip a bit in the ciphertext body (after nonce). + ciphertext[30] ^= 0xFF + + _, err := s.Out(ciphertext) + assert.ErrorIs(t, err, ErrDecryptionFailed) +} + +// failReader returns an error on read — for testing nonce generation failure. +type failReader struct{} + +func (f *failReader) Read([]byte) (int, error) { + return 0, errors.New("entropy source failed") +} + +func TestChaChaPolySigil_Bad_RandReaderFailure(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, _ := NewChaChaPolySigil(key) + s.randReader = &failReader{} + + _, err := s.In([]byte("data")) + assert.Error(t, err) +} + +// ── ChaChaPolySigil without obfuscator ───────────────────────────── + +func TestChaChaPolySigil_Good_NoObfuscator(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, _ := NewChaChaPolySigil(key) + s.Obfuscator = nil // Disable pre-obfuscation. + + plaintext := []byte("raw encryption without pre-obfuscation") + ciphertext, err := s.In(plaintext) + require.NoError(t, err) + + decrypted, err := s.Out(ciphertext) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +// ── GetNonceFromCiphertext ───────────────────────────────────────── + +func TestGetNonceFromCiphertext_Good(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, _ := NewChaChaPolySigil(key) + ciphertext, _ := s.In([]byte("nonce extraction test")) + + nonce, err := GetNonceFromCiphertext(ciphertext) + require.NoError(t, err) + assert.Len(t, nonce, 24) // XChaCha20 nonce is 24 bytes. + + // Nonce should match the prefix of the ciphertext. + assert.Equal(t, ciphertext[:24], nonce) +} + +func TestGetNonceFromCiphertext_Good_NonceCopied(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, _ := NewChaChaPolySigil(key) + ciphertext, _ := s.In([]byte("data")) + + nonce, _ := GetNonceFromCiphertext(ciphertext) + original := make([]byte, len(nonce)) + copy(original, nonce) + + // Mutating the nonce should not affect the ciphertext. + nonce[0] ^= 0xFF + assert.Equal(t, original, ciphertext[:24]) +} + +func TestGetNonceFromCiphertext_Bad_TooShort(t *testing.T) { + _, err := GetNonceFromCiphertext([]byte("short")) + assert.ErrorIs(t, err, ErrCiphertextTooShort) +} + +func TestGetNonceFromCiphertext_Bad_Empty(t *testing.T) { + _, err := GetNonceFromCiphertext(nil) + assert.ErrorIs(t, err, ErrCiphertextTooShort) +} + +// ── ChaChaPolySigil in Transmute pipeline ────────────────────────── + +func TestChaChaPolySigil_Good_InTransmutePipeline(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, _ := NewChaChaPolySigil(key) + hexSigil, _ := NewSigil("hex") + + chain := []Sigil{s, hexSigil} + plaintext := []byte("encrypt then hex encode") + + encoded, err := Transmute(plaintext, chain) + require.NoError(t, err) + + // Result should be hex-encoded ciphertext. + assert.True(t, isHex(encoded)) + + decoded, err := Untransmute(encoded, chain) + require.NoError(t, err) + assert.Equal(t, plaintext, decoded) +} + +func isHex(data []byte) bool { + for _, b := range data { + if !((b >= '0' && b <= '9') || (b >= 'a' && b <= 'f')) { + return false + } + } + return len(data) > 0 +} + +// ── Transmute error propagation ──────────────────────────────────── + +type failSigil struct{} + +func (f *failSigil) In([]byte) ([]byte, error) { return nil, errors.New("fail in") } +func (f *failSigil) Out([]byte) ([]byte, error) { return nil, errors.New("fail out") } + +func TestTransmute_Bad_ErrorPropagation(t *testing.T) { + _, err := Transmute([]byte("data"), []Sigil{&failSigil{}}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "fail in") +} + +func TestUntransmute_Bad_ErrorPropagation(t *testing.T) { + _, err := Untransmute([]byte("data"), []Sigil{&failSigil{}}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "fail out") +} + +// ── GzipSigil with custom writer (edge case) ────────────────────── + +func TestGzipSigil_Good_CustomWriter(t *testing.T) { + var buf bytes.Buffer + s := &GzipSigil{writer: &buf} + + // With custom writer, compressed data goes to buf, returned bytes will be empty + // because the internal buffer 'b' is unused when s.writer is set. + _, err := s.In([]byte("test data")) + require.NoError(t, err) + assert.Greater(t, buf.Len(), 0) +} + +// ── deriveKeyStream edge: exactly 32 bytes ───────────────────────── + +func TestDeriveKeyStream_Good_ExactBlockSize(t *testing.T) { + ob := &XORObfuscator{} + data := make([]byte, 32) // Exactly one SHA-256 block. + for i := range data { + data[i] = byte(i) + } + entropy := []byte("block-boundary") + + obfuscated := ob.Obfuscate(data, entropy) + restored := ob.Deobfuscate(obfuscated, entropy) + assert.Equal(t, data, restored) +} + +// ── io.Reader fallback in In ─────────────────────────────────────── + +func TestChaChaPolySigil_Good_NilRandReader(t *testing.T) { + key := make([]byte, 32) + _, _ = rand.Read(key) + + s, _ := NewChaChaPolySigil(key) + s.randReader = nil // Should fall back to crypto/rand.Reader. + + ciphertext, err := s.In([]byte("fallback reader")) + require.NoError(t, err) + + decrypted, err := s.Out(ciphertext) + require.NoError(t, err) + assert.Equal(t, []byte("fallback reader"), decrypted) +} + +// limitReader returns exactly N bytes then EOF — for deterministic tests. +type limitReader struct { + data []byte + pos int +} + +func (l *limitReader) Read(p []byte) (int, error) { + if l.pos >= len(l.data) { + return 0, io.EOF + } + n := copy(p, l.data[l.pos:]) + l.pos += n + return n, nil +} diff --git a/pkg/lab/config_test.go b/pkg/lab/config_test.go new file mode 100644 index 0000000..b4cc809 --- /dev/null +++ b/pkg/lab/config_test.go @@ -0,0 +1,129 @@ +package lab + +import ( + "os" + "testing" +) + +// ── LoadConfig defaults ──────────────────────────────────────────── + +func TestLoadConfig_Good_Defaults(t *testing.T) { + cfg := LoadConfig() + + if cfg.Addr != ":8080" { + t.Fatalf("expected :8080, got %s", cfg.Addr) + } + if cfg.PrometheusURL != "http://prometheus:9090" { + t.Fatalf("unexpected PrometheusURL: %s", cfg.PrometheusURL) + } + if cfg.PrometheusInterval != 15 { + t.Fatalf("expected 15, got %d", cfg.PrometheusInterval) + } + if cfg.ForgeURL != "https://forge.lthn.io" { + t.Fatalf("unexpected ForgeURL: %s", cfg.ForgeURL) + } + if cfg.ForgeInterval != 60 { + t.Fatalf("expected 60, got %d", cfg.ForgeInterval) + } + if cfg.HFAuthor != "lthn" { + t.Fatalf("expected lthn, got %s", cfg.HFAuthor) + } + if cfg.HFInterval != 300 { + t.Fatalf("expected 300, got %d", cfg.HFInterval) + } + if cfg.TrainingDataDir != "/data/training" { + t.Fatalf("unexpected TrainingDataDir: %s", cfg.TrainingDataDir) + } + if cfg.InfluxDB != "training" { + t.Fatalf("expected training, got %s", cfg.InfluxDB) + } +} + +// ── env override ─────────────────────────────────────────────────── + +func TestLoadConfig_Good_EnvOverride(t *testing.T) { + os.Setenv("ADDR", ":9090") + os.Setenv("FORGE_URL", "https://forge.lthn.ai") + os.Setenv("HF_AUTHOR", "snider") + defer func() { + os.Unsetenv("ADDR") + os.Unsetenv("FORGE_URL") + os.Unsetenv("HF_AUTHOR") + }() + + cfg := LoadConfig() + if cfg.Addr != ":9090" { + t.Fatalf("expected :9090, got %s", cfg.Addr) + } + if cfg.ForgeURL != "https://forge.lthn.ai" { + t.Fatalf("expected forge.lthn.ai, got %s", cfg.ForgeURL) + } + if cfg.HFAuthor != "snider" { + t.Fatalf("expected snider, got %s", cfg.HFAuthor) + } +} + +// ── envInt ───────────────────────────────────────────────────────── + +func TestLoadConfig_Good_IntEnvOverride(t *testing.T) { + os.Setenv("PROMETHEUS_INTERVAL", "30") + defer os.Unsetenv("PROMETHEUS_INTERVAL") + + cfg := LoadConfig() + if cfg.PrometheusInterval != 30 { + t.Fatalf("expected 30, got %d", cfg.PrometheusInterval) + } +} + +func TestLoadConfig_Bad_InvalidIntFallsBack(t *testing.T) { + os.Setenv("PROMETHEUS_INTERVAL", "not-a-number") + defer os.Unsetenv("PROMETHEUS_INTERVAL") + + cfg := LoadConfig() + if cfg.PrometheusInterval != 15 { + t.Fatalf("expected fallback 15, got %d", cfg.PrometheusInterval) + } +} + +// ── env / envInt helpers directly ────────────────────────────────── + +func TestEnv_Good(t *testing.T) { + os.Setenv("TEST_LAB_KEY", "hello") + defer os.Unsetenv("TEST_LAB_KEY") + + if got := env("TEST_LAB_KEY", "default"); got != "hello" { + t.Fatalf("expected hello, got %s", got) + } +} + +func TestEnv_Good_Fallback(t *testing.T) { + os.Unsetenv("TEST_LAB_MISSING") + if got := env("TEST_LAB_MISSING", "fallback"); got != "fallback" { + t.Fatalf("expected fallback, got %s", got) + } +} + +func TestEnvInt_Good(t *testing.T) { + os.Setenv("TEST_LAB_INT", "42") + defer os.Unsetenv("TEST_LAB_INT") + + if got := envInt("TEST_LAB_INT", 0); got != 42 { + t.Fatalf("expected 42, got %d", got) + } +} + +func TestEnvInt_Bad_Fallback(t *testing.T) { + os.Unsetenv("TEST_LAB_INT_MISSING") + if got := envInt("TEST_LAB_INT_MISSING", 99); got != 99 { + t.Fatalf("expected 99, got %d", got) + } +} + +func TestEnvInt_Bad_InvalidString(t *testing.T) { + os.Setenv("TEST_LAB_INT_BAD", "xyz") + defer os.Unsetenv("TEST_LAB_INT_BAD") + + if got := envInt("TEST_LAB_INT_BAD", 7); got != 7 { + t.Fatalf("expected fallback 7, got %d", got) + } +} diff --git a/pkg/lab/store_test.go b/pkg/lab/store_test.go new file mode 100644 index 0000000..6a37646 --- /dev/null +++ b/pkg/lab/store_test.go @@ -0,0 +1,391 @@ +package lab + +import ( + "errors" + "testing" + "time" +) + +// ── NewStore ──────────────────────────────────────────────────────── + +func TestNewStore_Good(t *testing.T) { + s := NewStore() + if s == nil { + t.Fatal("NewStore returned nil") + } + if s.subs == nil { + t.Fatal("subs map not initialised") + } + if s.errors == nil { + t.Fatal("errors map not initialised") + } +} + +// ── Subscribe / Unsubscribe ──────────────────────────────────────── + +func TestSubscribe_Good(t *testing.T) { + s := NewStore() + ch := s.Subscribe() + if ch == nil { + t.Fatal("Subscribe returned nil channel") + } + + s.subMu.Lock() + _, ok := s.subs[ch] + s.subMu.Unlock() + if !ok { + t.Fatal("subscriber not registered") + } +} + +func TestUnsubscribe_Good(t *testing.T) { + s := NewStore() + ch := s.Subscribe() + s.Unsubscribe(ch) + + s.subMu.Lock() + _, ok := s.subs[ch] + s.subMu.Unlock() + if ok { + t.Fatal("subscriber not removed after Unsubscribe") + } +} + +func TestUnsubscribe_Bad_NeverSubscribed(t *testing.T) { + s := NewStore() + ch := make(chan struct{}, 1) + // Should not panic. + s.Unsubscribe(ch) +} + +// ── Notify ───────────────────────────────────────────────────────── + +func TestNotify_Good_SubscriberReceivesSignal(t *testing.T) { + s := NewStore() + ch := s.Subscribe() + defer s.Unsubscribe(ch) + + s.SetMachines([]Machine{{Name: "test"}}) + + select { + case <-ch: + // good + case <-time.After(100 * time.Millisecond): + t.Fatal("subscriber did not receive notification") + } +} + +func TestNotify_Good_NonBlockingWhenFull(t *testing.T) { + s := NewStore() + ch := s.Subscribe() + defer s.Unsubscribe(ch) + + // Fill the buffer. + ch <- struct{}{} + + // Should not block. + s.SetMachines([]Machine{{Name: "a"}}) + s.SetMachines([]Machine{{Name: "b"}}) +} + +func TestNotify_Good_MultipleSubscribers(t *testing.T) { + s := NewStore() + ch1 := s.Subscribe() + ch2 := s.Subscribe() + defer s.Unsubscribe(ch1) + defer s.Unsubscribe(ch2) + + s.SetAgents(AgentSummary{Available: true}) + + for _, ch := range []chan struct{}{ch1, ch2} { + select { + case <-ch: + case <-time.After(100 * time.Millisecond): + t.Fatal("subscriber missed notification") + } + } +} + +// ── SetMachines / Overview ───────────────────────────────────────── + +func TestSetMachines_Good(t *testing.T) { + s := NewStore() + machines := []Machine{{Name: "noc", Host: "77.42.42.205"}, {Name: "de1", Host: "116.202.82.115"}} + s.SetMachines(machines) + + ov := s.Overview() + if len(ov.Machines) != 2 { + t.Fatalf("expected 2 machines, got %d", len(ov.Machines)) + } + if ov.Machines[0].Name != "noc" { + t.Fatalf("expected noc, got %s", ov.Machines[0].Name) + } +} + +func TestOverview_Good_ContainersMergedIntoFirstMachine(t *testing.T) { + s := NewStore() + s.SetMachines([]Machine{{Name: "primary"}, {Name: "secondary"}}) + s.SetContainers([]Container{{Name: "forgejo", Status: "running"}}) + + ov := s.Overview() + if len(ov.Machines[0].Containers) != 1 { + t.Fatal("containers not merged into first machine") + } + if ov.Machines[0].Containers[0].Name != "forgejo" { + t.Fatalf("unexpected container name: %s", ov.Machines[0].Containers[0].Name) + } + if len(ov.Machines[1].Containers) != 0 { + t.Fatal("containers leaked into second machine") + } +} + +func TestOverview_Good_EmptyMachinesNoContainerPanic(t *testing.T) { + s := NewStore() + s.SetContainers([]Container{{Name: "c1"}}) + + // No machines set — should not panic. + ov := s.Overview() + if len(ov.Machines) != 0 { + t.Fatal("expected zero machines") + } +} + +func TestOverview_Good_ErrorsCopied(t *testing.T) { + s := NewStore() + s.SetError("prometheus", errors.New("connection refused")) + + ov := s.Overview() + if ov.Errors["prometheus"] != "connection refused" { + t.Fatal("error not in overview") + } + + // Mutating the copy should not affect the store. + ov.Errors["prometheus"] = "hacked" + ov2 := s.Overview() + if ov2.Errors["prometheus"] != "connection refused" { + t.Fatal("overview errors map is not a copy") + } +} + +// ── SetAgents / GetAgents ────────────────────────────────────────── + +func TestAgents_Good(t *testing.T) { + s := NewStore() + s.SetAgents(AgentSummary{Available: true, RegisteredTotal: 3, QueuePending: 1}) + + got := s.GetAgents() + if !got.Available { + t.Fatal("expected Available=true") + } + if got.RegisteredTotal != 3 { + t.Fatalf("expected 3, got %d", got.RegisteredTotal) + } +} + +// ── SetTraining / GetTraining ────────────────────────────────────── + +func TestTraining_Good(t *testing.T) { + s := NewStore() + s.SetTraining(TrainingSummary{GoldGenerated: 404, GoldTarget: 15000, GoldPercent: 2.69}) + + got := s.GetTraining() + if got.GoldGenerated != 404 { + t.Fatalf("expected 404, got %d", got.GoldGenerated) + } +} + +// ── SetModels / GetModels ────────────────────────────────────────── + +func TestModels_Good(t *testing.T) { + s := NewStore() + s.SetModels([]HFModel{{ModelID: "lthn/lem-gemma3-1b", Downloads: 42}}) + + got := s.GetModels() + if len(got) != 1 { + t.Fatal("expected 1 model") + } + if got[0].Downloads != 42 { + t.Fatalf("expected 42 downloads, got %d", got[0].Downloads) + } +} + +// ── SetCommits ───────────────────────────────────────────────────── + +func TestCommits_Good(t *testing.T) { + s := NewStore() + s.SetCommits([]Commit{{SHA: "abc123", Message: "feat: test coverage", Author: "virgil"}}) + + ov := s.Overview() + if len(ov.Commits) != 1 { + t.Fatal("expected 1 commit") + } + if ov.Commits[0].Author != "virgil" { + t.Fatalf("expected virgil, got %s", ov.Commits[0].Author) + } +} + +// ── SetContainers / GetContainers ────────────────────────────────── + +func TestContainers_Good(t *testing.T) { + s := NewStore() + s.SetContainers([]Container{{Name: "traefik", Status: "running"}, {Name: "forgejo", Status: "running"}}) + + got := s.GetContainers() + if len(got) != 2 { + t.Fatal("expected 2 containers") + } +} + +// ── SetError / GetErrors ─────────────────────────────────────────── + +func TestSetError_Good_SetAndClear(t *testing.T) { + s := NewStore() + s.SetError("hf", errors.New("rate limited")) + + errs := s.GetErrors() + if errs["hf"] != "rate limited" { + t.Fatal("error not stored") + } + + // Clear by passing nil. + s.SetError("hf", nil) + errs = s.GetErrors() + if _, ok := errs["hf"]; ok { + t.Fatal("error not cleared") + } +} + +func TestGetErrors_Good_ReturnsCopy(t *testing.T) { + s := NewStore() + s.SetError("forge", errors.New("timeout")) + + errs := s.GetErrors() + errs["forge"] = "tampered" + + fresh := s.GetErrors() + if fresh["forge"] != "timeout" { + t.Fatal("GetErrors did not return a copy") + } +} + +// ── SetServices / GetServices ────────────────────────────────────── + +func TestServices_Good(t *testing.T) { + s := NewStore() + s.SetServices([]Service{{Name: "Forgejo", URL: "https://forge.lthn.ai", Status: "ok"}}) + + got := s.GetServices() + if len(got) != 1 { + t.Fatal("expected 1 service") + } + if got[0].Name != "Forgejo" { + t.Fatalf("expected Forgejo, got %s", got[0].Name) + } +} + +// ── SetBenchmarks / GetBenchmarks ────────────────────────────────── + +func TestBenchmarks_Good(t *testing.T) { + s := NewStore() + s.SetBenchmarks(BenchmarkData{ + Runs: []BenchmarkRun{{RunID: "run-1", Model: "gemma3-4b", Type: "training"}}, + }) + + got := s.GetBenchmarks() + if len(got.Runs) != 1 { + t.Fatal("expected 1 benchmark run") + } +} + +// ── SetGoldenSet / GetGoldenSet ──────────────────────────────────── + +func TestGoldenSet_Good(t *testing.T) { + s := NewStore() + s.SetGoldenSet(GoldenSetSummary{Available: true, TotalExamples: 15000, TargetTotal: 15000, CompletionPct: 100}) + + got := s.GetGoldenSet() + if !got.Available { + t.Fatal("expected Available=true") + } + if got.TotalExamples != 15000 { + t.Fatalf("expected 15000, got %d", got.TotalExamples) + } +} + +// ── SetTrainingRuns / GetTrainingRuns ─────────────────────────────── + +func TestTrainingRuns_Good(t *testing.T) { + s := NewStore() + s.SetTrainingRuns([]TrainingRunStatus{ + {Model: "gemma3-4b", RunID: "r1", Status: "training", Iteration: 100, TotalIters: 300}, + }) + + got := s.GetTrainingRuns() + if len(got) != 1 { + t.Fatal("expected 1 training run") + } + if got[0].Iteration != 100 { + t.Fatalf("expected iter 100, got %d", got[0].Iteration) + } +} + +// ── SetDataset / GetDataset ──────────────────────────────────────── + +func TestDataset_Good(t *testing.T) { + s := NewStore() + s.SetDataset(DatasetSummary{ + Available: true, + Tables: []DatasetTable{{Name: "golden_set", Rows: 15000}}, + }) + + got := s.GetDataset() + if !got.Available { + t.Fatal("expected Available=true") + } + if len(got.Tables) != 1 { + t.Fatal("expected 1 table") + } +} + +// ── Concurrent access (race detector) ────────────────────────────── + +func TestConcurrentAccess_Good(t *testing.T) { + s := NewStore() + done := make(chan struct{}) + + // Writer goroutine. + go func() { + for i := range 100 { + s.SetMachines([]Machine{{Name: "noc"}}) + s.SetAgents(AgentSummary{Available: true}) + s.SetTraining(TrainingSummary{GoldGenerated: i}) + s.SetModels([]HFModel{{ModelID: "m1"}}) + s.SetCommits([]Commit{{SHA: "abc"}}) + s.SetContainers([]Container{{Name: "c1"}}) + s.SetError("test", errors.New("e")) + s.SetServices([]Service{{Name: "s1"}}) + s.SetBenchmarks(BenchmarkData{}) + s.SetGoldenSet(GoldenSetSummary{}) + s.SetTrainingRuns([]TrainingRunStatus{}) + s.SetDataset(DatasetSummary{}) + } + close(done) + }() + + // Reader goroutine. + for range 100 { + _ = s.Overview() + _ = s.GetModels() + _ = s.GetTraining() + _ = s.GetAgents() + _ = s.GetContainers() + _ = s.GetServices() + _ = s.GetBenchmarks() + _ = s.GetGoldenSet() + _ = s.GetTrainingRuns() + _ = s.GetDataset() + _ = s.GetErrors() + } + + <-done +} diff --git a/pkg/plugin/installer_test.go b/pkg/plugin/installer_test.go index b8afcf4..7c32a75 100644 --- a/pkg/plugin/installer_test.go +++ b/pkg/plugin/installer_test.go @@ -1,11 +1,110 @@ package plugin import ( + "context" "testing" + "forge.lthn.ai/core/go/pkg/io" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// ── NewInstaller ─────────────────────────────────────────────────── + +func TestNewInstaller_Good(t *testing.T) { + m := io.NewMockMedium() + reg := NewRegistry(m, "/plugins") + inst := NewInstaller(m, reg) + + assert.NotNil(t, inst) + assert.Equal(t, m, inst.medium) + assert.Equal(t, reg, inst.registry) +} + +// ── Install error paths ──────────────────────────────────────────── + +func TestInstall_Bad_InvalidSource(t *testing.T) { + m := io.NewMockMedium() + reg := NewRegistry(m, "/plugins") + inst := NewInstaller(m, reg) + + err := inst.Install(context.Background(), "bad-source") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid source") +} + +func TestInstall_Bad_AlreadyInstalled(t *testing.T) { + m := io.NewMockMedium() + reg := NewRegistry(m, "/plugins") + _ = reg.Add(&PluginConfig{Name: "my-plugin", Version: "1.0.0"}) + + inst := NewInstaller(m, reg) + err := inst.Install(context.Background(), "org/my-plugin") + assert.Error(t, err) + assert.Contains(t, err.Error(), "already installed") +} + +// ── Remove ───────────────────────────────────────────────────────── + +func TestRemove_Good(t *testing.T) { + m := io.NewMockMedium() + reg := NewRegistry(m, "/plugins") + _ = reg.Add(&PluginConfig{Name: "removable", Version: "1.0.0"}) + + // Create plugin directory. + _ = m.EnsureDir("/plugins/removable") + _ = m.Write("/plugins/removable/plugin.json", `{"name":"removable"}`) + + inst := NewInstaller(m, reg) + err := inst.Remove("removable") + require.NoError(t, err) + + // Plugin removed from registry. + _, ok := reg.Get("removable") + assert.False(t, ok) + + // Directory cleaned up. + assert.False(t, m.Exists("/plugins/removable")) +} + +func TestRemove_Good_DirAlreadyGone(t *testing.T) { + m := io.NewMockMedium() + reg := NewRegistry(m, "/plugins") + _ = reg.Add(&PluginConfig{Name: "ghost", Version: "1.0.0"}) + // No directory exists — should still succeed. + + inst := NewInstaller(m, reg) + err := inst.Remove("ghost") + require.NoError(t, err) + + _, ok := reg.Get("ghost") + assert.False(t, ok) +} + +func TestRemove_Bad_NotFound(t *testing.T) { + m := io.NewMockMedium() + reg := NewRegistry(m, "/plugins") + inst := NewInstaller(m, reg) + + err := inst.Remove("nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin not found") +} + +// ── Update error paths ───────────────────────────────────────────── + +func TestUpdate_Bad_NotFound(t *testing.T) { + m := io.NewMockMedium() + reg := NewRegistry(m, "/plugins") + inst := NewInstaller(m, reg) + + err := inst.Update(context.Background(), "missing") + assert.Error(t, err) + assert.Contains(t, err.Error(), "plugin not found") +} + +// ── ParseSource ──────────────────────────────────────────────────── + func TestParseSource_Good_OrgRepo(t *testing.T) { org, repo, version, err := ParseSource("host-uk/core-plugin") assert.NoError(t, err) diff --git a/pkg/plugin/registry_test.go b/pkg/plugin/registry_test.go index cedfaed..ff25d33 100644 --- a/pkg/plugin/registry_test.go +++ b/pkg/plugin/registry_test.go @@ -134,3 +134,59 @@ func TestRegistry_Load_Good_EmptyWhenNoFile(t *testing.T) { assert.NoError(t, err) assert.Empty(t, reg.List()) } + +func TestRegistry_Load_Bad_InvalidJSON(t *testing.T) { + m := io.NewMockMedium() + basePath := "/home/user/.core/plugins" + _ = m.Write(basePath+"/registry.json", "not valid json {{{") + + reg := NewRegistry(m, basePath) + err := reg.Load() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse registry") +} + +func TestRegistry_Load_Good_NullJSON(t *testing.T) { + m := io.NewMockMedium() + basePath := "/home/user/.core/plugins" + _ = m.Write(basePath+"/registry.json", "null") + + reg := NewRegistry(m, basePath) + err := reg.Load() + assert.NoError(t, err) + assert.Empty(t, reg.List()) +} + +func TestRegistry_Save_Good_CreatesDir(t *testing.T) { + m := io.NewMockMedium() + basePath := "/home/user/.core/plugins" + reg := NewRegistry(m, basePath) + + _ = reg.Add(&PluginConfig{Name: "test", Version: "1.0.0"}) + err := reg.Save() + assert.NoError(t, err) + + // Verify file was written. + assert.True(t, m.IsFile(basePath+"/registry.json")) +} + +func TestRegistry_List_Good_Sorted(t *testing.T) { + m := io.NewMockMedium() + reg := NewRegistry(m, "/plugins") + + _ = reg.Add(&PluginConfig{Name: "zebra", Version: "1.0.0"}) + _ = reg.Add(&PluginConfig{Name: "alpha", Version: "1.0.0"}) + _ = reg.Add(&PluginConfig{Name: "middle", Version: "1.0.0"}) + + list := reg.List() + assert.Len(t, list, 3) + assert.Equal(t, "alpha", list[0].Name) + assert.Equal(t, "middle", list[1].Name) + assert.Equal(t, "zebra", list[2].Name) +} + +func TestRegistry_RegistryPath_Good(t *testing.T) { + m := io.NewMockMedium() + reg := NewRegistry(m, "/base/path") + assert.Equal(t, "/base/path/registry.json", reg.registryPath()) +} diff --git a/pkg/repos/registry_test.go b/pkg/repos/registry_test.go index 52e417d..1b5ad1a 100644 --- a/pkg/repos/registry_test.go +++ b/pkg/repos/registry_test.go @@ -5,9 +5,12 @@ import ( "forge.lthn.ai/core/go/pkg/io" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestLoadRegistry(t *testing.T) { +// ── LoadRegistry ─────────────────────────────────────────────────── + +func TestLoadRegistry_Good(t *testing.T) { m := io.NewMockMedium() yaml := ` version: 1 @@ -34,7 +37,401 @@ repos: assert.Equal(t, reg, repo.registry) } -func TestRepo_Exists(t *testing.T) { +func TestLoadRegistry_Good_WithDefaults(t *testing.T) { + m := io.NewMockMedium() + yaml := ` +version: 1 +org: host-uk +base_path: /tmp/repos +defaults: + ci: github-actions + license: EUPL-1.2 + branch: main +repos: + core-php: + type: foundation + description: Foundation + core-admin: + type: module + description: Admin panel +` + _ = m.Write("/tmp/repos.yaml", yaml) + + reg, err := LoadRegistry(m, "/tmp/repos.yaml") + require.NoError(t, err) + + php, ok := reg.Get("core-php") + require.True(t, ok) + assert.Equal(t, "github-actions", php.CI) + + admin, ok := reg.Get("core-admin") + require.True(t, ok) + assert.Equal(t, "github-actions", admin.CI) +} + +func TestLoadRegistry_Good_CustomRepoPath(t *testing.T) { + m := io.NewMockMedium() + yaml := ` +version: 1 +org: host-uk +base_path: /tmp/repos +repos: + special: + type: module + path: /opt/special-repo +` + _ = m.Write("/tmp/repos.yaml", yaml) + + reg, err := LoadRegistry(m, "/tmp/repos.yaml") + require.NoError(t, err) + + repo, ok := reg.Get("special") + require.True(t, ok) + assert.Equal(t, "/opt/special-repo", repo.Path) +} + +func TestLoadRegistry_Good_CIOverride(t *testing.T) { + m := io.NewMockMedium() + yaml := ` +version: 1 +org: test +base_path: /tmp +defaults: + ci: default-ci +repos: + a: + type: module + b: + type: module + ci: custom-ci +` + _ = m.Write("/tmp/repos.yaml", yaml) + + reg, err := LoadRegistry(m, "/tmp/repos.yaml") + require.NoError(t, err) + + a, _ := reg.Get("a") + assert.Equal(t, "default-ci", a.CI) + + b, _ := reg.Get("b") + assert.Equal(t, "custom-ci", b.CI) +} + +func TestLoadRegistry_Bad_FileNotFound(t *testing.T) { + m := io.NewMockMedium() + _, err := LoadRegistry(m, "/nonexistent/repos.yaml") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read") +} + +func TestLoadRegistry_Bad_InvalidYAML(t *testing.T) { + m := io.NewMockMedium() + _ = m.Write("/tmp/bad.yaml", "{{{{not yaml at all") + + _, err := LoadRegistry(m, "/tmp/bad.yaml") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse") +} + +// ── List / Get / ByType ──────────────────────────────────────────── + +func newTestRegistry(t *testing.T) *Registry { + t.Helper() + m := io.NewMockMedium() + yaml := ` +version: 1 +org: host-uk +base_path: /tmp/repos +repos: + core-php: + type: foundation + description: Foundation + core-admin: + type: module + depends_on: [core-php] + description: Admin + core-tenant: + type: module + depends_on: [core-php] + description: Tenancy + core-bio: + type: product + depends_on: [core-php, core-tenant] + description: Bio product +` + _ = m.Write("/tmp/repos.yaml", yaml) + reg, err := LoadRegistry(m, "/tmp/repos.yaml") + require.NoError(t, err) + return reg +} + +func TestRegistry_List_Good(t *testing.T) { + reg := newTestRegistry(t) + repos := reg.List() + assert.Len(t, repos, 4) +} + +func TestRegistry_Get_Good(t *testing.T) { + reg := newTestRegistry(t) + repo, ok := reg.Get("core-php") + assert.True(t, ok) + assert.Equal(t, "core-php", repo.Name) +} + +func TestRegistry_Get_Bad_NotFound(t *testing.T) { + reg := newTestRegistry(t) + _, ok := reg.Get("nonexistent") + assert.False(t, ok) +} + +func TestRegistry_ByType_Good(t *testing.T) { + reg := newTestRegistry(t) + + foundations := reg.ByType("foundation") + assert.Len(t, foundations, 1) + assert.Equal(t, "core-php", foundations[0].Name) + + modules := reg.ByType("module") + assert.Len(t, modules, 2) + + products := reg.ByType("product") + assert.Len(t, products, 1) +} + +func TestRegistry_ByType_Good_NoMatch(t *testing.T) { + reg := newTestRegistry(t) + templates := reg.ByType("template") + assert.Empty(t, templates) +} + +// ── TopologicalOrder ─────────────────────────────────────────────── + +func TestTopologicalOrder_Good(t *testing.T) { + reg := newTestRegistry(t) + order, err := TopologicalOrder(reg) + require.NoError(t, err) + assert.Len(t, order, 4) + + // core-php must come before everything that depends on it. + phpIdx := -1 + for i, r := range order { + if r.Name == "core-php" { + phpIdx = i + break + } + } + require.GreaterOrEqual(t, phpIdx, 0, "core-php not found") + + for i, r := range order { + for _, dep := range r.DependsOn { + depIdx := -1 + for j, d := range order { + if d.Name == dep { + depIdx = j + break + } + } + assert.Less(t, depIdx, i, "%s should come before %s", dep, r.Name) + } + } +} + +func TopologicalOrder(reg *Registry) ([]*Repo, error) { + return reg.TopologicalOrder() +} + +func TestTopologicalOrder_Bad_CircularDep(t *testing.T) { + m := io.NewMockMedium() + yaml := ` +version: 1 +org: test +base_path: /tmp +repos: + a: + type: module + depends_on: [b] + b: + type: module + depends_on: [a] +` + _ = m.Write("/tmp/repos.yaml", yaml) + reg, err := LoadRegistry(m, "/tmp/repos.yaml") + require.NoError(t, err) + + _, err = reg.TopologicalOrder() + assert.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") +} + +func TestTopologicalOrder_Bad_UnknownDep(t *testing.T) { + m := io.NewMockMedium() + yaml := ` +version: 1 +org: test +base_path: /tmp +repos: + a: + type: module + depends_on: [nonexistent] +` + _ = m.Write("/tmp/repos.yaml", yaml) + reg, err := LoadRegistry(m, "/tmp/repos.yaml") + require.NoError(t, err) + + _, err = reg.TopologicalOrder() + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown repo") +} + +func TestTopologicalOrder_Good_NoDeps(t *testing.T) { + m := io.NewMockMedium() + yaml := ` +version: 1 +org: test +base_path: /tmp +repos: + a: + type: module + b: + type: module +` + _ = m.Write("/tmp/repos.yaml", yaml) + reg, err := LoadRegistry(m, "/tmp/repos.yaml") + require.NoError(t, err) + + order, err := reg.TopologicalOrder() + require.NoError(t, err) + assert.Len(t, order, 2) +} + +// ── ScanDirectory ────────────────────────────────────────────────── + +func TestScanDirectory_Good(t *testing.T) { + m := io.NewMockMedium() + + // Create mock repos with .git dirs. + _ = m.EnsureDir("/workspace/repo-a/.git") + _ = m.EnsureDir("/workspace/repo-b/.git") + _ = m.EnsureDir("/workspace/not-a-repo") // No .git + + // Write a file (not a dir) at top level. + _ = m.Write("/workspace/README.md", "hello") + + reg, err := ScanDirectory(m, "/workspace") + require.NoError(t, err) + + assert.Len(t, reg.Repos, 2) + + a, ok := reg.Repos["repo-a"] + assert.True(t, ok) + assert.Equal(t, "/workspace/repo-a", a.Path) + assert.Equal(t, "module", a.Type) // Default type. + + _, ok = reg.Repos["not-a-repo"] + assert.False(t, ok) +} + +func TestScanDirectory_Good_DetectsGitHubOrg(t *testing.T) { + m := io.NewMockMedium() + + _ = m.EnsureDir("/workspace/my-repo/.git") + _ = m.Write("/workspace/my-repo/.git/config", `[core] + repositoryformatversion = 0 +[remote "origin"] + url = git@github.com:host-uk/my-repo.git + fetch = +refs/heads/*:refs/remotes/origin/* +`) + + reg, err := ScanDirectory(m, "/workspace") + require.NoError(t, err) + assert.Equal(t, "host-uk", reg.Org) +} + +func TestScanDirectory_Good_DetectsHTTPSOrg(t *testing.T) { + m := io.NewMockMedium() + + _ = m.EnsureDir("/workspace/my-repo/.git") + _ = m.Write("/workspace/my-repo/.git/config", `[remote "origin"] + url = https://github.com/lethean-io/my-repo.git +`) + + reg, err := ScanDirectory(m, "/workspace") + require.NoError(t, err) + assert.Equal(t, "lethean-io", reg.Org) +} + +func TestScanDirectory_Good_EmptyDir(t *testing.T) { + m := io.NewMockMedium() + _ = m.EnsureDir("/empty") + + reg, err := ScanDirectory(m, "/empty") + require.NoError(t, err) + assert.Empty(t, reg.Repos) + assert.Equal(t, "", reg.Org) +} + +func TestScanDirectory_Bad_InvalidDir(t *testing.T) { + m := io.NewMockMedium() + _, err := ScanDirectory(m, "/nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read directory") +} + +// ── detectOrg ────────────────────────────────────────────────────── + +func TestDetectOrg_Good_SSHRemote(t *testing.T) { + m := io.NewMockMedium() + _ = m.Write("/repo/.git/config", `[remote "origin"] + url = git@github.com:host-uk/core.git +`) + assert.Equal(t, "host-uk", detectOrg(m, "/repo")) +} + +func TestDetectOrg_Good_HTTPSRemote(t *testing.T) { + m := io.NewMockMedium() + _ = m.Write("/repo/.git/config", `[remote "origin"] + url = https://github.com/snider/project.git +`) + assert.Equal(t, "snider", detectOrg(m, "/repo")) +} + +func TestDetectOrg_Bad_NoConfig(t *testing.T) { + m := io.NewMockMedium() + assert.Equal(t, "", detectOrg(m, "/nonexistent")) +} + +func TestDetectOrg_Bad_NoRemote(t *testing.T) { + m := io.NewMockMedium() + _ = m.Write("/repo/.git/config", `[core] + repositoryformatversion = 0 +`) + assert.Equal(t, "", detectOrg(m, "/repo")) +} + +func TestDetectOrg_Bad_NonGitHubRemote(t *testing.T) { + m := io.NewMockMedium() + _ = m.Write("/repo/.git/config", `[remote "origin"] + url = ssh://git@forge.lthn.ai:2223/core/go.git +`) + assert.Equal(t, "", detectOrg(m, "/repo")) +} + +// ── expandPath ───────────────────────────────────────────────────── + +func TestExpandPath_Good_Tilde(t *testing.T) { + got := expandPath("~/Code/repos") + assert.NotContains(t, got, "~") + assert.Contains(t, got, "Code/repos") +} + +func TestExpandPath_Good_NoTilde(t *testing.T) { + assert.Equal(t, "/absolute/path", expandPath("/absolute/path")) + assert.Equal(t, "relative/path", expandPath("relative/path")) +} + +// ── Repo.Exists / IsGitRepo ─────────────────────────────────────── + +func TestRepo_Exists_Good(t *testing.T) { m := io.NewMockMedium() reg := &Registry{ medium: m, @@ -47,15 +444,13 @@ func TestRepo_Exists(t *testing.T) { registry: reg, } - // Not exists yet assert.False(t, repo.Exists()) - // Create directory in mock _ = m.EnsureDir("/tmp/repos/core") assert.True(t, repo.Exists()) } -func TestRepo_IsGitRepo(t *testing.T) { +func TestRepo_IsGitRepo_Good(t *testing.T) { m := io.NewMockMedium() reg := &Registry{ medium: m, @@ -68,10 +463,24 @@ func TestRepo_IsGitRepo(t *testing.T) { registry: reg, } - // Not a git repo yet assert.False(t, repo.IsGitRepo()) - // Create .git directory in mock _ = m.EnsureDir("/tmp/repos/core/.git") assert.True(t, repo.IsGitRepo()) } + +// ── getMedium fallback ───────────────────────────────────────────── + +func TestGetMedium_Good_FallbackToLocal(t *testing.T) { + repo := &Repo{Name: "orphan", Path: "/tmp/orphan"} + // No registry set — should fall back to io.Local. + m := repo.getMedium() + assert.Equal(t, io.Local, m) +} + +func TestGetMedium_Good_NilMediumFallback(t *testing.T) { + reg := &Registry{} // medium is nil. + repo := &Repo{Name: "test", registry: reg} + m := repo.getMedium() + assert.Equal(t, io.Local, m) +} diff --git a/pkg/session/html_test.go b/pkg/session/html_test.go new file mode 100644 index 0000000..9b0a98d --- /dev/null +++ b/pkg/session/html_test.go @@ -0,0 +1,194 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRenderHTML_Good_BasicSession(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "session.html") + + sess := &Session{ + ID: "f3fb074c-8c72-4da6-a15a-85bae652ccaa", + StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 2, 24, 10, 5, 0, 0, time.UTC), + Events: []Event{ + { + Timestamp: time.Date(2026, 2, 24, 10, 0, 5, 0, time.UTC), + Type: "tool_use", + Tool: "Bash", + Input: "go test ./...", + Output: "ok forge.lthn.ai/core/go 1.2s", + Duration: time.Second, + Success: true, + }, + { + Timestamp: time.Date(2026, 2, 24, 10, 1, 0, 0, time.UTC), + Type: "tool_use", + Tool: "Read", + Input: "/tmp/test.go", + Output: "package main", + Duration: 200 * time.Millisecond, + Success: true, + }, + { + Timestamp: time.Date(2026, 2, 24, 10, 2, 0, 0, time.UTC), + Type: "user", + Input: "looks good", + }, + }, + } + + if err := RenderHTML(sess, out); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + + html := string(data) + if !strings.Contains(html, "f3fb074c") { + t.Fatal("missing session ID") + } + if !strings.Contains(html, "go test ./...") { + t.Fatal("missing bash command") + } + if !strings.Contains(html, "2 tool calls") { + t.Fatal("missing tool count") + } + if !strings.Contains(html, "filterEvents") { + t.Fatal("missing JS filter function") + } +} + +func TestRenderHTML_Good_WithErrors(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "errors.html") + + sess := &Session{ + ID: "err-session", + StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 2, 24, 10, 1, 0, 0, time.UTC), + Events: []Event{ + { + Type: "tool_use", Tool: "Bash", + Timestamp: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), + Input: "bad command", Output: "error", Success: false, + }, + }, + } + + if err := RenderHTML(sess, out); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(out) + html := string(data) + if !strings.Contains(html, "1 errors") { + t.Fatal("missing error count") + } + if !strings.Contains(html, `class="event error"`) { + t.Fatal("missing error class") + } + if !strings.Contains(html, "✗") { + t.Fatal("missing failure icon") + } +} + +func TestRenderHTML_Good_AssistantEvent(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "asst.html") + + sess := &Session{ + ID: "asst-test", + StartTime: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 2, 24, 10, 0, 5, 0, time.UTC), + Events: []Event{ + { + Type: "assistant", + Timestamp: time.Date(2026, 2, 24, 10, 0, 0, 0, time.UTC), + Input: "Let me check that.", + }, + }, + } + + if err := RenderHTML(sess, out); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(out) + if !strings.Contains(string(data), "Claude") { + t.Fatal("missing Claude label for assistant") + } +} + +func TestRenderHTML_Good_EmptySession(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "empty.html") + + sess := &Session{ + ID: "empty", + StartTime: time.Now(), + EndTime: time.Now(), + } + + if err := RenderHTML(sess, out); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(out) + if err != nil { + t.Fatal(err) + } + if info.Size() == 0 { + t.Fatal("HTML file is empty") + } +} + +func TestRenderHTML_Bad_InvalidPath(t *testing.T) { + sess := &Session{ID: "test", StartTime: time.Now(), EndTime: time.Now()} + err := RenderHTML(sess, "/nonexistent/dir/out.html") + if err == nil { + t.Fatal("expected error for invalid path") + } +} + +func TestRenderHTML_Good_XSSEscaping(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "xss.html") + + sess := &Session{ + ID: "xss-test", + StartTime: time.Now(), + EndTime: time.Now(), + Events: []Event{ + { + Type: "tool_use", + Tool: "Bash", + Timestamp: time.Now(), + Input: `echo ""`, + Output: ``, + Success: true, + }, + }, + } + + if err := RenderHTML(sess, out); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(out) + html := string(data) + if strings.Contains(html, "