Smail/backend/controllers/security_setup_test.go
smail 914bdc67e2
Some checks are pending
Security Checks / security (push) Waiting to run
按实际安全设置状态重复提醒并仅关闭当前登录提示
2026-09-18 23:32:06 +08:00

237 lines
8.5 KiB
Go

package controllers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"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 setupSecurityPromptTest(t *testing.T) *xorm.Engine {
t.Helper()
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{}, &models.ExternalEmail{}, &models.RecoveryCode{}); err != nil {
t.Fatal(err)
}
previousRuntime := securityRuntime
securityRuntime.credentials = nil
t.Cleanup(func() { securityRuntime = previousRuntime })
return engine
}
func TestSecuritySetupAcknowledgementIsSessionScoped(t *testing.T) {
engine := setupSecurityPromptTest(t)
previousSession, previousConfig, previousManager := session.Instance, config.Instance, config.CurrentManager()
session.Instance = scs.New()
config.SetRuntimeManager(nil)
config.Instance = &config.Config{Domain: "example.com"}
t.Cleanup(func() {
session.Instance, config.Instance = previousSession, previousConfig
config.SetRuntimeManager(previousManager)
})
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")
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", strings.NewReader(`{"user_id":2}`))
loadedSession, err := session.Instance.Load(request.Context(), "")
if err != nil {
t.Fatal(err)
}
request = request.WithContext(loadedSession)
if err := session.PutUser(loadedSession, &first); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
recorder := httptest.NewRecorder()
AcknowledgeSecuritySetup(&appcontext.Context{UserID: first.ID}, recorder, request)
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 != 0 || untouched.SecuritySetupAcknowledged != 0 {
t.Fatal("dismissal must not persist on either account")
}
fields := loginSecurityFields(&loaded)
if fields["showSecuritySetupPrompt"] != true || fields["showRecoverySetupPrompt"] != false {
t.Fatal("incomplete setup must be reported again on login")
}
if untouched.Password != second.Password || loaded.SecurityVersion != first.SecurityVersion {
t.Fatal("acknowledgement changed security credentials")
}
otherSession, err := session.Instance.Load(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if session.Instance.GetBool(otherSession, session.SecuritySetupDismissedKey) {
t.Fatal("dismissal leaked to another session")
}
token, _, err := session.Instance.Commit(loadedSession)
if err != nil {
t.Fatal(err)
}
refreshed, err := session.Instance.Load(context.Background(), token)
if err != nil {
t.Fatal(err)
}
assertInfoPrompt := func(want bool) {
t.Helper()
recorder := httptest.NewRecorder()
Info(&appcontext.Context{UserID: first.ID, Role: access.RoleSuperAdmin}, recorder, request.WithContext(refreshed))
var payload struct {
ErrorNo int `json:"errorNo"`
Data struct {
Show bool `json:"showSecuritySetupPrompt"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload.ErrorNo != 0 || payload.Data.Show != want {
t.Fatalf("unexpected refreshed prompt: %s", recorder.Body.String())
}
}
assertInfoPrompt(false)
if err := session.PutUser(refreshed, &first); err != nil {
t.Fatal(err)
}
assertInfoPrompt(true)
}
func TestSecuritySetupRequiresUsableCredentials(t *testing.T) {
for _, role := range []access.Role{access.RoleUser, access.RoleAdmin, access.RoleSuperAdmin} {
for _, emailStatus := range []string{"", models.ExternalEmailStatusPending, models.ExternalEmailStatusVerified, models.ExternalEmailStatusDisabled} {
for _, codeStatus := range []string{"missing", "valid", "used"} {
t.Run(string(role)+"/"+emailStatus+"/"+codeStatus, func(t *testing.T) {
engine := setupSecurityPromptTest(t)
user := models.User{Account: "alice", Role: string(role), SecuritySetupAcknowledged: 1}
if _, err := engine.Insert(&user); err != nil {
t.Fatal(err)
}
if emailStatus != "" {
if _, err := engine.Insert(&models.ExternalEmail{UserID: user.ID, Status: emailStatus, EmailFingerprint: "fixture"}); err != nil {
t.Fatal(err)
}
}
if codeStatus != "missing" {
code := models.RecoveryCode{UserID: user.ID, CodeHash: "fixture"}
if codeStatus == "used" {
now := time.Now()
code.UsedAt = &now
}
if _, err := engine.Insert(&code); err != nil {
t.Fatal(err)
}
}
fields := loginSecurityFields(&user)
emailReady := emailStatus == models.ExternalEmailStatusVerified
codeReady := codeStatus == "valid" || role == access.RoleSuperAdmin
if fields["externalEmailConfigured"] != emailReady || fields["showSecuritySetupPrompt"] != (!emailReady || !codeReady) {
t.Fatalf("unexpected security state: %#v", fields)
}
})
}
}
}
}
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")
}
}