summaryrefslogtreecommitdiffstats
path: root/pkg
diff options
context:
space:
mode:
Diffstat (limited to 'pkg')
-rw-r--r--pkg/crypto/crypto_test.go440
-rw-r--r--pkg/identity/identity_test.go426
-rw-r--r--pkg/protocol/messages_test.go428
-rw-r--r--pkg/tor/tor_test.go282
4 files changed, 1576 insertions, 0 deletions
diff --git a/pkg/crypto/crypto_test.go b/pkg/crypto/crypto_test.go
new file mode 100644
index 0000000..1d76767
--- /dev/null
+++ b/pkg/crypto/crypto_test.go
@@ -0,0 +1,440 @@
+package crypto
+
+import (
+ "bytes"
+ "crypto/cipher"
+ "net"
+ "testing"
+ "time"
+)
+
+func TestPadMessage(t *testing.T) {
+ tests := []struct {
+ name string
+ input []byte
+ wantSize int // Expected padded size (2 + paddedLen)
+ }{
+ {"empty", []byte{}, 2 + MessageBlockSize},
+ {"small", []byte("hello"), 2 + MessageBlockSize},
+ {"exact block minus header", make([]byte, MessageBlockSize-2), 2 + MessageBlockSize},
+ {"one block", make([]byte, MessageBlockSize), 2 + MessageBlockSize*2},
+ {"large", make([]byte, MessageBlockSize*2+50), 2 + MessageBlockSize*3},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ padded, err := PadMessage(tt.input)
+ if err != nil {
+ t.Fatalf("PadMessage() error = %v", err)
+ }
+
+ if len(padded) != tt.wantSize {
+ t.Errorf("PadMessage() size = %d, want %d", len(padded), tt.wantSize)
+ }
+
+ // Verify we can unpad correctly
+ unpadded, err := UnpadMessage(padded)
+ if err != nil {
+ t.Fatalf("UnpadMessage() error = %v", err)
+ }
+
+ if !bytes.Equal(unpadded, tt.input) {
+ t.Errorf("UnpadMessage() = %v, want %v", unpadded, tt.input)
+ }
+ })
+ }
+}
+
+func TestUnpadMessage_Errors(t *testing.T) {
+ tests := []struct {
+ name string
+ input []byte
+ wantErr bool
+ }{
+ {"nil", nil, true},
+ {"empty", []byte{}, true},
+ {"too short", []byte{0x00}, true},
+ {"invalid length", []byte{0xFF, 0xFF, 0x00}, true}, // Claims 65535 bytes but only has 1
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := UnpadMessage(tt.input)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("UnpadMessage() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestPadUnpadRoundTrip(t *testing.T) {
+ messages := []string{
+ "",
+ "Hello, World!",
+ "This is a longer message that spans multiple words",
+ string(make([]byte, 1000)), // Large message
+ }
+
+ for _, msg := range messages {
+ padded, err := PadMessage([]byte(msg))
+ if err != nil {
+ t.Fatalf("PadMessage(%q) error = %v", msg, err)
+ }
+
+ unpadded, err := UnpadMessage(padded)
+ if err != nil {
+ t.Fatalf("UnpadMessage() error = %v", err)
+ }
+
+ if string(unpadded) != msg {
+ t.Errorf("Round trip failed: got %q, want %q", string(unpadded), msg)
+ }
+ }
+}
+
+func TestDeriveIdentity(t *testing.T) {
+ pubKey := []byte("test-public-key-32-bytes-long!!!")
+
+ identity := DeriveIdentity(pubKey)
+
+ // Should start with "anon_"
+ if len(identity) < 5 || identity[:5] != "anon_" {
+ t.Errorf("DeriveIdentity() = %q, should start with 'anon_'", identity)
+ }
+
+ // Should be deterministic
+ identity2 := DeriveIdentity(pubKey)
+ if identity != identity2 {
+ t.Errorf("DeriveIdentity() not deterministic: %q != %q", identity, identity2)
+ }
+
+ // Different input should produce different output
+ identity3 := DeriveIdentity([]byte("different-key-32-bytes-long!!!!"))
+ if identity == identity3 {
+ t.Error("DeriveIdentity() should produce different output for different input")
+ }
+}
+
+func TestDeriveFingerprint(t *testing.T) {
+ pubKey := []byte("test-public-key-32-bytes-long!!!")
+
+ fp := DeriveFingerprint(pubKey)
+
+ // Should be 32 hex chars (16 bytes)
+ if len(fp) != 32 {
+ t.Errorf("DeriveFingerprint() length = %d, want 32", len(fp))
+ }
+
+ // Should be deterministic
+ fp2 := DeriveFingerprint(pubKey)
+ if fp != fp2 {
+ t.Errorf("DeriveFingerprint() not deterministic: %q != %q", fp, fp2)
+ }
+}
+
+func TestSetupAESGCM(t *testing.T) {
+ // Valid 32-byte key
+ validKey := make([]byte, 32)
+ for i := range validKey {
+ validKey[i] = byte(i)
+ }
+
+ gcm, err := SetupAESGCM(validKey)
+ if err != nil {
+ t.Fatalf("SetupAESGCM() error = %v", err)
+ }
+
+ if gcm == nil {
+ t.Error("SetupAESGCM() returned nil")
+ }
+
+ // Too short key
+ _, err = SetupAESGCM([]byte("short"))
+ if err == nil {
+ t.Error("SetupAESGCM() should fail with short key")
+ }
+}
+
+func TestEncryptDecrypt(t *testing.T) {
+ key := make([]byte, 32)
+ for i := range key {
+ key[i] = byte(i)
+ }
+
+ gcm, err := SetupAESGCM(key)
+ if err != nil {
+ t.Fatalf("SetupAESGCM() error = %v", err)
+ }
+
+ plaintext := []byte("Hello, secure world!")
+
+ // Encrypt
+ ciphertext, err := Encrypt(gcm, plaintext)
+ if err != nil {
+ t.Fatalf("Encrypt() error = %v", err)
+ }
+
+ // Ciphertext should be different from plaintext
+ if bytes.Equal(ciphertext, plaintext) {
+ t.Error("Encrypt() ciphertext equals plaintext")
+ }
+
+ // Should include nonce (12 bytes) + ciphertext + tag
+ if len(ciphertext) < NonceSize+len(plaintext) {
+ t.Errorf("Encrypt() ciphertext too short: %d", len(ciphertext))
+ }
+
+ // Decrypt
+ decrypted, err := Decrypt(gcm, ciphertext)
+ if err != nil {
+ t.Fatalf("Decrypt() error = %v", err)
+ }
+
+ if !bytes.Equal(decrypted, plaintext) {
+ t.Errorf("Decrypt() = %q, want %q", string(decrypted), string(plaintext))
+ }
+}
+
+func TestEncryptDecrypt_DifferentNonces(t *testing.T) {
+ key := make([]byte, 32)
+ gcm, _ := SetupAESGCM(key)
+
+ plaintext := []byte("Same message")
+
+ // Encrypt twice
+ ct1, _ := Encrypt(gcm, plaintext)
+ ct2, _ := Encrypt(gcm, plaintext)
+
+ // Ciphertexts should be different (different nonces)
+ if bytes.Equal(ct1, ct2) {
+ t.Error("Encrypt() should produce different ciphertext each time (different nonces)")
+ }
+
+ // But both should decrypt to same plaintext
+ pt1, _ := Decrypt(gcm, ct1)
+ pt2, _ := Decrypt(gcm, ct2)
+
+ if !bytes.Equal(pt1, pt2) {
+ t.Error("Both ciphertexts should decrypt to same plaintext")
+ }
+}
+
+func TestDecrypt_Errors(t *testing.T) {
+ key := make([]byte, 32)
+ gcm, _ := SetupAESGCM(key)
+
+ tests := []struct {
+ name string
+ input []byte
+ }{
+ {"nil", nil},
+ {"empty", []byte{}},
+ {"too short", []byte("short")},
+ {"invalid ciphertext", make([]byte, 50)}, // Random bytes won't decrypt
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := Decrypt(gcm, tt.input)
+ if err == nil {
+ t.Error("Decrypt() should fail")
+ }
+ })
+ }
+}
+
+func TestEncryptDecryptMessage(t *testing.T) {
+ key := make([]byte, 32)
+ gcm, _ := SetupAESGCM(key)
+
+ message := "Hello, this is a test message!"
+
+ // Encrypt (note: this includes random delay, may be slow)
+ encrypted, err := EncryptMessage(gcm, message)
+ if err != nil {
+ t.Fatalf("EncryptMessage() error = %v", err)
+ }
+
+ // Decrypt
+ decrypted, err := DecryptMessage(gcm, encrypted)
+ if err != nil {
+ t.Fatalf("DecryptMessage() error = %v", err)
+ }
+
+ if decrypted != message {
+ t.Errorf("DecryptMessage() = %q, want %q", decrypted, message)
+ }
+}
+
+func TestGenerateX25519KeyPair(t *testing.T) {
+ key1, err := GenerateX25519KeyPair()
+ if err != nil {
+ t.Fatalf("GenerateX25519KeyPair() error = %v", err)
+ }
+
+ if key1 == nil {
+ t.Fatal("GenerateX25519KeyPair() returned nil")
+ }
+
+ // Public key should be 32 bytes
+ pubKey := key1.PublicKey().Bytes()
+ if len(pubKey) != 32 {
+ t.Errorf("Public key length = %d, want 32", len(pubKey))
+ }
+
+ // Generate another - should be different
+ key2, _ := GenerateX25519KeyPair()
+ if bytes.Equal(key1.PublicKey().Bytes(), key2.PublicKey().Bytes()) {
+ t.Error("Two generated keys should be different")
+ }
+}
+
+func TestPerformECDH(t *testing.T) {
+ // Generate two key pairs
+ aliceKey, _ := GenerateX25519KeyPair()
+ bobKey, _ := GenerateX25519KeyPair()
+
+ // Alice computes shared secret with Bob's public key
+ aliceShared, err := PerformECDH(aliceKey, bobKey.PublicKey().Bytes())
+ if err != nil {
+ t.Fatalf("PerformECDH(alice) error = %v", err)
+ }
+
+ // Bob computes shared secret with Alice's public key
+ bobShared, err := PerformECDH(bobKey, aliceKey.PublicKey().Bytes())
+ if err != nil {
+ t.Fatalf("PerformECDH(bob) error = %v", err)
+ }
+
+ // Both should arrive at the same shared secret
+ if !bytes.Equal(aliceShared, bobShared) {
+ t.Error("ECDH shared secrets don't match")
+ }
+
+ // Shared secret should be 32 bytes
+ if len(aliceShared) != 32 {
+ t.Errorf("Shared secret length = %d, want 32", len(aliceShared))
+ }
+}
+
+func TestPerformECDH_InvalidKey(t *testing.T) {
+ aliceKey, _ := GenerateX25519KeyPair()
+
+ // Invalid public key (wrong size)
+ _, err := PerformECDH(aliceKey, []byte("too short"))
+ if err == nil {
+ t.Error("PerformECDH() should fail with invalid public key")
+ }
+}
+
+func TestSecureBuffer(t *testing.T) {
+ secret := []byte("super-secret-data-here!")
+
+ enclave := SecureBuffer(secret)
+ if enclave == nil {
+ t.Fatal("SecureBuffer() returned nil")
+ }
+
+ // Original data should be zeroed (memguard behavior)
+ // We can't easily test this without accessing memguard internals
+}
+
+// Mock connection for auth tests
+type mockConn struct {
+ readBuf *bytes.Buffer
+ writeBuf *bytes.Buffer
+}
+
+func newMockConnPair() (*mockConn, *mockConn) {
+ buf1 := &bytes.Buffer{}
+ buf2 := &bytes.Buffer{}
+ return &mockConn{readBuf: buf1, writeBuf: buf2},
+ &mockConn{readBuf: buf2, writeBuf: buf1}
+}
+
+func (m *mockConn) Read(b []byte) (n int, err error) { return m.readBuf.Read(b) }
+func (m *mockConn) Write(b []byte) (n int, err error) { return m.writeBuf.Write(b) }
+func (m *mockConn) Close() error { return nil }
+func (m *mockConn) LocalAddr() net.Addr { return nil }
+func (m *mockConn) RemoteAddr() net.Addr { return nil }
+func (m *mockConn) SetDeadline(t time.Time) error { return nil }
+func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
+func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
+
+func TestMutualAuth(t *testing.T) {
+ sharedSecret := make([]byte, 32)
+ for i := range sharedSecret {
+ sharedSecret[i] = byte(i)
+ }
+
+ // Create connected mock pair
+ serverConn, clientConn := newMockConnPair()
+
+ // Run server auth in goroutine
+ serverErr := make(chan error, 1)
+ go func() {
+ serverErr <- PerformServerAuth(serverConn, sharedSecret)
+ }()
+
+ // Small delay to ensure server sends challenge first
+ time.Sleep(10 * time.Millisecond)
+
+ // Run client auth
+ err := PerformClientAuth(clientConn, sharedSecret)
+ if err != nil {
+ t.Fatalf("PerformClientAuth() error = %v", err)
+ }
+
+ // Check server result
+ select {
+ case err := <-serverErr:
+ if err != nil {
+ t.Fatalf("PerformServerAuth() error = %v", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("Server auth timed out")
+ }
+}
+
+func TestRandomDelay(t *testing.T) {
+ start := time.Now()
+ err := RandomDelay()
+ elapsed := time.Since(start)
+
+ if err != nil {
+ t.Fatalf("RandomDelay() error = %v", err)
+ }
+
+ // Should be at least MinRandomDelay
+ if elapsed < time.Duration(MinRandomDelay)*time.Millisecond {
+ t.Errorf("RandomDelay() too fast: %v", elapsed)
+ }
+
+ // Should be at most MaxRandomDelay (with some buffer)
+ if elapsed > time.Duration(MaxRandomDelay+50)*time.Millisecond {
+ t.Errorf("RandomDelay() too slow: %v", elapsed)
+ }
+}
+
+// Benchmark tests
+func BenchmarkPadMessage(b *testing.B) {
+ msg := []byte("This is a test message for benchmarking")
+ for i := 0; i < b.N; i++ {
+ PadMessage(msg)
+ }
+}
+
+func BenchmarkEncryptDecrypt(b *testing.B) {
+ key := make([]byte, 32)
+ gcm, _ := SetupAESGCM(key)
+ plaintext := []byte("Benchmark message for encryption testing")
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ ct, _ := Encrypt(gcm, plaintext)
+ Decrypt(gcm, ct)
+ }
+}
+
+// Helper to satisfy cipher.AEAD interface check
+var _ cipher.AEAD = (cipher.AEAD)(nil)
diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go
new file mode 100644
index 0000000..40981ad
--- /dev/null
+++ b/pkg/identity/identity_test.go
@@ -0,0 +1,426 @@
+package identity
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestGetDefaultKeyPath(t *testing.T) {
+ path, err := GetDefaultKeyPath("test.key")
+ if err != nil {
+ t.Fatalf("GetDefaultKeyPath() error = %v", err)
+ }
+
+ if !strings.Contains(path, DefaultKeyDir) {
+ t.Errorf("Path should contain %q: %s", DefaultKeyDir, path)
+ }
+
+ if !strings.HasSuffix(path, "test.key") {
+ t.Errorf("Path should end with 'test.key': %s", path)
+ }
+}
+
+func TestNew(t *testing.T) {
+ id, err := New("test")
+ if err != nil {
+ t.Fatalf("New() error = %v", err)
+ }
+
+ if id.PrivateKey == nil {
+ t.Error("PrivateKey should not be nil")
+ }
+ if id.PublicKey == nil {
+ t.Error("PublicKey should not be nil")
+ }
+ if !strings.HasPrefix(id.Username, "test_") {
+ t.Errorf("Username should start with 'test_': %s", id.Username)
+ }
+ if len(id.Fingerprint) != 32 {
+ t.Errorf("Fingerprint length = %d, want 32", len(id.Fingerprint))
+ }
+}
+
+func TestNew_DifferentEachTime(t *testing.T) {
+ id1, _ := New("test")
+ id2, _ := New("test")
+
+ if id1.Fingerprint == id2.Fingerprint {
+ t.Error("Two New() calls should produce different identities")
+ }
+}
+
+func TestIdentity_SaveAndLoad(t *testing.T) {
+ // Create temp directory
+ tmpDir, err := os.MkdirTemp("", "noshitalk-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ keyPath := filepath.Join(tmpDir, "test-identity.noshikey")
+
+ // Create and save identity
+ original, err := New("test")
+ if err != nil {
+ t.Fatalf("New() error = %v", err)
+ }
+
+ err = original.Save(keyPath)
+ if err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+
+ // Verify file exists
+ if _, err := os.Stat(keyPath); os.IsNotExist(err) {
+ t.Fatal("Key file was not created")
+ }
+
+ // Load identity
+ loaded, err := Load(keyPath)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ // Compare
+ if loaded.Username != original.Username {
+ t.Errorf("Username mismatch: %q != %q", loaded.Username, original.Username)
+ }
+ if loaded.Fingerprint != original.Fingerprint {
+ t.Errorf("Fingerprint mismatch: %q != %q", loaded.Fingerprint, original.Fingerprint)
+ }
+
+ // Private keys should be functionally equivalent
+ origPubKey := original.PrivateKey.PublicKey().Bytes()
+ loadedPubKey := loaded.PrivateKey.PublicKey().Bytes()
+ if hex.EncodeToString(origPubKey) != hex.EncodeToString(loadedPubKey) {
+ t.Error("Public keys derived from private keys don't match")
+ }
+}
+
+func TestIdentity_SaveRaw(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "noshitalk-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ keyPath := filepath.Join(tmpDir, "raw.key")
+
+ // Create and save identity in raw format
+ original, _ := New("test")
+ err = original.SaveRaw(keyPath)
+ if err != nil {
+ t.Fatalf("SaveRaw() error = %v", err)
+ }
+
+ // Verify file is 32 bytes (raw private key)
+ data, err := os.ReadFile(keyPath)
+ if err != nil {
+ t.Fatalf("ReadFile() error = %v", err)
+ }
+ if len(data) != 32 {
+ t.Errorf("Raw key file size = %d, want 32", len(data))
+ }
+
+ // Load it back
+ loaded, err := Load(keyPath)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ // Fingerprints should match
+ if loaded.Fingerprint != original.Fingerprint {
+ t.Errorf("Fingerprint mismatch after raw save/load")
+ }
+}
+
+func TestLoad_JSONFormat(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "noshitalk-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a valid JSON identity file manually
+ id, _ := New("test")
+ idFile := IdentityFile{
+ Version: IdentityVersion,
+ Username: id.Username,
+ Fingerprint: id.Fingerprint,
+ EncryptKeyPair: KeyPair{
+ PrivateKey: hex.EncodeToString(id.PrivateKey.Bytes()),
+ PublicKey: hex.EncodeToString(id.PublicKey.Bytes()),
+ },
+ }
+
+ data, _ := json.Marshal(idFile)
+ keyPath := filepath.Join(tmpDir, "identity.json")
+ os.WriteFile(keyPath, data, 0600)
+
+ // Load it
+ loaded, err := Load(keyPath)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ if loaded.Username != id.Username {
+ t.Errorf("Username mismatch")
+ }
+}
+
+func TestLoad_SignKeyPairFormat(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "noshitalk-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create JSON with SignKeyPair instead of EncryptKeyPair
+ id, _ := New("test")
+ idFile := IdentityFile{
+ Version: IdentityVersion,
+ Username: id.Username,
+ Fingerprint: id.Fingerprint,
+ SignKeyPair: KeyPair{
+ PrivateKey: hex.EncodeToString(id.PrivateKey.Bytes()),
+ PublicKey: hex.EncodeToString(id.PublicKey.Bytes()),
+ },
+ }
+
+ data, _ := json.Marshal(idFile)
+ keyPath := filepath.Join(tmpDir, "sign-format.json")
+ os.WriteFile(keyPath, data, 0600)
+
+ // Should still load
+ loaded, err := Load(keyPath)
+ if err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ if loaded.Fingerprint != id.Fingerprint {
+ t.Errorf("Fingerprint mismatch")
+ }
+}
+
+func TestLoad_Errors(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "noshitalk-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ tests := []struct {
+ name string
+ content []byte
+ wantErr bool
+ }{
+ {
+ name: "nonexistent file",
+ content: nil, // Don't create file
+ wantErr: true,
+ },
+ {
+ name: "invalid json",
+ content: []byte("not json at all"),
+ wantErr: true,
+ },
+ {
+ name: "empty json",
+ content: []byte("{}"),
+ wantErr: true, // No private key
+ },
+ {
+ name: "invalid hex in private key",
+ content: []byte(`{"encryptKeyPair":{"privateKey":"not-hex"}}`),
+ wantErr: true,
+ },
+ {
+ name: "wrong size raw key",
+ content: make([]byte, 16), // Should be 32
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ keyPath := filepath.Join(tmpDir, tt.name+".key")
+
+ if tt.content != nil {
+ os.WriteFile(keyPath, tt.content, 0600)
+ }
+
+ _, err := Load(keyPath)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestLoadOrCreate_ExistingFile(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "noshitalk-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ keyPath := filepath.Join(tmpDir, "identity.noshikey")
+
+ // Create identity first
+ original, _ := New("test")
+ original.Save(keyPath)
+
+ // LoadOrCreate should load existing
+ loaded, err := LoadOrCreate(keyPath, "test")
+ if err != nil {
+ t.Fatalf("LoadOrCreate() error = %v", err)
+ }
+
+ if loaded.Fingerprint != original.Fingerprint {
+ t.Error("LoadOrCreate should load existing file, not create new")
+ }
+}
+
+func TestLoadOrCreate_NewFile(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "noshitalk-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ keyPath := filepath.Join(tmpDir, "new-identity.noshikey")
+
+ // File doesn't exist - should create
+ created, err := LoadOrCreate(keyPath, "new")
+ if err != nil {
+ t.Fatalf("LoadOrCreate() error = %v", err)
+ }
+
+ if created == nil {
+ t.Fatal("LoadOrCreate() returned nil identity")
+ }
+
+ // File should now exist
+ if _, err := os.Stat(keyPath); os.IsNotExist(err) {
+ t.Error("LoadOrCreate should create file if not exists")
+ }
+
+ // Loading again should get same identity
+ loaded, _ := Load(keyPath)
+ if loaded.Fingerprint != created.Fingerprint {
+ t.Error("Saved and loaded fingerprints don't match")
+ }
+}
+
+func TestDeriveSharedIdentity(t *testing.T) {
+ // Same seed should produce same identity
+ id1, err := DeriveSharedIdentity("my-secret-seed", "derived")
+ if err != nil {
+ t.Fatalf("DeriveSharedIdentity() error = %v", err)
+ }
+
+ id2, _ := DeriveSharedIdentity("my-secret-seed", "derived")
+
+ if id1.Fingerprint != id2.Fingerprint {
+ t.Error("Same seed should produce same fingerprint")
+ }
+
+ // Different seed should produce different identity
+ id3, _ := DeriveSharedIdentity("different-seed", "derived")
+ if id1.Fingerprint == id3.Fingerprint {
+ t.Error("Different seeds should produce different fingerprints")
+ }
+}
+
+func TestGenerateChallenge(t *testing.T) {
+ challenge, err := GenerateChallenge()
+ if err != nil {
+ t.Fatalf("GenerateChallenge() error = %v", err)
+ }
+
+ if len(challenge) != 32 {
+ t.Errorf("Challenge length = %d, want 32", len(challenge))
+ }
+
+ // Two challenges should be different
+ challenge2, _ := GenerateChallenge()
+ if string(challenge) == string(challenge2) {
+ t.Error("Challenges should be random")
+ }
+}
+
+func TestIdentityFile_JSONRoundTrip(t *testing.T) {
+ original := IdentityFile{
+ Version: IdentityVersion,
+ Username: "testuser",
+ Fingerprint: "abc123def456",
+ EncryptKeyPair: KeyPair{
+ PrivateKey: "0102030405060708",
+ PublicKey: "09101112131415",
+ },
+ }
+
+ data, err := json.Marshal(original)
+ if err != nil {
+ t.Fatalf("Marshal error = %v", err)
+ }
+
+ var loaded IdentityFile
+ if err := json.Unmarshal(data, &loaded); err != nil {
+ t.Fatalf("Unmarshal error = %v", err)
+ }
+
+ if loaded.Username != original.Username {
+ t.Errorf("Username mismatch")
+ }
+ if loaded.Version != original.Version {
+ t.Errorf("Version mismatch")
+ }
+}
+
+func TestSave_CreatesDirectory(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "noshitalk-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Path with non-existent subdirectory
+ keyPath := filepath.Join(tmpDir, "subdir", "deep", "identity.noshikey")
+
+ id, _ := New("test")
+ err = id.Save(keyPath)
+ if err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+
+ // Should have created directories
+ if _, err := os.Stat(keyPath); os.IsNotExist(err) {
+ t.Error("Save should create parent directories")
+ }
+}
+
+// Benchmark
+func BenchmarkNew(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ New("bench")
+ }
+}
+
+func BenchmarkSaveLoad(b *testing.B) {
+ tmpDir, _ := os.MkdirTemp("", "noshitalk-bench-*")
+ defer os.RemoveAll(tmpDir)
+
+ id, _ := New("bench")
+ keyPath := filepath.Join(tmpDir, "bench.key")
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ id.Save(keyPath)
+ Load(keyPath)
+ }
+}
diff --git a/pkg/protocol/messages_test.go b/pkg/protocol/messages_test.go
new file mode 100644
index 0000000..c6211ea
--- /dev/null
+++ b/pkg/protocol/messages_test.go
@@ -0,0 +1,428 @@
+package protocol
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+func TestNewMessage(t *testing.T) {
+ msg := NewMessage("alice", "bob", "Hello!", TypePrivate)
+
+ if msg.From != "alice" {
+ t.Errorf("From = %q, want 'alice'", msg.From)
+ }
+ if msg.To != "bob" {
+ t.Errorf("To = %q, want 'bob'", msg.To)
+ }
+ if msg.Content != "Hello!" {
+ t.Errorf("Content = %q, want 'Hello!'", msg.Content)
+ }
+ if msg.Type != TypePrivate {
+ t.Errorf("Type = %q, want %q", msg.Type, TypePrivate)
+ }
+ if msg.Time == "" {
+ t.Error("Time should not be empty")
+ }
+}
+
+func TestNewSystemMessage(t *testing.T) {
+ msg := NewSystemMessage("Server restarting")
+
+ if msg.From != "System" {
+ t.Errorf("From = %q, want 'System'", msg.From)
+ }
+ if msg.Content != "Server restarting" {
+ t.Errorf("Content = %q, want 'Server restarting'", msg.Content)
+ }
+ if msg.Type != TypeSystem {
+ t.Errorf("Type = %q, want %q", msg.Type, TypeSystem)
+ }
+}
+
+func TestNewErrorMessage(t *testing.T) {
+ msg := NewErrorMessage("Connection failed")
+
+ if msg.From != "System" {
+ t.Errorf("From = %q, want 'System'", msg.From)
+ }
+ if msg.Type != TypeError {
+ t.Errorf("Type = %q, want %q", msg.Type, TypeError)
+ }
+}
+
+func TestNewUserListMessage(t *testing.T) {
+ users := []string{"alice", "bob", "charlie"}
+ msg := NewUserListMessage(users)
+
+ if msg.Type != TypeUserList {
+ t.Errorf("Type = %q, want %q", msg.Type, TypeUserList)
+ }
+ if len(msg.Users) != 3 {
+ t.Errorf("Users count = %d, want 3", len(msg.Users))
+ }
+ if msg.Time == "" {
+ t.Error("Time should not be empty")
+ }
+}
+
+func TestMessage_ToJSON(t *testing.T) {
+ msg := NewMessage("alice", "bob", "Test", TypeMessage)
+
+ data, err := msg.ToJSON()
+ if err != nil {
+ t.Fatalf("ToJSON() error = %v", err)
+ }
+
+ // Should be valid JSON
+ var parsed map[string]interface{}
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("Invalid JSON: %v", err)
+ }
+
+ if parsed["from"] != "alice" {
+ t.Errorf("JSON from = %v, want 'alice'", parsed["from"])
+ }
+ if parsed["type"] != TypeMessage {
+ t.Errorf("JSON type = %v, want %q", parsed["type"], TypeMessage)
+ }
+}
+
+func TestUserListMessage_ToJSON(t *testing.T) {
+ msg := NewUserListMessage([]string{"alice", "bob"})
+
+ data, err := msg.ToJSON()
+ if err != nil {
+ t.Fatalf("ToJSON() error = %v", err)
+ }
+
+ var parsed map[string]interface{}
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("Invalid JSON: %v", err)
+ }
+
+ if parsed["type"] != TypeUserList {
+ t.Errorf("JSON type = %v, want %q", parsed["type"], TypeUserList)
+ }
+
+ users, ok := parsed["users"].([]interface{})
+ if !ok || len(users) != 2 {
+ t.Errorf("JSON users incorrect: %v", parsed["users"])
+ }
+}
+
+func TestFileOfferMessage_ToJSON(t *testing.T) {
+ msg := FileOfferMessage{
+ Type: TypeFileOffer,
+ From: "alice",
+ To: "bob",
+ FileID: "abc123",
+ Filename: "test.txt",
+ Size: 1024,
+ TotalChunks: 4,
+ SHA256: "deadbeef",
+ Compressed: true,
+ Time: time.Now().Format("15:04:05"),
+ }
+
+ data, err := msg.ToJSON()
+ if err != nil {
+ t.Fatalf("ToJSON() error = %v", err)
+ }
+
+ var parsed FileOfferMessage
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("Unmarshal error = %v", err)
+ }
+
+ if parsed.FileID != "abc123" {
+ t.Errorf("FileID = %q, want 'abc123'", parsed.FileID)
+ }
+ if parsed.Size != 1024 {
+ t.Errorf("Size = %d, want 1024", parsed.Size)
+ }
+ if !parsed.Compressed {
+ t.Error("Compressed should be true")
+ }
+}
+
+func TestFileChunkMessage_ToJSON(t *testing.T) {
+ msg := FileChunkMessage{
+ Type: TypeFileChunk,
+ FileID: "abc123",
+ ChunkIndex: 2,
+ TotalChunks: 10,
+ Data: "base64encodeddata",
+ Time: time.Now().Format("15:04:05"),
+ }
+
+ data, err := msg.ToJSON()
+ if err != nil {
+ t.Fatalf("ToJSON() error = %v", err)
+ }
+
+ var parsed FileChunkMessage
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("Unmarshal error = %v", err)
+ }
+
+ if parsed.ChunkIndex != 2 {
+ t.Errorf("ChunkIndex = %d, want 2", parsed.ChunkIndex)
+ }
+ if parsed.TotalChunks != 10 {
+ t.Errorf("TotalChunks = %d, want 10", parsed.TotalChunks)
+ }
+}
+
+func TestFileResponseMessage_ToJSON(t *testing.T) {
+ msg := FileResponseMessage{
+ Type: TypeFileAccept,
+ FileID: "abc123",
+ From: "bob",
+ Status: "accepted",
+ Message: "Transfer starting",
+ Time: time.Now().Format("15:04:05"),
+ }
+
+ data, err := msg.ToJSON()
+ if err != nil {
+ t.Fatalf("ToJSON() error = %v", err)
+ }
+
+ var parsed FileResponseMessage
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("Unmarshal error = %v", err)
+ }
+
+ if parsed.Status != "accepted" {
+ t.Errorf("Status = %q, want 'accepted'", parsed.Status)
+ }
+}
+
+func TestKeyRotationMessage_ToJSON(t *testing.T) {
+ msg := KeyRotationMessage{
+ Type: TypeKeyRotation,
+ PublicKey: []byte{1, 2, 3, 4},
+ Nonce: []byte{5, 6, 7, 8},
+ Signature: []byte{9, 10, 11, 12},
+ Time: time.Now().Format("15:04:05"),
+ }
+
+ data, err := msg.ToJSON()
+ if err != nil {
+ t.Fatalf("ToJSON() error = %v", err)
+ }
+
+ var parsed KeyRotationMessage
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ t.Fatalf("Unmarshal error = %v", err)
+ }
+
+ if len(parsed.PublicKey) != 4 {
+ t.Errorf("PublicKey length = %d, want 4", len(parsed.PublicKey))
+ }
+}
+
+func TestParseMessage(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantType string
+ wantErr bool
+ }{
+ {
+ name: "message type",
+ input: `{"type":"message","from":"alice","content":"hi"}`,
+ wantType: TypeMessage,
+ },
+ {
+ name: "private type",
+ input: `{"type":"private","from":"alice","to":"bob"}`,
+ wantType: TypePrivate,
+ },
+ {
+ name: "system type",
+ input: `{"type":"system","content":"welcome"}`,
+ wantType: TypeSystem,
+ },
+ {
+ name: "no type field",
+ input: `{"from":"alice","content":"hi"}`,
+ wantType: TypeMessage, // Default
+ },
+ {
+ name: "invalid json",
+ input: `not json`,
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ msgType, data, err := ParseMessage([]byte(tt.input))
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ParseMessage() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if err != nil {
+ return
+ }
+ if msgType != tt.wantType {
+ t.Errorf("ParseMessage() type = %q, want %q", msgType, tt.wantType)
+ }
+ if data == nil {
+ t.Error("ParseMessage() data should not be nil")
+ }
+ })
+ }
+}
+
+func TestParseAsMessage(t *testing.T) {
+ input := `{"type":"message","from":"alice","to":"bob","content":"hello","time":"12:34:56"}`
+
+ msg, err := ParseAsMessage([]byte(input))
+ if err != nil {
+ t.Fatalf("ParseAsMessage() error = %v", err)
+ }
+
+ if msg.From != "alice" {
+ t.Errorf("From = %q, want 'alice'", msg.From)
+ }
+ if msg.To != "bob" {
+ t.Errorf("To = %q, want 'bob'", msg.To)
+ }
+ if msg.Content != "hello" {
+ t.Errorf("Content = %q, want 'hello'", msg.Content)
+ }
+}
+
+func TestParseAsUserList(t *testing.T) {
+ input := `{"type":"user_list","users":["alice","bob","charlie"],"time":"12:34:56"}`
+
+ msg, err := ParseAsUserList([]byte(input))
+ if err != nil {
+ t.Fatalf("ParseAsUserList() error = %v", err)
+ }
+
+ if msg.Type != TypeUserList {
+ t.Errorf("Type = %q, want %q", msg.Type, TypeUserList)
+ }
+ if len(msg.Users) != 3 {
+ t.Errorf("Users count = %d, want 3", len(msg.Users))
+ }
+}
+
+func TestParseAsFileOffer(t *testing.T) {
+ input := `{"type":"file_offer","from":"alice","to":"bob","file_id":"abc","filename":"test.txt","size":100,"total_chunks":1}`
+
+ msg, err := ParseAsFileOffer([]byte(input))
+ if err != nil {
+ t.Fatalf("ParseAsFileOffer() error = %v", err)
+ }
+
+ if msg.FileID != "abc" {
+ t.Errorf("FileID = %q, want 'abc'", msg.FileID)
+ }
+ if msg.Filename != "test.txt" {
+ t.Errorf("Filename = %q, want 'test.txt'", msg.Filename)
+ }
+}
+
+func TestParseAsFileChunk(t *testing.T) {
+ input := `{"type":"file_chunk","file_id":"abc","chunk_index":5,"total_chunks":10,"data":"base64"}`
+
+ msg, err := ParseAsFileChunk([]byte(input))
+ if err != nil {
+ t.Fatalf("ParseAsFileChunk() error = %v", err)
+ }
+
+ if msg.ChunkIndex != 5 {
+ t.Errorf("ChunkIndex = %d, want 5", msg.ChunkIndex)
+ }
+}
+
+func TestParseAsFileResponse(t *testing.T) {
+ input := `{"type":"file_accept","file_id":"abc","from":"bob","status":"accepted","message":"ok"}`
+
+ msg, err := ParseAsFileResponse([]byte(input))
+ if err != nil {
+ t.Fatalf("ParseAsFileResponse() error = %v", err)
+ }
+
+ if msg.Status != "accepted" {
+ t.Errorf("Status = %q, want 'accepted'", msg.Status)
+ }
+}
+
+func TestParseAs_InvalidJSON(t *testing.T) {
+ invalid := []byte("not json")
+
+ _, err := ParseAsMessage(invalid)
+ if err == nil {
+ t.Error("ParseAsMessage should fail on invalid JSON")
+ }
+
+ _, err = ParseAsUserList(invalid)
+ if err == nil {
+ t.Error("ParseAsUserList should fail on invalid JSON")
+ }
+
+ _, err = ParseAsFileOffer(invalid)
+ if err == nil {
+ t.Error("ParseAsFileOffer should fail on invalid JSON")
+ }
+
+ _, err = ParseAsFileChunk(invalid)
+ if err == nil {
+ t.Error("ParseAsFileChunk should fail on invalid JSON")
+ }
+
+ _, err = ParseAsFileResponse(invalid)
+ if err == nil {
+ t.Error("ParseAsFileResponse should fail on invalid JSON")
+ }
+}
+
+// Test message type constants
+func TestMessageTypeConstants(t *testing.T) {
+ types := map[string]string{
+ "TypeMessage": TypeMessage,
+ "TypePrivate": TypePrivate,
+ "TypeSystem": TypeSystem,
+ "TypeError": TypeError,
+ "TypeUserList": TypeUserList,
+ "TypeUserJoin": TypeUserJoin,
+ "TypeUserLeave": TypeUserLeave,
+ "TypeHandshake": TypeHandshake,
+ "TypePublic": TypePublic,
+ "TypeKeyRotation": TypeKeyRotation,
+ "TypeFileOffer": TypeFileOffer,
+ "TypeFileChunk": TypeFileChunk,
+ "TypeFileAccept": TypeFileAccept,
+ "TypeFileReject": TypeFileReject,
+ "TypeFileComplete": TypeFileComplete,
+ }
+
+ for name, value := range types {
+ if value == "" {
+ t.Errorf("%s should not be empty", name)
+ }
+ }
+}
+
+// Benchmark
+func BenchmarkMessage_ToJSON(b *testing.B) {
+ msg := NewMessage("alice", "bob", "Test message content", TypeMessage)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ msg.ToJSON()
+ }
+}
+
+func BenchmarkParseMessage(b *testing.B) {
+ data := []byte(`{"type":"message","from":"alice","to":"bob","content":"hello","time":"12:34:56"}`)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ ParseMessage(data)
+ }
+}
diff --git a/pkg/tor/tor_test.go b/pkg/tor/tor_test.go
new file mode 100644
index 0000000..b6411e0
--- /dev/null
+++ b/pkg/tor/tor_test.go
@@ -0,0 +1,282 @@
+package tor
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestValidateOnionAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ wantErr bool
+ }{
+ // Valid v3 .onion addresses
+ {
+ name: "valid v3 onion with port",
+ addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:8080",
+ wantErr: false,
+ },
+ {
+ name: "valid v3 onion without port",
+ addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion",
+ wantErr: false,
+ },
+ {
+ name: "valid v3 onion lowercase",
+ addr: "vwxyz234567abcdefghijklmnopqrstuvwxyz234567abcdefghijk.onion:443",
+ wantErr: false,
+ },
+
+ // Invalid addresses
+ {
+ name: "empty",
+ addr: "",
+ wantErr: true,
+ },
+ {
+ name: "clearnet domain",
+ addr: "example.com:8080",
+ wantErr: true,
+ },
+ {
+ name: "clearnet IP",
+ addr: "192.168.1.1:8080",
+ wantErr: true,
+ },
+ {
+ name: "v2 onion (too short)",
+ addr: "abcdefghijklmnop.onion:8080",
+ wantErr: true,
+ },
+ {
+ name: "uppercase not allowed",
+ addr: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV.onion",
+ wantErr: true,
+ },
+ {
+ name: "invalid characters",
+ addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrst01.onion",
+ wantErr: true, // 0 and 1 not in base32
+ },
+ {
+ name: "too long",
+ addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz.onion",
+ wantErr: true,
+ },
+ {
+ name: "missing .onion",
+ addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv",
+ wantErr: true,
+ },
+ {
+ name: "invalid port",
+ addr: "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:999999",
+ wantErr: true,
+ },
+ {
+ name: "localhost",
+ addr: "localhost:8080",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ValidateOnionAddress(tt.addr)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ValidateOnionAddress(%q) error = %v, wantErr %v", tt.addr, err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestValidateOnionAddress_ErrorMessages(t *testing.T) {
+ tests := []struct {
+ addr string
+ errContains string
+ }{
+ {"", "empty"},
+ {"example.com", ".onion"},
+ {"short.onion", "invalid"},
+ }
+
+ for _, tt := range tests {
+ err := ValidateOnionAddress(tt.addr)
+ if err == nil {
+ t.Errorf("Expected error for %q", tt.addr)
+ continue
+ }
+ if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.errContains)) {
+ t.Errorf("Error %q should contain %q", err.Error(), tt.errContains)
+ }
+ }
+}
+
+func TestOnionRegex(t *testing.T) {
+ // Valid patterns
+ validAddrs := []string{
+ "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion",
+ "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:80",
+ "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:8080",
+ "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:65535",
+ }
+
+ for _, addr := range validAddrs {
+ if !OnionRegex.MatchString(addr) {
+ t.Errorf("OnionRegex should match %q", addr)
+ }
+ }
+
+ // Invalid patterns
+ invalidAddrs := []string{
+ "tooshort.onion",
+ "example.com",
+ "127.0.0.1:8080",
+ "UPPERCASE.onion",
+ }
+
+ for _, addr := range invalidAddrs {
+ if OnionRegex.MatchString(addr) {
+ t.Errorf("OnionRegex should NOT match %q", addr)
+ }
+ }
+}
+
+func TestDefaultProxyAddresses(t *testing.T) {
+ if len(DefaultProxyAddresses) == 0 {
+ t.Error("DefaultProxyAddresses should not be empty")
+ }
+
+ for _, addr := range DefaultProxyAddresses {
+ if !strings.HasPrefix(addr, "socks5://") {
+ t.Errorf("Proxy address should start with socks5://: %s", addr)
+ }
+ }
+}
+
+func TestNewDialer(t *testing.T) {
+ dialer := NewDialer()
+
+ if dialer == nil {
+ t.Fatal("NewDialer() returned nil")
+ }
+
+ if len(dialer.ProxyAddresses) == 0 {
+ t.Error("Dialer should have default proxy addresses")
+ }
+
+ if dialer.Timeout == 0 {
+ t.Error("Dialer should have default timeout")
+ }
+
+ if dialer.KeepAlive == 0 {
+ t.Error("Dialer should have default keepalive")
+ }
+}
+
+func TestDialer_CustomSettings(t *testing.T) {
+ dialer := NewDialer()
+ dialer.ProxyAddresses = []string{"socks5://custom:1234"}
+
+ if len(dialer.ProxyAddresses) != 1 {
+ t.Error("Should allow custom proxy addresses")
+ }
+}
+
+// Note: These tests don't actually connect to Tor (that would require Tor running)
+// They just test the validation and configuration logic
+
+func TestConnect_InvalidAddress(t *testing.T) {
+ _, err := Connect("invalid-address", nil)
+ if err == nil {
+ t.Error("Connect should fail with invalid address")
+ }
+}
+
+func TestConnect_EmptyAddress(t *testing.T) {
+ _, err := Connect("", nil)
+ if err == nil {
+ t.Error("Connect should fail with empty address")
+ }
+}
+
+func TestDialer_Dial_InvalidAddress(t *testing.T) {
+ dialer := NewDialer()
+ _, err := dialer.Dial("not-an-onion")
+ if err == nil {
+ t.Error("Dial should fail with invalid address")
+ }
+}
+
+func TestConnectWithCallback_InvalidAddress(t *testing.T) {
+ var progressMessages []string
+ callback := func(msg string) {
+ progressMessages = append(progressMessages, msg)
+ }
+
+ _, err := ConnectWithCallback("invalid", nil, callback)
+ if err == nil {
+ t.Error("ConnectWithCallback should fail with invalid address")
+ }
+}
+
+func TestTestProxyAvailable_InvalidURL(t *testing.T) {
+ err := TestProxyAvailable("not-a-valid-url")
+ if err == nil {
+ t.Error("TestProxyAvailable should fail with invalid URL")
+ }
+}
+
+func TestTestProxyAvailable_NotRunning(t *testing.T) {
+ // This proxy shouldn't exist
+ err := TestProxyAvailable("socks5://127.0.0.1:59999")
+ if err == nil {
+ t.Error("TestProxyAvailable should fail when proxy not running")
+ }
+}
+
+func TestDialer_IsAvailable_NoProxy(t *testing.T) {
+ dialer := &Dialer{
+ ProxyAddresses: []string{"socks5://127.0.0.1:59999"}, // Non-existent
+ }
+
+ if dialer.IsAvailable() {
+ t.Error("IsAvailable should return false when no proxy running")
+ }
+}
+
+// Test constants
+func TestConstants(t *testing.T) {
+ if DefaultConnectionTimeout == 0 {
+ t.Error("DefaultConnectionTimeout should not be zero")
+ }
+ if DefaultKeepAlive == 0 {
+ t.Error("DefaultKeepAlive should not be zero")
+ }
+ if ProxyTestTimeout == 0 {
+ t.Error("ProxyTestTimeout should not be zero")
+ }
+}
+
+// Benchmark validation
+func BenchmarkValidateOnionAddress_Valid(b *testing.B) {
+ addr := "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:8080"
+ for i := 0; i < b.N; i++ {
+ ValidateOnionAddress(addr)
+ }
+}
+
+func BenchmarkValidateOnionAddress_Invalid(b *testing.B) {
+ addr := "example.com"
+ for i := 0; i < b.N; i++ {
+ ValidateOnionAddress(addr)
+ }
+}
+
+func BenchmarkOnionRegex_Match(b *testing.B) {
+ addr := "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuv.onion:8080"
+ for i := 0; i < b.N; i++ {
+ OnionRegex.MatchString(addr)
+ }
+}