summaryrefslogtreecommitdiffstats
path: root/internal/nntp/client_test.go
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-22 20:36:56 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-22 20:36:56 +0200
commitc1decadb590c4d79d92bcc4df7772119a5c91244 (patch)
tree468f9fa37535dbb90cc5d4061eb20de83912131e /internal/nntp/client_test.go
downloadaegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz
aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip
Initial Aegis Usenet client release
Diffstat (limited to 'internal/nntp/client_test.go')
-rw-r--r--internal/nntp/client_test.go357
1 files changed, 357 insertions, 0 deletions
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 <a@example.org>\tMon, 1 Jan 2024 00:00:00 +0000\t<119@example>\t\t100\t5\r\n120\tSecond\tBob <b@example.org>\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)
+ }
+ }
+}