完善第三方协议登录测试
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-31 02:31:47 +08:00
parent 18f9fd34e5
commit 065d8a5417
3 changed files with 213 additions and 30 deletions

View File

@ -9,6 +9,7 @@ import (
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/credentials"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
@ -23,6 +24,10 @@ func TestSecurityHandlersRejectUnauthenticatedRequests(t *testing.T) {
{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: "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},
@ -61,3 +66,114 @@ func TestSuperAdminCannotManageRecoveryCodesThroughWeb(t *testing.T) {
t.Fatalf("error number = %d, want %d; body=%s", result.ErrorNo, response.NoAccessPrivileges, recorder.Body.String())
}
}
func TestProtocolCredentialHandlersManageCurrentUserCredential(t *testing.T) {
_, _, engine, user, _ := newPublicSecurityFixture(t)
credentialService, err := credentials.NewService(engine)
if err != nil {
t.Fatal(err)
}
previous := securityRuntime
securityRuntime.credentials = credentialService
t.Cleanup(func() { securityRuntime = previous })
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")
}
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")
}
}
}

View File

@ -187,6 +187,10 @@ func TestFrontendV1RoutesAreRegistered(t *testing.T) {
"/api/v1/security/credentials/recovery-codes",
"/api/v1/security/credentials/recovery-codes/generate",
"/api/v1/security/credentials/recovery-codes/revoke",
"/api/v1/security/credentials/protocols",
"/api/v1/security/credentials/protocols/enabled",
"/api/v1/security/credentials/protocols/generate",
"/api/v1/security/credentials/protocols/revoke",
"/api/v1/security/external-email/bind",
"/api/v1/security/external-email/disable",
"/api/v1/security/external-email/list",

View File

@ -19,6 +19,7 @@ import (
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/utils/array"
pcontext "git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/password"
@ -30,6 +31,7 @@ import (
var clientUnLogin *imapclient.Client
var clientLogin *imapclient.Client
var imapTestAddr string
var imapTestProtocolCode string
func TestMain(m *testing.M) {
wd, err := os.Getwd()
@ -97,7 +99,7 @@ func TestMain(m *testing.M) {
panic(err)
}
err = clientLogin.Login("testCase", "testCase").Wait()
err = clientLogin.Login("testCase@test.domain", imapTestProtocolCode).Wait()
if err != nil {
panic(err)
}
@ -158,35 +160,67 @@ func seedIMAPTestData() {
}
var num int
_, err = db.Instance.Table(&models.UserEmail{}).Where("user_id=?", user.ID).Select("count(1)").Get(&num)
_, err = db.Instance.Table(&models.UserEmail{}).Where("user_id=? AND email_id > 0", user.ID).Select("count(1)").Get(&num)
if err != nil {
panic(err)
}
if num > 0 {
return
if num == 0 {
statuses := []int8{3}
for i := 0; i < 10; i++ {
statuses = append(statuses, 0)
}
for _, status := range statuses {
email := models.Email{
Subject: "test",
Status: status,
FromAddress: "sender@test.domain",
To: `[{"EmailAddress":"testCase@test.domain","Name":"testCase"}]`,
MsgID: "imap-test@test.domain",
SendDate: time.Now(),
}
if _, err = db.Instance.Insert(&email); err != nil {
panic(err)
}
ue := models.UserEmail{UserID: user.ID, EmailID: email.Id, Status: status}
if _, err = db.Instance.Insert(&ue); err != nil {
panic(err)
}
}
}
statuses := []int8{3}
for i := 0; i < 10; i++ {
statuses = append(statuses, 0)
canonicalAddress := "testcase@test.domain"
var loginBinding models.UserEmail
hasBinding, err := db.Instance.Table(&models.UserEmail{}).
Where("user_id = ? AND address_canonical = ?", user.ID, canonicalAddress).
Get(&loginBinding)
if err != nil {
panic(err)
}
for _, status := range statuses {
email := models.Email{
Subject: "test",
Status: status,
FromAddress: "sender@test.domain",
To: `[{"EmailAddress":"testCase@test.domain","Name":"testCase"}]`,
MsgID: "imap-test@test.domain",
SendDate: time.Now(),
if !hasBinding {
verifiedAt := time.Now().UTC()
loginBinding = models.UserEmail{
UserID: user.ID,
Address: "testCase@test.domain",
AddressCanonical: &canonicalAddress,
VerifiedAt: &verifiedAt,
IsPrimary: 1,
}
if _, err = db.Instance.Insert(&email); err != nil {
panic(err)
}
ue := models.UserEmail{UserID: user.ID, EmailID: email.Id, Status: status}
if _, err = db.Instance.Insert(&ue); err != nil {
if _, err = db.Instance.Insert(&loginBinding); err != nil {
panic(err)
}
}
credentialService, err := credentials.NewService(db.Instance)
if err != nil {
panic(err)
}
if err = credentialService.SetProtocolEnabled(user.ID, credentials.ProtocolIMAP, true); err != nil {
panic(err)
}
imapTestProtocolCode, err = credentialService.GenerateProtocolCode(user.ID, credentials.ProtocolIMAP)
if err != nil {
panic(err)
}
}
func TestCapability(t *testing.T) {
@ -210,7 +244,7 @@ func TestCapability(t *testing.T) {
}
func TestLogin(t *testing.T) {
err := clientUnLogin.Login("testCase", "testCaseasdfsadf").Wait()
err := clientUnLogin.Login("testCase@test.domain", "wrong-imap-code").Wait()
sErr := err.(*imap.Error)
if sErr.Code != "AUTHENTICATIONFAILED" {
t.Error("Login Error")
@ -495,19 +529,41 @@ func TestClose(t *testing.T) {
}
func TestExpunge(t *testing.T) {
if _, err := clientLogin.Select("INBOX", &imap.SelectOptions{}).Wait(); err != nil {
t.Fatal(err)
}
clientLogin.Select("INBOX", &imap.SelectOptions{}).Wait()
res, err := clientLogin.UIDExpunge(imap.UIDSetNum(1, 2)).Collect()
var targets []models.UserEmail
if err := db.Instance.Table("user_email").
Where("user_id = ? AND email_id > 0", 1).
OrderBy("id ASC").
Limit(2).
Find(&targets); err != nil {
t.Fatal(err)
}
if len(targets) == 0 {
t.Fatal("no email relations available for expunge")
}
uids := make([]imap.UID, 0, len(targets))
for _, target := range targets {
uids = append(uids, imap.UID(target.ID))
}
res, err := clientLogin.UIDExpunge(imap.UIDSetNum(uids...)).Collect()
if err != nil {
t.Errorf("%+v", err)
}
t.Logf("%+v", res)
var ues []models.UserEmail
db.Instance.Table("user_email").Where("id=1 or id=2").Find(&ues)
if len(ues) > 0 {
t.Errorf("TestExpunge Error")
for _, target := range targets {
var remaining models.UserEmail
found, err := db.Instance.ID(target.ID).Get(&remaining)
if err != nil {
t.Fatal(err)
}
if found {
t.Errorf("email relation %d was not expunged", target.ID)
}
}
}
@ -620,6 +676,13 @@ func TestNoop(t *testing.T) {
}
}
func TestIDLE(t *testing.T) {
credentialService, err := credentials.NewService(db.Instance)
if err != nil {
t.Fatal(err)
}
if !credentialService.VerifyProtocolCode(1, credentials.ProtocolIMAP, imapTestProtocolCode) {
t.Fatalf("test IMAP authorization code was rejected before raw LOGIN: %q", imapTestProtocolCode)
}
conn, err := tls.Dial("tcp", imapTestAddr, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatal(err)
@ -635,7 +698,7 @@ func TestIDLE(t *testing.T) {
t.Fatalf("unexpected greeting: %s", line)
}
if _, err = conn.Write([]byte("a001 LOGIN testCase testCase\r\n")); err != nil {
if _, err = conn.Write([]byte("a001 LOGIN testCase@test.domain " + imapTestProtocolCode + "\r\n")); err != nil {
t.Fatal(err)
}
if line, err = readTaggedLine(reader, "a001"); err != nil {
@ -699,7 +762,7 @@ func TestIdleNotice(t *testing.T) {
}
defer client.Close()
if err = client.Login("testCase", "testCase").Wait(); err != nil {
if err = client.Login("testCase@test.domain", imapTestProtocolCode).Wait(); err != nil {
t.Fatal(err)
}