Smail/backend/controllers/security_authentication_test.go
smail 67931eab5d
Some checks are pending
Security Checks / security (push) Waiting to run
强制超级管理员配置安全验证并保护本机密码重置
2026-09-19 00:24:27 +08:00

276 lines
11 KiB
Go

package controllers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/services/externalemail"
"git.pcux.cn/xuans/Smail/services/verification"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
func TestSecurityHandlersRejectUnauthenticatedRequests(t *testing.T) {
tests := []struct {
name string
hand func(*appcontext.Context, http.ResponseWriter, *http.Request)
body string
}{
{name: "get security question", hand: GetSecurityQuestion},
{name: "set security question", hand: SetSecurityQuestion, body: `{"question":"问题","answer":"答案"}`},
{name: "generate recovery codes", hand: GenerateRecoveryCodes, body: `{"count":5}`},
{name: "get recovery code status", hand: GetRecoveryCodeStatus},
{name: "revoke recovery codes", hand: RevokeRecoveryCodes},
{name: "list protocol credentials", hand: ListProtocolCredentials},
{name: "set protocol enabled", hand: SetProtocolCredentialEnabled, body: `{"protocol":"smtp","enabled":true}`},
{name: "generate protocol credential", hand: GenerateProtocolCredential, body: `{"protocol":"smtp"}`},
{name: "revoke protocol credential", hand: RevokeProtocolCredential, body: `{"protocol":"smtp"}`},
{name: "reveal protocol credentials", hand: RevealProtocolCredentials, body: `{"password":"FixtureP12"}`},
{name: "request verification", hand: RequestVerification, body: `{"purpose":"login","account":"smail","email":"user@example.com"}`},
{name: "verify verification", hand: VerifyVerification, body: `{"id":"challenge","account":"smail","email":"user@example.com","code":"12345678","purpose":"login"}`},
{name: "list external emails", hand: ListExternalEmails},
{name: "bind external email", hand: BindExternalEmail, body: `{"email":"backup@example.com","type":"backup"}`},
{name: "verify external email", hand: VerifyExternalEmail, body: `{"id":"challenge","binding_id":1,"email":"backup@example.com","code":"12345678"}`},
{name: "disable external email", hand: DisableExternalEmail, body: `{"id":1}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/security", strings.NewReader(test.body))
recorder := httptest.NewRecorder()
test.hand(&appcontext.Context{}, recorder, request)
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if result.ErrorNo != response.NeedLogin {
t.Fatalf("error number = %d, want %d; body=%s", result.ErrorNo, response.NeedLogin, recorder.Body.String())
}
})
}
}
func TestSuperAdminRecoveryCodesRequireRecentAuthentication(t *testing.T) {
ctx := &appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin}
request := httptest.NewRequest(http.MethodPost, "/api/security/credentials/recovery-codes", strings.NewReader(`{"count":5}`))
recorder := httptest.NewRecorder()
GenerateRecoveryCodes(ctx, recorder, request)
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
if result.ErrorNo != response.NoAccessPrivileges {
t.Fatalf("error number = %d, want %d; body=%s", result.ErrorNo, response.NoAccessPrivileges, recorder.Body.String())
}
}
func TestBindExternalEmailReturnsStableRecentAuthenticationCode(t *testing.T) {
previous := securityRuntime
securityRuntime.external = &externalemail.Repository{}
securityRuntime.verification = &verification.Service{}
t.Cleanup(func() { securityRuntime = previous })
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/security/external-email/bind",
strings.NewReader(`{"email":"backup@example.com","type":"backup"}`),
)
BindExternalEmail(&appcontext.Context{UserID: 1, UserAccount: "alice"}, recorder, request)
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
data, ok := result.Data.(map[string]any)
if result.ErrorNo != response.NoAccessPrivileges || !ok || data["code"] != string(access.CodeRecentAuthRequired) {
t.Fatalf("recent authentication response = %+v", result)
}
}
func TestChangeOwnExternalEmailRequiresRecentAuthentication(t *testing.T) {
previous := securityRuntime
securityRuntime.external = &externalemail.Repository{}
securityRuntime.verification = &verification.Service{}
t.Cleanup(func() { securityRuntime = previous })
recorder := httptest.NewRecorder()
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/security/external-email/change",
strings.NewReader(`{"email":"backup@example.com","type":"primary"}`),
)
ChangeOwnExternalEmail(&appcontext.Context{UserID: 1, UserAccount: "alice"}, recorder, request)
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
data, ok := result.Data.(map[string]any)
if result.ErrorNo != response.NoAccessPrivileges || !ok || data["code"] != string(access.CodeRecentAuthRequired) {
t.Fatalf("recent authentication response = %+v", result)
}
}
func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) {
_, _, engine, user, _ := newPublicSecurityFixture(t)
credentialService, err := credentials.NewService(engine)
if err != nil {
t.Fatal(err)
}
tempVault := t.TempDir()
credentialService.SetVaultRoot(tempVault)
previousDB := db.Instance
db.Instance = engine
previous := securityRuntime
securityRuntime.credentials = credentialService
t.Cleanup(func() {
securityRuntime = previous
db.Instance = previousDB
})
ctx := &appcontext.Context{UserID: user.ID, UserAccount: user.Account}
list := func() response.Response {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/security/credentials/protocols", nil)
ListProtocolCredentials(ctx, recorder, request)
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid protocol list response: %v; body=%s", err, recorder.Body.String())
}
return result
}
initial := list()
if initial.ErrorNo != 0 {
t.Fatalf("list protocol credentials failed: %+v", initial)
}
setRecorder := httptest.NewRecorder()
setRequest := httptest.NewRequest(
http.MethodPut,
"/api/security/credentials/protocols/enabled",
strings.NewReader(`{"protocol":"SMTP","enabled":true}`),
)
SetProtocolCredentialEnabled(ctx, setRecorder, setRequest)
var setResult response.Response
if err := json.Unmarshal(setRecorder.Body.Bytes(), &setResult); err != nil {
t.Fatalf("invalid protocol enable response: %v", err)
}
if setResult.ErrorNo != 0 {
t.Fatalf("enable protocol failed: %+v", setResult)
}
generateRecorder := httptest.NewRecorder()
generateRequest := httptest.NewRequest(
http.MethodPost,
"/api/security/credentials/protocols/generate",
strings.NewReader(`{"protocol":"smtp"}`),
)
GenerateProtocolCredential(ctx, generateRecorder, generateRequest)
var generateResult response.Response
if err := json.Unmarshal(generateRecorder.Body.Bytes(), &generateResult); err != nil {
t.Fatalf("invalid protocol generate response: %v", err)
}
if generateResult.ErrorNo != 0 {
t.Fatalf("generate protocol credential failed: %+v", generateResult)
}
generated, ok := generateResult.Data.(map[string]any)
if !ok || generated["code"] == "" || generated["shown_once"] != true {
t.Fatalf("unexpected generated protocol response: %#v", generateResult.Data)
}
if strings.Contains(generateRecorder.Body.String(), "code_hash") {
t.Fatal("protocol response exposed code hash")
}
status := list()
statusData, ok := status.Data.([]any)
if !ok || len(statusData) != 3 {
t.Fatalf("unexpected protocol status response: %#v", status.Data)
}
foundSMTP := false
for _, item := range statusData {
itemMap, ok := item.(map[string]any)
if !ok || itemMap["protocol"] != "smtp" {
continue
}
foundSMTP = true
if itemMap["enabled"] != true || itemMap["configured"] != true {
t.Fatalf("unexpected SMTP status: %#v", itemMap)
}
}
if !foundSMTP {
t.Fatal("SMTP protocol status was not returned")
}
revealFailRecorder := httptest.NewRecorder()
revealFailRequest := httptest.NewRequest(
http.MethodPost,
"/api/security/credentials/protocols/reveal",
strings.NewReader(`{"password":"wrong-password"}`),
)
RevealProtocolCredentials(ctx, revealFailRecorder, revealFailRequest)
var revealFailResult response.Response
if err := json.Unmarshal(revealFailRecorder.Body.Bytes(), &revealFailResult); err != nil {
t.Fatalf("invalid reveal fail response: %v", err)
}
if revealFailResult.ErrorNo == 0 {
t.Fatal("expected wrong password to fail reveal, got errorNo 0")
}
revealSuccessRecorder := httptest.NewRecorder()
revealSuccessRequest := httptest.NewRequest(
http.MethodPost,
"/api/security/credentials/protocols/reveal",
strings.NewReader(`{"password":"FixtureP12"}`),
)
RevealProtocolCredentials(ctx, revealSuccessRecorder, revealSuccessRequest)
var revealSuccessResult response.Response
if err := json.Unmarshal(revealSuccessRecorder.Body.Bytes(), &revealSuccessResult); err != nil {
t.Fatalf("invalid reveal success response: %v", err)
}
if revealSuccessResult.ErrorNo != 0 {
t.Fatalf("reveal protocol credentials failed: %+v", revealSuccessResult)
}
revealedMap, ok := revealSuccessResult.Data.(map[string]any)
if !ok {
t.Fatalf("expected map[string]any reveal response, got %#v", revealSuccessResult.Data)
}
if revealedMap["smtp"] != generated["code"] {
t.Fatalf("expected revealed smtp code %v, got %v", generated["code"], revealedMap["smtp"])
}
revokeRecorder := httptest.NewRecorder()
revokeRequest := httptest.NewRequest(
http.MethodPost,
"/api/security/credentials/protocols/revoke",
strings.NewReader(`{"protocol":"smtp"}`),
)
RevokeProtocolCredential(ctx, revokeRecorder, revokeRequest)
var revokeResult response.Response
if err := json.Unmarshal(revokeRecorder.Body.Bytes(), &revokeResult); err != nil {
t.Fatalf("invalid protocol revoke response: %v", err)
}
if revokeResult.ErrorNo != 0 {
t.Fatalf("revoke protocol credential failed: %+v", revokeResult)
}
status = list()
statusData, ok = status.Data.([]any)
if !ok || len(statusData) != 3 {
t.Fatalf("unexpected revoked protocol status response: %#v", status.Data)
}
for _, item := range statusData {
itemMap, ok := item.(map[string]any)
if ok && itemMap["protocol"] == "smtp" && itemMap["configured"] == true {
t.Fatal("revoked SMTP protocol credential remained configured")
}
}
}