package pop3_server import ( "bufio" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "log" "math/big" "net" "os" "path/filepath" "strings" "testing" "time" "mail_go/config" "mail_go/internal/db" "mail_go/internal/storage" "mail_go/internal/store" "mail_go/internal/tlsutil" "golang.org/x/crypto/bcrypt" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func newTestServer(t *testing.T) *POP3Server { t.Helper() gdb, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.ProtocolLog{}); err != nil { t.Fatalf("migrate: %v", err) } stores := store.NewStores(gdb) return &POP3Server{ stores: stores, cfg: config.POP3Config{}, banCfg: config.BanConfig{MaxFailAttempts: 5, BanDurationMin: 30}, } } // TestHandleConnLogsAuthFailure 验证认证失败的 POP3 会话写入协议日志。 func TestHandleConnLogsAuthFailure(t *testing.T) { s := newTestServer(t) server, client := net.Pipe() defer server.Close() defer client.Close() done := make(chan struct{}) go func() { defer close(done) s.handleConn(server, 110) }() br := bufio.NewReader(client) // 等待 greeting if _, err := br.ReadString('\n'); err != nil { t.Fatalf("greeting: %v", err) } client.Write([]byte("USER no-such-user\r\n")) br.ReadString('\n') client.Write([]byte("PASS wrong-pass\r\n")) br.ReadString('\n') client.Write([]byte("QUIT\r\n")) br.ReadString('\n') client.Close() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("handleConn did not return") } logs, total, err := s.stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{}) if err != nil { t.Fatalf("list: %v", err) } if total != 1 { t.Fatalf("expected 1 log, got %d", total) } log := logs[0] if log.Protocol != db.ProtocolPOP3 || log.Port != 110 { t.Fatalf("unexpected log: %+v", log) } if log.Success { t.Fatalf("expected failure, got %+v", log) } if log.Username != "no-such-user" { t.Fatalf("username = %q, want no-such-user", log.Username) } if !strings.Contains(log.Detail, "USER") || !strings.Contains(log.Detail, "PASS") { t.Fatalf("detail missing commands: %q", log.Detail) } } // TestHandleConnLogsSuccess 验证认证成功的 POP3 会话写入成功日志。 func TestHandleConnLogsSuccess(t *testing.T) { s := newTestServer(t) domain := &db.Domain{Name: "example.com"} if err := s.stores.Domains.Create(domain); err != nil { t.Fatalf("create domain: %v", err) } user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true} hashed, err := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) if err != nil { t.Fatalf("hash: %v", err) } user.PasswordHash = string(hashed) if err := s.stores.Users.Create(user); err != nil { t.Fatalf("create user: %v", err) } server, client := net.Pipe() defer server.Close() defer client.Close() done := make(chan struct{}) go func() { defer close(done) s.handleConn(server, 110) }() br := bufio.NewReader(client) br.ReadString('\n') client.Write([]byte("USER alice@example.com\r\n")) br.ReadString('\n') client.Write([]byte("PASS secret123\r\n")) br.ReadString('\n') client.Write([]byte("STAT\r\n")) br.ReadString('\n') client.Write([]byte("QUIT\r\n")) br.ReadString('\n') client.Close() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("handleConn did not return") } logs, total, err := s.stores.ProtocolLogs.List(1, 10, store.ProtocolLogFilter{}) if err != nil { t.Fatalf("list: %v", err) } if total != 1 { t.Fatalf("expected 1 log, got %d", total) } if !logs[0].Success { t.Fatalf("expected success, got %+v", logs[0]) } if logs[0].Username != "alice@example.com" { t.Fatalf("username = %q", logs[0].Username) } } // TestPop3CommandDetail 验证命令摘要生成。 func TestPop3CommandDetail(t *testing.T) { counts := map[string]int{"USER": 1, "PASS": 1, "RETR": 3, "DELE": 1, "NOOP": 2} got := pop3CommandDetail(counts, 2) if got != "USER PASS RETR×3 DELE 删除2" { t.Fatalf("detail = %q", got) } if empty := pop3CommandDetail(nil, 0); empty != "连接建立,无命令" { t.Fatalf("empty detail = %q", empty) } } // mockPusher 记录推送调用的测试桩。 type mockPusher struct { expunged []struct { Email string Mailbox string Seqs []uint32 } } func (m *mockPusher) PushNewMessage(string, *db.Message) {} func (m *mockPusher) PushFlagsChanged(string, string, *db.Message) {} func (m *mockPusher) PushExpunged(email, mailbox string, seqs []uint32) { m.expunged = append(m.expunged, struct { Email string Mailbox string Seqs []uint32 }{email, mailbox, seqs}) } // TestExpungePushesIMAPUpdate 验证 POP3 删除邮件后向 IMAP 推送 Expunge。 func TestExpungePushesIMAPUpdate(t *testing.T) { s := newTestServer(t) pusher := &mockPusher{} s.pusher = pusher domain := &db.Domain{Name: "example.com"} if err := s.stores.Domains.Create(domain); err != nil { t.Fatalf("create domain: %v", err) } user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true} hashed, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.DefaultCost) user.PasswordHash = string(hashed) if err := s.stores.Users.Create(user); err != nil { t.Fatalf("create user: %v", err) } for i := 0; i < 3; i++ { msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "m", Date: time.Now()} if err := s.stores.Mails.Create(msg); err != nil { t.Fatalf("create message %d: %v", i, err) } } server, client := net.Pipe() defer server.Close() defer client.Close() done := make(chan struct{}) go func() { defer close(done) s.handleConn(server, 110) }() br := bufio.NewReader(client) br.ReadString('\n') client.Write([]byte("USER alice@example.com\r\n")) br.ReadString('\n') client.Write([]byte("PASS secret123\r\n")) br.ReadString('\n') // 删除第 1 封后退出 client.Write([]byte("DELE 1\r\n")) br.ReadString('\n') client.Write([]byte("QUIT\r\n")) br.ReadString('\n') client.Close() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("handleConn did not return") } if len(pusher.expunged) != 1 { t.Fatalf("expunge pushes = %d, want 1", len(pusher.expunged)) } p := pusher.expunged[0] if p.Email != "alice@example.com" || p.Mailbox != "INBOX" { t.Fatalf("push target = %s/%s", p.Email, p.Mailbox) } if len(p.Seqs) != 1 || p.Seqs[0] != 1 { t.Fatalf("seqs = %v, want [1]", p.Seqs) } // 邮件确实已删除 if n, _ := s.stores.Mails.CountByUserAndFolder(user.ID, "INBOX"); n != 2 { t.Fatalf("inbox count = %d, want 2", n) } } // TestExpungeDeletedRemovesAttachmentsAndRefundsQuota 验证 POP3 删除邮件时 // 附件文件被清理、配额被回退(与 Web/IMAP 删除路径同源)。 func TestExpungeDeletedRemovesAttachmentsAndRefundsQuota(t *testing.T) { s := newTestServer(t) attDir := t.TempDir() attStorage := storage.NewAttachmentStorage(attDir) s.storage = attStorage domain := &db.Domain{Name: "example.com"} if err := s.stores.Domains.Create(domain); err != nil { t.Fatalf("create domain: %v", err) } user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true, QuotaBytes: 1 << 20, UsedBytes: 11} if err := s.stores.Users.Create(user); err != nil { t.Fatalf("create user: %v", err) } msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "with attachment", Date: time.Now()} if err := s.stores.Mails.Create(msg); err != nil { t.Fatalf("create message: %v", err) } relPath, err := attStorage.Save("a.txt", []byte("hello world")) if err != nil { t.Fatalf("save attachment: %v", err) } att := &db.Attachment{MessageID: msg.ID, FileName: "a.txt", FilePath: relPath, FileSize: 11} if err := s.stores.Attachments.Create(att); err != nil { t.Fatalf("create attachment: %v", err) } msgs := []pop3Message{{id: msg.ID, raw: "x", size: 1, message: msg}} if n := s.expungeDeleted(msgs, map[int]bool{1: true}, user); n != 1 { t.Fatalf("expungeDeleted = %d, want 1", n) } // 附件文件已删除 if _, err := os.Stat(filepath.Join(attDir, relPath)); !os.IsNotExist(err) { t.Fatalf("attachment file should be gone (err=%v)", err) } // 附件记录已删除 if _, err := s.stores.Attachments.GetByID(att.ID); err == nil { t.Fatal("attachment record should be gone") } // 邮件已删除 if _, err := s.stores.Mails.GetByID(msg.ID); err == nil { t.Fatal("message should be gone") } // 配额已回退 u, err := s.stores.Users.GetByID(user.ID) if err != nil { t.Fatalf("GetByID: %v", err) } if u.UsedBytes != 0 { t.Fatalf("used_bytes = %d, want 0 after refund", u.UsedBytes) } } // writeSelfSignedCert 生成测试用自签名证书(模拟配置了 TLS 的部署)。 func writeSelfSignedCert(t *testing.T, certPath, keyPath string) { t.Helper() priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } tmpl := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, NotBefore: time.Now(), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, DNSNames: []string{"localhost"}, } der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) if err != nil { t.Fatal(err) } certOut, err := os.Create(certPath) if err != nil { t.Fatal(err) } defer certOut.Close() if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { t.Fatal(err) } keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { t.Fatal(err) } defer keyOut.Close() if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { t.Fatal(err) } } // runPop3Commands 执行一系列命令,返回每条命令的响应(不含 greeting)。 func runPop3Commands(t *testing.T, s *POP3Server, cmds []string) map[string]string { t.Helper() server, client := net.Pipe() defer server.Close() defer client.Close() done := make(chan struct{}) go func() { defer close(done) s.handleConn(server, 110) }() br := bufio.NewReader(client) if _, err := br.ReadString('\n'); err != nil { // greeting t.Fatalf("greeting: %v", err) } replies := make(map[string]string, len(cmds)) for _, cmd := range cmds { client.Write([]byte(cmd + "\r\n")) line, err := br.ReadString('\n') if err != nil { t.Fatalf("read reply for %q: %v", cmd, err) } replies[cmd] = line } client.Close() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("handleConn did not return") } return replies } // TestPlainAuthRejectedWithoutTLSOnNonLoopback 验证明文认证限制(#25): // 配置了 TLS 且来源非回环时,PASS 必须先 STLS,否则拒绝。 func TestPlainAuthRejectedWithoutTLSOnNonLoopback(t *testing.T) { s := newTestServer(t) certDir := t.TempDir() certPath := filepath.Join(certDir, "cert.pem") keyPath := filepath.Join(certDir, "key.pem") writeSelfSignedCert(t, certPath, keyPath) loader, err := tlsutil.NewLoader(certPath, keyPath, nil, log.Printf) if err != nil { t.Fatalf("loader: %v", err) } s.tlsLoader = loader replies := runPop3Commands(t, s, []string{"USER alice@example.com", "PASS wrong", "QUIT"}) if !strings.Contains(replies["PASS wrong"], "TLS required") { t.Fatalf("PASS reply = %q, want TLS required", replies["PASS wrong"]) } } // TestPlainAuthAllowedWithInsecureAuthOption 验证 allow_insecure_auth // 显式放行时,PASS 正常进入认证流程(仅返回认证失败而非 TLS 拒绝)。 func TestPlainAuthAllowedWithInsecureAuthOption(t *testing.T) { s := newTestServer(t) certDir := t.TempDir() certPath := filepath.Join(certDir, "cert.pem") keyPath := filepath.Join(certDir, "key.pem") writeSelfSignedCert(t, certPath, keyPath) loader, err := tlsutil.NewLoader(certPath, keyPath, nil, log.Printf) if err != nil { t.Fatalf("loader: %v", err) } s.tlsLoader = loader s.cfg.AllowInsecureAuth = true replies := runPop3Commands(t, s, []string{"USER no-such-user", "PASS wrong", "QUIT"}) if strings.Contains(replies["PASS wrong"], "TLS required") { t.Fatalf("PASS reply = %q, should reach authentication", replies["PASS wrong"]) } if !strings.Contains(replies["PASS wrong"], "-ERR") { t.Fatalf("PASS reply = %q, want -ERR", replies["PASS wrong"]) } }