Files
ttstd-infinite-canvas/backend/internal/auth/account_test.go
2026-10-13 05:31:30 +08:00

232 lines
8.6 KiB
Go

package auth
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"gorm.io/gorm"
"yingce/backend/internal/model"
)
type profileTestHost struct{ nopHost }
func (profileTestHost) SettingsEncryptionKey() ([]byte, error) {
return []byte("profile-test-key-32-bytes-long!!!"), nil
}
func newProfileTestService(t *testing.T) (*Service, *gorm.DB, *model.User, string) {
t.Helper()
s, db := newPasswordResetTestService(t)
s.host = profileTestHost{}
if err := db.AutoMigrate(&model.AuthVerification{}, &model.NotificationQuota{}, &model.UserIdentity{}, &model.OAuthState{}); err != nil {
t.Fatal(err)
}
hash, err := HashPassword("old-password")
if err != nil {
t.Fatal(err)
}
u := &model.User{ID: "profile-user", Username: "profile-user", Email: "old@example.com", Status: model.UserStatusActive, PasswordHash: hash}
if err := db.Create(u).Error; err != nil {
t.Fatal(err)
}
for _, id := range []string{"session-one", "session-two"} {
if err := db.Create(&model.AuthSession{ID: id, UserID: u.ID, TokenHash: HashToken("token"), ExpiresAt: time.Now().Add(time.Hour)}).Error; err != nil {
t.Fatal(err)
}
}
return s, db, u, "session-one.token"
}
func TestProfilePasswordRequiresCurrentCredentialsAndRevokesAllSessions(t *testing.T) {
s, db, u, cookie := newProfileTestService(t)
if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{CurrentPassword: "wrong", NewPassword: "new-password"}); err == nil {
t.Fatal("wrong password accepted")
}
if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{CurrentPassword: "old-password", NewPassword: "new-password"}); err != nil {
t.Fatal(err)
}
var updated model.User
db.First(&updated, "id = ?", u.ID)
if !verifyPassword("new-password", updated.PasswordHash) {
t.Fatal("password not changed")
}
var count int64
db.Model(&model.AuthSession{}).Where("user_id = ?", u.ID).Count(&count)
if count != 0 {
t.Fatal("sessions not revoked")
}
if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{CurrentPassword: "old-password", NewPassword: "third-password"}); err == nil {
t.Fatal("stale actor/session accepted")
}
}
func TestProfileOAuthAccountNeedsRecentSameSessionReauthentication(t *testing.T) {
s, db, u, cookie := newProfileTestService(t)
db.Model(u).Update("password_hash", "")
u.PasswordHash = ""
if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{NewPassword: "new-password"}); err == nil {
t.Fatal("OAuth account accepted without reauthentication")
}
now := time.Now()
db.Model(&model.AuthSession{}).Where("id = ?", "session-two").Update("reauthenticated_at", now)
if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{NewPassword: "new-password"}); err == nil {
t.Fatal("another session reauthentication accepted")
}
expired := now.Add(-6 * time.Minute)
db.Model(&model.AuthSession{}).Where("id = ?", "session-one").Update("reauthenticated_at", expired)
if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{NewPassword: "new-password"}); err == nil {
t.Fatal("expired reauthentication accepted")
}
db.Model(&model.AuthSession{}).Where("id = ?", "session-one").Update("reauthenticated_at", now)
if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{NewPassword: "new-password"}); err != nil {
t.Fatal(err)
}
}
func TestProfileEmailTicketBoundToSessionAndReportsOldMailFailure(t *testing.T) {
s, db, u, cookie := newProfileTestService(t)
var code string
s.SetMailSender(func(_ EmailSettingValue, to, subject, body string) error {
if to == "new@external.example" {
code = codeFromEmailBody(body)
return nil
}
return errors.New("mail unavailable")
})
ticket, err := s.SendProfileEmailCode(context.Background(), u, cookie, ProfileEmailCodeRequest{Email: "new@external.example", CurrentPassword: "old-password"})
if err != nil {
t.Fatal(err)
}
if len(code) != 6 {
t.Fatal("missing code")
}
req := ProfileEmailChangeRequest{Ticket: ticket.Ticket, EmailCode: code, CurrentPassword: "old-password"}
if _, err := s.ChangeProfileEmail(u, "session-two.token", req); err == nil {
t.Fatal("another session used ticket")
}
result, err := s.ChangeProfileEmail(u, cookie, req)
if err != nil {
t.Fatal(err)
}
if !result.Changed || result.NotificationStatus != "failed" {
t.Fatalf("result %#v", result)
}
var updated model.User
db.First(&updated, "id = ?", u.ID)
if updated.Email != "new@external.example" || updated.EmailVerifiedAt == nil {
t.Fatalf("updated user %#v", updated)
}
var count int64
db.Model(&model.AuthSession{}).Where("user_id = ?", u.ID).Count(&count)
if count != 0 {
t.Fatal("sessions remain")
}
if _, err := s.ChangeProfileEmail(u, cookie, req); err == nil {
t.Fatal("reused ticket accepted")
}
}
func TestProfileEmailDisabledAndDuplicateAreRejected(t *testing.T) {
s, db, u, cookie := newProfileTestService(t)
db.Create(&model.User{ID: "other", Username: "other", Email: "used@example.com", Status: model.UserStatusActive})
if _, err := s.SendProfileEmailCode(context.Background(), u, cookie, ProfileEmailCodeRequest{Email: "used@example.com", CurrentPassword: "old-password"}); err == nil {
t.Fatal("duplicate email accepted")
}
db.Model(&model.SystemSetting{}).Where("key = ?", emailSettingKey).Update("value_json", `{"enabled":false}`)
if _, err := s.SendProfileEmailCode(context.Background(), u, cookie, ProfileEmailCodeRequest{Email: "new@example.com", CurrentPassword: "old-password"}); err == nil {
t.Fatal("disabled SMTP accepted")
}
}
func TestProfileLinuxDOReauthenticationRequiresMatchingSubjectAndSession(t *testing.T) {
for _, mode := range []string{"matching", "wrong-subject", "wrong-session", "expired-session"} {
t.Run(mode, func(t *testing.T) {
s, db, u, cookie := newProfileTestService(t)
subject := "linked-subject"
if mode == "wrong-subject" {
subject = "another-person"
}
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
upstreamCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalls++
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/token" {
w.Write([]byte(`{"access_token":"test-access-token"}`))
return
}
json.NewEncoder(w).Encode(map[string]string{"id": subject})
}))
defer server.Close()
setting, err := json.Marshal(linuxDOSettingValue{Enabled: true, ClientID: "test", AuthorizationURL: server.URL + "/authorize", TokenURL: server.URL + "/token", UserInfoURL: server.URL + "/userinfo", RedirectURL: "http://localhost/callback"})
if err != nil {
t.Fatal(err)
}
if err := db.Create(&model.SystemSetting{Key: linuxDOSettingKey, ValueJSON: string(setting)}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.UserIdentity{ID: "identity", UserID: u.ID, Provider: "linuxdo", Subject: "linked-subject"}).Error; err != nil {
t.Fatal(err)
}
location, err := s.BeginLinuxDOReauthentication(u, cookie)
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(location)
if err != nil {
t.Fatal(err)
}
state := parsed.Query().Get("state")
isReauth, err := s.LinuxDOReauthenticationState(state)
if err != nil || !isReauth {
t.Fatalf("state marker %v %v", isReauth, err)
}
if mode == "wrong-session" {
cookie = "session-two.token"
}
if mode == "expired-session" {
db.Model(&model.AuthSession{}).Where("id = ?", "session-one").Update("expires_at", time.Now().Add(-time.Minute))
}
result, err := s.CompleteLinuxDOLogin(state, "test-code", cookie)
if mode == "matching" {
if err != nil {
t.Fatal(err)
}
if !result.Reauthenticated || result.Session != nil || result.Next != "/settings?section=profile&reauth=success" {
t.Fatalf("result %#v", result)
}
} else if err == nil {
t.Fatal("invalid reauthentication accepted")
}
if (mode == "wrong-session" || mode == "expired-session") && upstreamCalls != 0 {
t.Fatal("invalid session contacted provider")
}
var session model.AuthSession
if err := db.First(&session, "id = ?", "session-one").Error; err != nil {
t.Fatal(err)
}
if (session.ReauthenticatedAt != nil) != (mode == "matching") {
t.Fatal("incorrect reauthentication marker")
}
var count int64
db.Model(&model.AuthSession{}).Count(&count)
if count != 2 {
t.Fatal("reauthentication created a session")
}
db.Model(&model.User{}).Count(&count)
if count != 1 {
t.Fatal("reauthentication created a user")
}
if _, err := s.CompleteLinuxDOLogin(state, "test-code", cookie); err == nil {
t.Fatal("OAuth state reused")
}
})
}
}