From c1decadb590c4d79d92bcc4df7772119a5c91244 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sat, 22 Aug 2026 20:36:56 +0200 Subject: Initial Aegis Usenet client release --- internal/nntp/client_test.go | 357 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) create mode 100644 internal/nntp/client_test.go (limited to 'internal/nntp/client_test.go') diff --git a/internal/nntp/client_test.go b/internal/nntp/client_test.go new file mode 100644 index 0000000..26354ed --- /dev/null +++ b/internal/nntp/client_test.go @@ -0,0 +1,357 @@ +package nntp + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/binary" + "fmt" + "io" + "math/big" + "net" + "strings" + "sync" + "testing" + "time" +) + +type fakeServer struct { + listener net.Listener + posted string + mu sync.Mutex +} + +func startFakeServer(t *testing.T) *fakeServer { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + return startFakeServerOn(t, listener) +} + +func startFakeTLSServer(t *testing.T) (*fakeServer, *tls.Config) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Aegis NNTP test"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatal(err) + } + certificate := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey} + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS12, + }) + if err != nil { + t.Fatal(err) + } + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + roots.AddCert(parsed) + return startFakeServerOn(t, listener), &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12} +} + +func startFakeServerOn(t *testing.T, listener net.Listener) *fakeServer { + t.Helper() + server := &fakeServer{listener: listener} + go server.serve(t) + t.Cleanup(func() { listener.Close() }) + return server +} + +func (s *fakeServer) address() (string, string) { + host, port, _ := net.SplitHostPort(s.listener.Addr().String()) + return host, port +} + +func (s *fakeServer) serve(t *testing.T) { + conn, err := s.listener.Accept() + if err != nil { + return + } + defer conn.Close() + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + write := func(value string) { + _, _ = writer.WriteString(value) + _ = writer.Flush() + } + write("200 fake server ready\r\n") + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimSpace(line) + switch { + case line == "AUTHINFO USER reader": + write("381 password required\r\n") + case line == "AUTHINFO PASS secret": + write("281 authentication accepted\r\n") + case line == "MODE READER": + write("200 reader mode\r\n") + case line == "CAPABILITIES": + write("101 capabilities follow\r\nVERSION 2\r\nOVER\r\nPOST\r\n.\r\n") + case line == "LIST ACTIVE": + write("215 list follows\r\ncomp.lang.go 120 101 y\r\nempty.group 0 1 n\r\n.\r\n") + case line == "GROUP comp.lang.go": + write("211 19 101 120 comp.lang.go\r\n") + case line == "OVER 119-120": + write("224 overview follows\r\n119\tFirst\tAlice \tMon, 1 Jan 2024 00:00:00 +0000\t<119@example>\t\t100\t5\r\n120\tSecond\tBob \tMon, 1 Jan 2024 01:00:00 +0000\t<120@example>\t<119@example>\t120\t6\r\n.\r\n") + case line == "ARTICLE 120": + write("220 120 <120@example> article follows\r\nSubject: Second\r\n\r\nHello\r\n..dot line\r\n.\r\n") + case line == "POST": + write("340 send article\r\n") + var article strings.Builder + for { + postedLine, readErr := reader.ReadString('\n') + if readErr != nil { + return + } + if postedLine == ".\r\n" { + break + } + article.WriteString(postedLine) + } + s.mu.Lock() + s.posted = article.String() + s.mu.Unlock() + write("240 article received\r\n") + case line == "QUIT": + write("205 closing connection\r\n") + return + default: + write(fmt.Sprintf("500 unsupported: %s\r\n", line)) + } + } +} + +func startSOCKSProxy(t *testing.T, upstreamAddress string) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { listener.Close() }) + go func() { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer connection.Close() + hello := make([]byte, 3) + if _, err := io.ReadFull(connection, hello); err != nil || hello[0] != 5 { + return + } + if _, err := connection.Write([]byte{5, 0}); err != nil { + return + } + header := make([]byte, 4) + if _, err := io.ReadFull(connection, header); err != nil || header[0] != 5 || header[1] != 1 { + return + } + switch header[3] { + case 1: + _, err = io.ReadFull(connection, make([]byte, 4)) + case 3: + length := make([]byte, 1) + if _, err = io.ReadFull(connection, length); err == nil { + _, err = io.ReadFull(connection, make([]byte, int(length[0]))) + } + case 4: + _, err = io.ReadFull(connection, make([]byte, 16)) + default: + return + } + if err != nil { + return + } + port := make([]byte, 2) + if _, err := io.ReadFull(connection, port); err != nil || binary.BigEndian.Uint16(port) == 0 { + return + } + upstream, err := net.DialTimeout("tcp", upstreamAddress, 2*time.Second) + if err != nil { + _, _ = connection.Write([]byte{5, 1, 0, 1, 0, 0, 0, 0, 0, 0}) + return + } + defer upstream.Close() + if _, err := connection.Write([]byte{5, 0, 0, 1, 127, 0, 0, 1, 0, 1}); err != nil { + return + } + done := make(chan struct{}) + go func() { + _, _ = io.Copy(upstream, connection) + _ = upstream.Close() + close(done) + }() + _, _ = io.Copy(connection, upstream) + <-done + }() + return listener.Addr().String() +} + +func TestClientWorkflow(t *testing.T) { + server, tlsConfig := startFakeTLSServer(t) + host, port := server.address() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := Dial(ctx, DialConfig{ + Host: host, + Port: port, + UseTLS: true, + Username: "reader", + Password: "secret", + ProxyType: "DIRECT", + tlsConfig: tlsConfig, + }) + if err != nil { + t.Fatalf("Dial() error = %v", err) + } + defer client.Close() + + capabilities, err := client.Capabilities() + if err != nil || len(capabilities) != 3 { + t.Fatalf("Capabilities() = %v, %v", capabilities, err) + } + groups, err := client.ListActive() + if err != nil { + t.Fatal(err) + } + if len(groups) != 2 || groups[0].EstimatedPost != 20 || groups[1].EstimatedPost != 0 { + t.Fatalf("ListActive() = %#v", groups) + } + status, headers, err := client.LatestOverview("comp.lang.go", 2) + if err != nil || status.Count != 19 || status.High != 120 { + t.Fatalf("LatestOverview() status = %#v, error = %v", status, err) + } + if err != nil || len(headers) != 2 || headers[1].Subject != "Second" { + t.Fatalf("LatestOverview() headers = %#v, error = %v", headers, err) + } + article, err := client.ArticleInGroup("comp.lang.go", 120) + if err != nil || !strings.Contains(article, "\n.dot line") { + t.Fatalf("Article() = %q, %v", article, err) + } + if err := client.Post("From: reader@example.org\nNewsgroups: comp.lang.go\nSubject: Test\n\n.line"); err != nil { + t.Fatal(err) + } + server.mu.Lock() + posted := server.posted + server.mu.Unlock() + if !strings.Contains(posted, "\r\n..line\r\n") { + t.Fatalf("posted article was not dot-stuffed: %q", posted) + } +} + +func TestTLSCertificateVerificationCannotBeSkipped(t *testing.T) { + server, _ := startFakeTLSServer(t) + host, port := server.address() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := Dial(ctx, DialConfig{Host: host, Port: port, UseTLS: true, ProxyType: "DIRECT"}) + if client != nil { + client.Close() + } + if err == nil { + t.Fatal("Dial() trusted an unknown TLS certificate") + } +} + +func TestRejectsCommandInjection(t *testing.T) { + if _, err := Dial(context.Background(), DialConfig{Host: "example.org\r\nQUIT", Port: "119"}); err == nil { + t.Fatal("Dial() accepted command injection in host") + } + client := &Client{} + if _, err := client.SelectGroup("comp.lang.go\r\nPOST"); err == nil { + t.Fatal("SelectGroup() accepted command injection") + } +} + +func TestValidateArticleTextConstraints(t *testing.T) { + tests := []struct { + name string + article string + wantErr bool + }{ + {name: "valid UTF-8 and CRLF", article: "Subject: café\r\n\r\ncorps UTF-8\r\n"}, + {name: "valid LF normalized by Post", article: "Subject: test\n\nbody"}, + {name: "998 octets", article: "Subject: " + strings.Repeat("a", 989)}, + {name: "oversized line", article: strings.Repeat("a", maxPostLineBytes+1), wantErr: true}, + {name: "dot-stuffed oversized line", article: "." + strings.Repeat("a", maxPostLineBytes-1), wantErr: true}, + {name: "NUL", article: "Subject: test\x00", wantErr: true}, + {name: "invalid UTF-8", article: string([]byte{'a', 0xff}), wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := ValidateArticle(test.article) + if (err != nil) != test.wantErr { + t.Fatalf("ValidateArticle() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestRejectsAuthenticationWithoutTLS(t *testing.T) { + if _, err := Dial(context.Background(), DialConfig{ + Host: "127.0.0.1", Port: "119", Username: "reader", Password: "secret", + }); err == nil { + t.Fatal("Dial() accepted authentication without TLS") + } +} + +func TestSOCKS5Connection(t *testing.T) { + server := startFakeServer(t) + proxyAddress := startSOCKSProxy(t, server.listener.Addr().String()) + host, port := server.address() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := Dial(ctx, DialConfig{ + Host: host, Port: port, ProxyType: "SOCKS5", ProxyAddress: proxyAddress, + }) + if err != nil { + t.Fatalf("Dial() through SOCKS5 error = %v", err) + } + defer client.Close() + groups, err := client.ListActive() + if err != nil || len(groups) != 2 { + t.Fatalf("ListActive() through SOCKS5 = %#v, %v", groups, err) + } +} + +func TestPopulationEstimate(t *testing.T) { + tests := []struct { + low, high int64 + want int64 + }{ + {101, 120, 20}, + {1, 1, 1}, + {1, 0, 0}, + {0, 0, 0}, + } + for _, test := range tests { + if got := estimatePopulation(test.low, test.high); got != test.want { + t.Errorf("estimatePopulation(%d, %d) = %d, want %d", test.low, test.high, got, test.want) + } + } +} -- cgit v1.2.3