Smail/server/services/credentials/service_test.go

110 lines
3.0 KiB
Go

package credentials
import (
"errors"
"path/filepath"
"sync"
"testing"
"git.pcux.cn/xuans/Smail/models"
_ "modernc.org/sqlite"
"xorm.io/xorm"
)
func newFixture(t *testing.T) *Service {
t.Helper()
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "credentials.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Close() })
if err := engine.Sync2(&models.User{}, &models.SecurityQuestion{}, &models.RecoveryCode{}, &models.WebAuthnCredential{}); err != nil {
t.Fatal(err)
}
if _, err := engine.Insert(&models.User{Account: "alice", Name: "Alice", Password: "unused", Role: "user"}); err != nil {
t.Fatal(err)
}
service, err := NewService(engine)
if err != nil {
t.Fatal(err)
}
return service
}
func TestSecurityQuestionIsCaseInsensitiveAndNeverReturnsHash(t *testing.T) {
service := newFixture(t)
if err := service.SetSecurityQuestion(1, "第一只宠物叫什么?", "BlueBird123"); err != nil {
t.Fatal(err)
}
question, ok, err := service.GetSecurityQuestion(1)
if err != nil || !ok || question == "" {
t.Fatalf("question = %q, ok=%v, err=%v", question, ok, err)
}
if verified, err := service.VerifySecurityQuestion(1, " bluebird123 "); err != nil || !verified {
t.Fatalf("verify = %v, %v", verified, err)
}
if verified, err := service.VerifySecurityQuestion(1, "wronganswer"); err != nil || verified {
t.Fatalf("wrong answer = %v, %v", verified, err)
}
var stored models.SecurityQuestion
if _, err := service.engine.ID(1).Get(&stored); err != nil {
t.Fatal(err)
}
if stored.AnswerHash == "BlueBird123" || stored.AnswerHash == "bluebird123" {
t.Fatal("answer was stored in plaintext")
}
}
func TestRecoveryCodesAreShownOnceAndConsumedAtomically(t *testing.T) {
service := newFixture(t)
codes, err := service.GenerateRecoveryCodes(1, 5)
if err != nil || len(codes) != 5 {
t.Fatalf("codes = %v, err=%v", codes, err)
}
var stored []models.RecoveryCode
if err := service.engine.Find(&stored); err != nil {
t.Fatal(err)
}
for _, item := range stored {
if item.CodeHash == codes[0] {
t.Fatal("recovery code was stored in plaintext")
}
}
if err := service.ConsumeRecoveryCode(1, codes[0]); err != nil {
t.Fatal(err)
}
if err := service.ConsumeRecoveryCode(1, codes[0]); !errors.Is(err, ErrCodeNotFound) {
t.Fatalf("replay error = %v", err)
}
}
func TestRecoveryCodeConcurrentConsumptionHasOneWinner(t *testing.T) {
service := newFixture(t)
codes, err := service.GenerateRecoveryCodes(1, 5)
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
results := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
results <- service.ConsumeRecoveryCode(1, codes[0])
}()
}
wg.Wait()
close(results)
winners := 0
for err := range results {
if err == nil {
winners++
} else if !errors.Is(err, ErrCodeNotFound) && !errors.Is(err, ErrCodeConsumed) {
t.Fatalf("unexpected error: %v", err)
}
}
if winners != 1 {
t.Fatalf("winners = %d", winners)
}
}