package service import ( "bufio" "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "github.com/freefire/jiang13-bbs/config" "github.com/freefire/jiang13-bbs/model" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "io" "math/big" "net" "net/http" "net/http/httptest" "net/http/httputil" "net/url" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" ) func testKey(t *testing.T) { t.Helper() t.Setenv("SETTINGS_MASTER_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32))) } func testOps(t *testing.T) *Operations { t.Helper() dsn := os.Getenv("OPS_TEST_DATABASE_URL") if dsn == "" { t.Skip("set OPS_TEST_DATABASE_URL to an isolated PostgreSQL database") } u, e := url.Parse(dsn) if e != nil || !strings.HasPrefix(strings.TrimPrefix(u.Path, "/"), "ops_test") { t.Fatal("test database name must begin ops_test") } db, e := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if e != nil { t.Fatal(e) } schema := "ops_test_" + fmt.Sprint(time.Now().UnixNano()) if e = db.Exec("CREATE SCHEMA " + schema).Error; e != nil { t.Fatal(e) } q := u.Query() q.Set("search_path", schema) u.RawQuery = q.Encode() scoped, e := gorm.Open(postgres.Open(u.String()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) if e != nil { t.Fatal(e) } t.Cleanup(func() { sql, _ := scoped.DB() _ = sql.Close() db.Exec("DROP SCHEMA " + schema + " CASCADE") sql, _ = db.DB() _ = sql.Close() }) if e = scoped.AutoMigrate(&model.TemporaryUpload{}, &model.ModuleConfig{}, &model.SiteSetting{}, &model.SettingsAudit{}, &model.ActionCounter{}, &model.MailTask{}, &model.EmailChallenge{}, &model.StoredObject{}, &model.User{}, &model.PostAttachment{}); e != nil { t.Fatal(e) } dir := t.TempDir() _ = os.MkdirAll(filepath.Join(dir, "uploads"), 0700) _ = os.MkdirAll(filepath.Join(dir, "private"), 0700) return NewOperations(scoped, &config.Config{DataDir: dir, DevMode: true, SiteURL: "https://forum.example.com"}) } func raw(v any) json.RawMessage { b, _ := json.Marshal(v); return b } func TestOperationalSecrets(t *testing.T) { testKey(t) o := &Operations{} enc, e := o.seal("do-not-leak", "mail:password") if e != nil { t.Fatal(e) } enc2, _ := o.seal("do-not-leak", "mail:password") if enc == enc2 || strings.Contains(enc, "do-not-leak") { t.Fatal("nonce or encryption failure") } p, e := o.open(enc, "mail:password") if e != nil || p != "do-not-leak" { t.Fatal("roundtrip") } if _, e = o.open(enc, "storage:secret_key"); e == nil { t.Fatal("domain substitution accepted") } t.Setenv("SETTINGS_MASTER_KEY", "") if _, e = o.open(enc, "mail:password"); e == nil { t.Fatal("missing key accepted") } } func TestFilterNormalizationExceptionsAndMarkdown(t *testing.T) { c := FilterConfig{Enabled: true, Rules: []FilterRule{{ID: "a", Word: "bad", Scopes: []string{"body"}, Action: "block", Enabled: true, Exceptions: []string{"badminton"}}, {ID: "b", Word: "hello", Scopes: []string{"body"}, Action: "log", Enabled: true}}} r := MatchFilter(c, "body", "HELLO badminton and BAD **bad** [label](https://bad.example)\n\n\u0060bad\u0060\n\n\u0060\u0060\u0060go\nbad\n\u0060\u0060\u0060") if r.Result != "block" { t.Fatal(r) } blocks, excepted := 0, 0 for _, h := range r.Hits { if h.Action == "block" { if h.Excepted { excepted++ } else { blocks++ } } } if blocks != 2 || excepted != 1 { t.Fatalf("blocks=%d excepted=%d text=%q", blocks, excepted, r.Text) } if MatchFilter(c, "title", "bad").Result != "pass" { t.Fatal("scope leak") } if MatchFilter(c, "body", "badminton").Result != "pass" { t.Fatal("exception not applied") } } func TestRateLimiterUsesRequestCategory(t *testing.T) { r := NewRateLimiter() r.SetLimit(RateLogin, 1) if !r.Allow("login:127.0.0.1") || r.Allow("login:127.0.0.1") { t.Fatal("per-IP key bypassed limit") } if !r.Allow("login:127.0.0.2") { t.Fatal("different IP denied") } } func TestPrivateServiceTargetDenied(t *testing.T) { t.Setenv("SERVICE_PRIVATE_HOSTS", "") c, e := safeDial(context.Background(), "tcp", "127.0.0.1:25") if c != nil { c.Close() } if e == nil || !strings.Contains(e.Error(), "受限") { t.Fatal(e) } } func TestModuleConflictAndAtomicDependency(t *testing.T) { testKey(t) o := testOps(t) cfg := defaultModule("security").(*SecurityConfig) cfg.CommentInterval = 15 var wins atomic.Int32 var wg sync.WaitGroup for range 8 { wg.Add(1) go func() { defer wg.Done() e := o.Save("security", 0, raw(cfg), nil, 1) if e == nil { wins.Add(1) } else if !errors.Is(e, ErrConfigConflict) { t.Errorf("unexpected: %v", e) } }() } wg.Wait() if wins.Load() != 1 { t.Fatalf("concurrent winners: %d", wins.Load()) } reopened := NewOperations(o.db, o.cfg) s, e := reopened.Security() if e != nil || s.CommentInterval != 15 { t.Fatal("restart persistence", e) } cfg.VerifyEmail = true cfg.AllowRegister = false if e = o.Save("security", 1, raw(cfg), nil, 1); e == nil { t.Fatal("dependency accepted") } s, _ = o.Security() if !s.AllowRegister || s.VerifyEmail { t.Fatal("failed save changed active config") } m := defaultModule("mail").(*MailConfig) m.Enabled = true m.Host = "smtp.example.com" m.Username = "user" m.Password = "secret-value" m.From = "sender@example.com" if e = o.Save("mail", 0, raw(m), nil, 1); e != nil { t.Fatal(e) } got, e := o.Read("mail") if e != nil { t.Fatal(e) } b, _ := json.Marshal(got) if strings.Contains(string(b), "secret-value") || strings.Contains(string(b), "mail:password") { t.Fatal("secret exposed") } cfg.AllowRegister = true if e = o.Save("security", 1, raw(cfg), nil, 1); e != nil { t.Fatal(e) } if e = o.Save("mail", 1, raw(map[string]any{"enabled": false}), nil, 1); e == nil { t.Fatal("mail dependency bypass") } if e = o.Save("mail", 1, raw(map[string]any{}), []string{"password"}, 1); e == nil { t.Fatal("dependent credential cleared") } if e = o.Save("mail", 1, raw(map[string]any{"password": ""}), nil, 1); e != nil { t.Fatal("blank failed to retain secret", e) } } func TestSharedQuotaAndFiniteLoginRecovery(t *testing.T) { o := testOps(t) var wins atomic.Int32 var wg sync.WaitGroup for range 20 { wg.Add(1) go func() { defer wg.Done() wait, e := o.Quota("same", 5, 60) if e != nil { t.Error(e) } else if wait == 0 { wins.Add(1) } }() } wg.Wait() if wins.Load() != 5 { t.Fatal(wins.Load()) } cfg := *defaultModule("security").(*SecurityConfig) for range cfg.LoginFailures { o.RecordFailure("Victim", "ip", cfg) } wait, e := o.FailureWait("victim", "other", cfg) if e != nil || wait <= 0 || wait > cfg.LoginWindow+1 { t.Fatal(wait, e) } o.db.Model(&model.ActionCounter{}).Where("key = ?", counterKey("failure:account:victim")).Update("expires_at", time.Now().Add(-time.Second)) if wait, e = o.FailureWait("victim", "other", cfg); e != nil || wait != 0 { t.Fatal("account did not recover") } } func smtpFixture(t *testing.T, authFail, stall bool) (string, int, *x509.CertPool, *atomic.Int32) { t.Helper() key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} der, e := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key) if e != nil { t.Fatal(e) } keyDER, _ := x509.MarshalECPrivateKey(key) cert, _ := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) pool := x509.NewCertPool() pool.AppendCertsFromPEM(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) ln, e := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}}) if e != nil { t.Fatal(e) } t.Cleanup(func() { ln.Close() }) var accepted atomic.Int32 go func() { for { conn, e := ln.Accept() if e != nil { return } go func() { defer conn.Close() _ = conn.SetDeadline(time.Now().Add(4 * time.Second)) if stall { time.Sleep(3 * time.Second) return } io.WriteString(conn, "220 test SMTP\r\n") r := bufio.NewReader(conn) for { line, e := r.ReadString('\n') if e != nil { return } switch { case strings.HasPrefix(line, "EHLO"): io.WriteString(conn, "250-test\r\n250 AUTH PLAIN\r\n") case strings.HasPrefix(line, "AUTH"): if authFail { io.WriteString(conn, "535 rejected\r\n") } else { io.WriteString(conn, "235 accepted\r\n") } case strings.HasPrefix(line, "DATA"): io.WriteString(conn, "354 go\r\n") for { l, e := r.ReadString('\n') if e != nil { return } if l == ".\r\n" { break } } accepted.Add(1) io.WriteString(conn, "250 queued\r\n") case strings.HasPrefix(line, "QUIT"): io.WriteString(conn, "221 bye\r\n") return default: io.WriteString(conn, "250 ok\r\n") } } }() } }() host, port, _ := net.SplitHostPort(ln.Addr().String()) n := 0 fmt.Sscan(port, &n) return host, n, pool, &accepted } func TestSMTPConnectionAcceptAuthAndTimeout(t *testing.T) { testKey(t) t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1") for _, mode := range []string{"success", "auth", "timeout", "certificate"} { t.Run(mode, func(t *testing.T) { host, port, roots, accepted := smtpFixture(t, mode == "auth", mode == "timeout") o := &Operations{smtpRoots: roots} if mode == "certificate" { o.smtpRoots = nil } secret, _ := o.seal("smtp-test-secret", "mail:password") c := MailConfig{Host: host, Port: port, TLS: "tls", Username: "test", Password: secret, From: "test@example.com", FromName: "Test", Timeout: 2} e := o.smtp(context.Background(), c, &mailPayload{To: "admin@example.com", Subject: "Test", Body: "
test
"}, "test") if mode == "success" { if e != nil || accepted.Load() != 1 { t.Fatal(e, accepted.Load()) } } else if e == nil { t.Fatal("failure expected") } }) } } func TestStorageProbeAndHistoricPrivateObject(t *testing.T) { testKey(t) t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1") o := testOps(t) var mu sync.Mutex objects := map[string][]byte{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() if r.Header.Get("Authorization") == "" { w.WriteHeader(403) return } switch r.Method { case "PUT": var body io.Reader = r.Body if strings.Contains(r.Header.Get("Content-Encoding"), "aws-chunked") || strings.HasPrefix(r.Header.Get("X-Amz-Content-Sha256"), "STREAMING-") { body = httputil.NewChunkedReader(r.Body) } b, _ := io.ReadAll(body) objects[r.URL.Path] = b w.Header().Set("ETag", `"test"`) w.WriteHeader(200) case "GET", "HEAD": b, ok := objects[r.URL.Path] if !ok { w.WriteHeader(404) return } w.Header().Set("Content-Length", fmt.Sprint(len(b))) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("ETag", `"test"`) w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) if r.Method == "GET" { w.Write(b) } case "DELETE": delete(objects, r.URL.Path) w.WriteHeader(204) default: w.WriteHeader(400) } })) defer srv.Close() c := defaultModule("storage").(*StorageConfig) c.Backend = "s3" c.Endpoint = srv.URL c.Bucket = "test-bucket" c.AccessKey = "test-access" c.SecretKey = "test-secret" if e := o.TestStorage(context.Background(), raw(c), nil); e != nil { t.Fatal(e) } if len(objects) != 0 { t.Fatal("probe leaked object") } if e := o.Save("storage", 0, raw(c), nil, 1); e != nil { t.Fatal(e) } path := filepath.Join(t.TempDir(), "file.txt") os.WriteFile(path, []byte("historic"), 0600) id, e := o.StoreFile(path, "text/plain", false) if e != nil { t.Fatal(e) } c.Backend = "local" c.AccessKey = "" c.SecretKey = "" if e = o.Save("storage", 1, raw(c), nil, 1); e != nil { t.Fatal(e) } if _, _, e = o.OpenObject(context.Background(), id, true); e == nil { t.Fatal("private object publicly accessible") } r, _, e := o.OpenObject(context.Background(), id, false) if e != nil { t.Fatal(e) } defer r.Close() b, _ := io.ReadAll(r) if string(b) != "historic" { t.Fatalf("%q", b) } _ = r.Close() if e = o.RemoveObject(id); e != nil { t.Fatal(e) } if _, _, e = o.OpenObject(context.Background(), id, false); e == nil { t.Fatal("deleted object accessible") } } func TestTemporaryCleanupProtectsActiveReferencedAndDraftFiles(t *testing.T) { o := testOps(t) if e := o.db.Exec("CREATE TABLE posts (content text); CREATE TABLE comments (content text)").Error; e != nil { t.Fatal(e) } dir := filepath.Join(o.cfg.DataDir, "uploads", "images") _ = os.MkdirAll(dir, 0700) path := filepath.Join(dir, "managed.png.partial") release, e := o.BeginTemporary(path) if e != nil { t.Fatal(e) } _ = os.WriteFile(path, []byte("temporary"), 0600) old := time.Now().Add(-8 * 24 * time.Hour) _ = os.Chtimes(path, old, old) o.db.Model(&model.TemporaryUpload{}).Where("id = ?", counterKey("uploads/images/managed.png.partial")).Update("created_at", old) candidates, e := o.temporaryCandidates() if e != nil || len(candidates) != 0 { t.Fatal("active upload selected", e) } release() candidates, e = o.temporaryCandidates() if e != nil || len(candidates) != 1 { t.Fatal("expired inactive upload not selected", e) } _ = o.db.Exec("INSERT INTO posts(content) VALUES (?)", "referenced "+filepath.Base(path)).Error r, e := o.CleanTemporary([]string{candidates[0].ID}) if e != nil || r["removed"] != 0 { t.Fatal("reference deleted", r, e) } o.db.Exec("DELETE FROM posts") draft := filepath.Join(dir, "draft.png") _ = os.WriteFile(draft, []byte("draft"), 0600) _ = os.Chtimes(draft, old, old) unknown := filepath.Join(dir, "legacy.png.partial") _ = os.WriteFile(unknown, []byte("unknown"), 0600) _ = os.Chtimes(unknown, old, old) r, e = o.CleanTemporary([]string{candidates[0].ID}) if e != nil || r["removed"] != 1 { t.Fatal(r, e) } for _, p := range []string{draft, unknown} { if _, e = os.Stat(p); e != nil { t.Fatal("protected file removed") } } r, e = o.CleanTemporary([]string{candidates[0].ID}) if e != nil || r["removed"] != 0 { t.Fatal("duplicate cleanup", r, e) } } func TestDurableMailRetriesAndCodeSingleUse(t *testing.T) { testKey(t) t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1") o := testOps(t) host, port, roots, _ := smtpFixture(t, true, false) o.smtpRoots = roots c := defaultModule("mail").(*MailConfig) c.Enabled = true c.Host = host c.Port = port c.From = "test@example.com" c.FromName = "Test" c.Username = "test" c.Password = "test" if e := o.Save("mail", 0, raw(c), nil, 1); e != nil { t.Fatal(e) } if e := o.EnqueueMail(o.db, "user@example.com", "register", "single-code", "same-task"); e != nil { t.Fatal(e) } if e := o.EnqueueMail(o.db, "user@example.com", "register", "single-code", "same-task"); e != nil { t.Fatal(e) } for range 3 { if e := o.ProcessMail(context.Background()); e != nil { t.Fatal(e) } o.db.Model(&model.MailTask{}).Where("dedupe = ?", "same-task").Update("next_at", time.Now().Add(-time.Second)) } rows, e := o.MailRows() if e != nil || len(rows) != 1 || rows[0].Status != "failed" || rows[0].Attempts != 3 || rows[0].Payload != "" { t.Fatal(rows, e) } challenge := model.EmailChallenge{Hash: counterKey("user@example.com:register:single-code"), Email: "user@example.com", Purpose: "register", ExpiresAt: time.Now().Add(time.Minute)} if e = o.db.Create(&challenge).Error; e != nil { t.Fatal(e) } if e = o.ConsumeCode("user@example.com", "register", "single-code"); e != nil { t.Fatal(e) } if e = o.ConsumeCode("user@example.com", "register", "single-code"); e == nil { t.Fatal("code replay") } } func TestMailQueueRecoveryAndDestinationQuota(t *testing.T) { testKey(t) t.Setenv("SERVICE_PRIVATE_HOSTS", "127.0.0.1") o := testOps(t) host, port, roots, accepted := smtpFixture(t, false, false) c := defaultModule("mail").(*MailConfig) c.Enabled = true c.Host = host c.Port = port c.Username = "test" c.Password = "secret" c.From = "test@example.com" c.FromName = "Test" if e := o.Save("mail", 0, raw(c), nil, 1); e != nil { t.Fatal(e) } security := defaultModule("security").(*SecurityConfig) security.VerifyEmail = true if e := o.Save("security", 0, raw(security), nil, 1); e != nil { t.Fatal(e) } if wait, e := o.SendCode("USER@example.com", "register", "192.0.2.1"); e != nil || wait != 0 { t.Fatal(wait, e) } if wait, e := o.SendCode("user@example.com", "register", "192.0.2.2"); e != nil || wait < 1 { t.Fatal("destination quota bypassed", wait, e) } var task model.MailTask if e := o.db.First(&task).Error; e != nil { t.Fatal(e) } if strings.Contains(task.Payload, "user@example.com") { t.Fatal("unencrypted queue") } o.db.Model(&task).Updates(map[string]any{"status": "sending", "attempts": 1, "next_at": time.Now().Add(-time.Minute)}) restarted := NewOperations(o.db, o.cfg) restarted.smtpRoots = roots if e := restarted.ProcessMail(context.Background()); e != nil { t.Fatal(e) } var finished model.MailTask o.db.First(&finished, task.ID) if finished.Status != "accepted" || finished.Attempts != 2 || finished.Payload != "" || accepted.Load() != 1 { t.Fatal("queue did not recover", finished.Status, finished.Attempts) } } func TestMailTemplateGuards(t *testing.T) { var cfg MailConfig cfg.applyTemplateDefaults() if cfg.BodyTemplate != defaultMailBody || cfg.SubjectTemplate != defaultMailSubject { t.Fatal("defaults not applied") } customBody := "{{code}}
" cfg.BodyTemplate = customBody cfg.applyTemplateDefaults() if cfg.BodyTemplate != customBody { t.Fatal("自定义正文不应被默认模板覆盖") } o := &Operations{cfg: &config.Config{SiteURL: "https://forum.example.com", DevMode: true}} if e := o.validate(nil, "mail", &MailConfig{Port: 465, TLS: "tls", Timeout: 10, Retention: 30, BodyTemplate: "no code
"}); e == nil || !strings.Contains(e.Error(), "{{code}}") { t.Fatal(e) } custom := `{{code}}
go` p := o.renderMail(MailConfig{BodyTemplate: custom}, "x", "reset") if strings.Contains(p.Body, "x") || !strings.Contains(p.Body, "<b>x</b>") || !strings.Contains(p.Body, "