Smail/backend/controllers/security_setup_test.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

139 lines
5.0 KiB
Go

package controllers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/session"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/csrf"
"github.com/alexedwards/scs/v2"
"xorm.io/xorm"
)
func TestLogoutDestroysSessionAndCSRFCookies(t *testing.T) {
previous := session.Instance
session.Instance = scs.New()
t.Cleanup(func() { session.Instance = previous })
request := httptest.NewRequest(http.MethodPost, "https://mail.example.com/api/v1/logout", nil)
loaded, err := session.Instance.Load(request.Context(), "")
if err != nil {
t.Fatal(err)
}
session.Instance.Put(loaded, csrf.SessionKey, "test-csrf")
token, _, err := session.Instance.Commit(loaded)
if err != nil {
t.Fatal(err)
}
request.AddCookie(&http.Cookie{Name: session.Instance.Cookie.Name, Value: token})
recorder := httptest.NewRecorder()
session.Instance.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Logout(&appcontext.Context{Context: r.Context(), UserID: 1}, w, r)
})).ServeHTTP(recorder, request)
removed := map[string]bool{}
for _, cookie := range recorder.Result().Cookies() {
removed[cookie.Name] = cookie.MaxAge < 0 && cookie.Value == ""
}
if !removed[session.Instance.Cookie.Name] || !removed[csrf.CookieName] {
t.Fatal("logout must expire both session and CSRF cookies")
}
_, exists, err := session.Instance.Store.Find(token)
if err != nil || exists {
t.Fatal("logout left a valid stored session")
}
}
func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T) {
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "onboarding.db"))
if err != nil {
t.Fatal(err)
}
previous := db.Instance
db.Instance = engine
t.Cleanup(func() { db.Instance = previous; _ = engine.Close() })
if err := engine.Sync2(&models.User{}); err != nil {
t.Fatal(err)
}
first := models.User{Account: "root", Name: "Root", Role: string(access.RoleSuperAdmin)}
second := models.User{Account: "alice", Name: "Alice", Role: string(access.RoleUser)}
if _, err := engine.Insert(&first, &second); err != nil {
t.Fatal(err)
}
if loginSecurityFields(&first)["showSecuritySetupPrompt"] != true {
t.Fatal("first super administrator login must show security setup")
}
for i := 0; i < 2; i++ {
recorder := httptest.NewRecorder()
AcknowledgeSecuritySetup(&appcontext.Context{UserID: first.ID}, recorder,
httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", strings.NewReader(`{"user_id":2}`)))
var payload struct {
ErrorNo int `json:"errorNo"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload.ErrorNo != 0 {
t.Fatalf("acknowledgement failed: %s", recorder.Body.String())
}
}
var loaded, untouched models.User
if _, err := engine.ID(first.ID).Get(&loaded); err != nil {
t.Fatal(err)
}
if _, err := engine.ID(second.ID).Get(&untouched); err != nil {
t.Fatal(err)
}
if loaded.SecuritySetupAcknowledged != 1 || untouched.SecuritySetupAcknowledged != 0 {
t.Fatal("acknowledgement changed the wrong account")
}
fields := loginSecurityFields(&loaded)
if fields["showSecuritySetupPrompt"] != false || fields["showRecoverySetupPrompt"] != false {
t.Fatal("acknowledged super administrator must not receive either prompt")
}
if untouched.Password != second.Password || loaded.SecurityVersion != first.SecurityVersion {
t.Fatal("acknowledgement changed security credentials")
}
}
func TestSecuritySetupRejectsAnonymous(t *testing.T) {
recorder := httptest.NewRecorder()
AcknowledgeSecuritySetup(nil, recorder, httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", nil))
var payload struct {
ErrorNo int `json:"errorNo"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload.ErrorNo == 0 {
t.Fatal("anonymous acknowledgement was accepted")
}
}
func TestLegacyConfigStatusReportsUnavailable(t *testing.T) {
previous := config.CurrentManager()
config.SetRuntimeManager(nil)
t.Cleanup(func() { config.SetRuntimeManager(previous) })
recorder := httptest.NewRecorder()
ConfigStatus(&appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin}, recorder,
httptest.NewRequest(http.MethodGet, "/api/v1/config/status", nil))
var payload struct {
ErrorNo int `json:"errorNo"`
Data struct {
Available bool `json:"available"`
Reason string `json:"unavailableReason"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if recorder.Code != http.StatusOK || payload.ErrorNo != 0 || payload.Data.Available || payload.Data.Reason == "" {
t.Fatalf("unexpected config status: %s", recorder.Body.String())
}
recorder = httptest.NewRecorder()
if _, ok := configManagerOrError(recorder); ok || recorder.Code != http.StatusServiceUnavailable {
t.Fatal("unavailable config operation must return HTTP 503")
}
}