diff --git a/server/controllers/security_authentication_test.go b/server/controllers/security_authentication_test.go index 48157db..755dce4 100644 --- a/server/controllers/security_authentication_test.go +++ b/server/controllers/security_authentication_test.go @@ -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") + } + } +} diff --git a/server/listen/http_server/api_v1_compat_test.go b/server/listen/http_server/api_v1_compat_test.go index 3b4bf18..0578815 100644 --- a/server/listen/http_server/api_v1_compat_test.go +++ b/server/listen/http_server/api_v1_compat_test.go @@ -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", diff --git a/server/listen/imap_server/imap_server_test.go b/server/listen/imap_server/imap_server_test.go index 4f8c185..99f1838 100644 --- a/server/listen/imap_server/imap_server_test.go +++ b/server/listen/imap_server/imap_server_test.go @@ -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) }