refac: hardening pass with multiple overhauls
This commit is contained in:
1 parent
b0f7b7892b
commit
da36c78bc0
28 files changed
+1375
-39
No files matched your search
@@ -0,0 +1,307 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/auth"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const atomicAcceptRuns = 50
|
||||
|
||||
type atomicPlainHasher struct{}
|
||||
|
||||
func (atomicPlainHasher) Hash(value string) (string, error) {
|
||||
return "hashed:" + value, nil
|
||||
}
|
||||
|
||||
func (atomicPlainHasher) Compare(hashed string, value string) error {
|
||||
if hashed == "hashed:"+value {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("hash mismatch")
|
||||
}
|
||||
|
||||
type atomicInviteRepoDecorator struct {
|
||||
invite.Repository
|
||||
afterGetAll func()
|
||||
}
|
||||
|
||||
func (d *atomicInviteRepoDecorator) GetAll() []invite.Invite {
|
||||
all := d.Repository.GetAll()
|
||||
d.afterGetAll()
|
||||
return all
|
||||
}
|
||||
|
||||
type atomicLookupBarrier struct {
|
||||
mu sync.Mutex
|
||||
arrived int
|
||||
parties int
|
||||
open chan struct{}
|
||||
}
|
||||
|
||||
func newAtomicLookupBarrier(parties int) *atomicLookupBarrier {
|
||||
return &atomicLookupBarrier{parties: parties, open: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (b *atomicLookupBarrier) wait() {
|
||||
b.mu.Lock()
|
||||
b.arrived++
|
||||
if b.arrived == b.parties {
|
||||
close(b.open)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
select {
|
||||
case <-b.open:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func withLookupBarrier(parties int) func(db *gorm.DB, repo invite.Repository) invite.Repository {
|
||||
barrier := newAtomicLookupBarrier(parties)
|
||||
return func(db *gorm.DB, repo invite.Repository) invite.Repository {
|
||||
return &atomicInviteRepoDecorator{Repository: repo, afterGetAll: barrier.wait}
|
||||
}
|
||||
}
|
||||
|
||||
type atomicAcceptFixture struct {
|
||||
db *gorm.DB
|
||||
router *chi.Mux
|
||||
}
|
||||
|
||||
func openRealSqlite(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
path := filepath.ToSlash(filepath.Join(t.TempDir(), "atomic.db"))
|
||||
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.User{}, &models.Invite{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Repository) invite.Repository) *atomicAcceptFixture {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
db := openRealSqlite(t)
|
||||
users := persistence.NewUserRepository(db)
|
||||
var invites invite.Repository = persistence.NewInviteRepository(db)
|
||||
if wrap != nil {
|
||||
invites = wrap(db, invites)
|
||||
}
|
||||
hasher := atomicPlainHasher{}
|
||||
handler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(invites, hasher),
|
||||
useCases.NewAcceptInviteUseCase(users, invites, hasher),
|
||||
jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
true,
|
||||
)
|
||||
router := chi.NewRouter()
|
||||
router.Post("/v1/auth/invite/accept", handler.AcceptInvite)
|
||||
|
||||
seedRealUser(t, db, models.User{Username: "marta", Password: "x", Email: "marta@renew.example", IsVerified: true})
|
||||
seedRealInvite(t, db)
|
||||
return &atomicAcceptFixture{db: db, router: router}
|
||||
}
|
||||
|
||||
func seedRealUser(t *testing.T, db *gorm.DB, u models.User) models.User {
|
||||
t.Helper()
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func seedRealInvite(t *testing.T, db *gorm.DB) models.Invite {
|
||||
t.Helper()
|
||||
pending := models.Invite{
|
||||
Email: acceptEmail,
|
||||
HashedCode: "hashed:" + acceptCode,
|
||||
ExpiresAt: time.Now().Add(48 * time.Hour),
|
||||
InvitedBy: 1,
|
||||
}
|
||||
if err := db.Create(&pending).Error; err != nil {
|
||||
t.Fatalf("seed invite: %v", err)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
func (f *atomicAcceptFixture) accept(username string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/invite/accept", bytes.NewBufferString(acceptBody(acceptCode, username, acceptPassword)))
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (f *atomicAcceptFixture) usersWithEmail(email string) int64 {
|
||||
var count int64
|
||||
f.db.Model(&models.User{}).Where("email = ?", email).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
func (f *atomicAcceptFixture) usersNamed(username string) int64 {
|
||||
var count int64
|
||||
f.db.Model(&models.User{}).Where("username = ?", username).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
func (f *atomicAcceptFixture) storedInvite() models.Invite {
|
||||
var stored models.Invite
|
||||
f.db.First(&stored)
|
||||
return stored
|
||||
}
|
||||
|
||||
func (f *atomicAcceptFixture) acceptConcurrently(usernames ...string) []*httptest.ResponseRecorder {
|
||||
recorders := make([]*httptest.ResponseRecorder, len(usernames))
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for index, username := range usernames {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
recorders[index] = f.accept(username)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
return recorders
|
||||
}
|
||||
|
||||
func assertOneWinnerOneLoser(t *testing.T, f *atomicAcceptFixture, recorders []*httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
winners, losers := 0, 0
|
||||
for _, rec := range recorders {
|
||||
switch {
|
||||
case rec.Code == http.StatusOK && jwtCookieOf(rec) != nil:
|
||||
winners++
|
||||
case rec.Code == http.StatusNotFound && rec.Body.String() == inviteInvalidBody && jwtCookieOf(rec) == nil:
|
||||
losers++
|
||||
default:
|
||||
t.Errorf("unexpected response %d %q cookie=%v", rec.Code, rec.Body.String(), jwtCookieOf(rec))
|
||||
}
|
||||
}
|
||||
if winners != 1 || losers != 1 {
|
||||
t.Errorf("expected one winner and one loser, got %d and %d", winners, losers)
|
||||
}
|
||||
if count := f.usersWithEmail(acceptEmail); count != 1 {
|
||||
t.Errorf("expected exactly one user for the invited email, got %d", count)
|
||||
}
|
||||
if f.storedInvite().AcceptedAt == nil {
|
||||
t.Errorf("invite must have accepted_at set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptInvitationAtomic(t *testing.T) {
|
||||
t.Run("Two concurrent accepts of one code create exactly one user", func(t *testing.T) {
|
||||
f := newAtomicAcceptFixture(t, withLookupBarrier(2))
|
||||
|
||||
assertOneWinnerOneLoser(t, f, f.acceptConcurrently("lea", "lea2"))
|
||||
})
|
||||
|
||||
t.Run("The concurrency test is repeatable", func(t *testing.T) {
|
||||
for run := range atomicAcceptRuns {
|
||||
t.Run(fmt.Sprintf("run %d", run), func(t *testing.T) {
|
||||
f := newAtomicAcceptFixture(t, withLookupBarrier(2))
|
||||
|
||||
assertOneWinnerOneLoser(t, f, f.acceptConcurrently("lea", "lea2"))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A used code loses at the conditional update, not at the earlier lookup", func(t *testing.T) {
|
||||
f := newAtomicAcceptFixture(t, func(db *gorm.DB, repo invite.Repository) invite.Repository {
|
||||
return &atomicInviteRepoDecorator{Repository: repo, afterGetAll: func() {
|
||||
db.Exec("UPDATE invites SET accepted_at = ?", time.Now())
|
||||
}}
|
||||
})
|
||||
|
||||
rec := f.accept("lea")
|
||||
|
||||
if rec.Code != http.StatusNotFound || rec.Body.String() != inviteInvalidBody {
|
||||
t.Errorf("expected 404 %q, got %d %q", inviteInvalidBody, rec.Code, rec.Body.String())
|
||||
}
|
||||
if jwtCookieOf(rec) != nil {
|
||||
t.Errorf("no jwt cookie may be set")
|
||||
}
|
||||
if f.usersNamed("lea") != 0 {
|
||||
t.Errorf("no user may be inserted for the losing accept")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed user insert leaves the invite pending", func(t *testing.T) {
|
||||
f := newAtomicAcceptFixture(t, nil)
|
||||
|
||||
rec := f.accept("marta")
|
||||
|
||||
if rec.Code != http.StatusConflict || rec.Body.String() != usernameTakenBody {
|
||||
t.Errorf("expected 409 %q, got %d %q", usernameTakenBody, rec.Code, rec.Body.String())
|
||||
}
|
||||
if f.storedInvite().AcceptedAt != nil {
|
||||
t.Errorf("invite must stay pending")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A user insert rejected by the database leaves the invite pending", func(t *testing.T) {
|
||||
f := newAtomicAcceptFixture(t, nil)
|
||||
seedRealUser(t, f.db, models.User{Username: "leam", Password: "x", Email: "someone@renew.example"})
|
||||
if err := f.db.Exec("CREATE TRIGGER users_reject_insert BEFORE INSERT ON users WHEN NEW.username = 'lea' BEGIN SELECT RAISE(ABORT, 'insert refused'); END").Error; err != nil {
|
||||
t.Fatalf("trigger: %v", err)
|
||||
}
|
||||
|
||||
rec := f.accept("lea")
|
||||
|
||||
if rec.Code != http.StatusInternalServerError || jwtCookieOf(rec) != nil {
|
||||
t.Errorf("expected 500 without cookie, got %d", rec.Code)
|
||||
}
|
||||
if f.storedInvite().AcceptedAt != nil {
|
||||
t.Errorf("invite must stay pending after the insert failed")
|
||||
}
|
||||
if f.usersNamed("lea") != 0 {
|
||||
t.Errorf("no user lea may exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed invite update leaves no user behind", func(t *testing.T) {
|
||||
f := newAtomicAcceptFixture(t, nil)
|
||||
if err := f.db.Exec("CREATE TRIGGER invites_frozen BEFORE UPDATE ON invites BEGIN SELECT RAISE(ABORT, 'invites are frozen'); END").Error; err != nil {
|
||||
t.Fatalf("trigger: %v", err)
|
||||
}
|
||||
|
||||
rec := f.accept("lea")
|
||||
|
||||
if rec.Code != http.StatusInternalServerError || jwtCookieOf(rec) != nil {
|
||||
t.Errorf("expected 500 without cookie, got %d", rec.Code)
|
||||
}
|
||||
if f.usersNamed("lea") != 0 {
|
||||
t.Errorf("no user lea may exist")
|
||||
}
|
||||
if f.storedInvite().AcceptedAt != nil {
|
||||
t.Errorf("invite must stay pending")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
|
||||
hasher: &stubHasher{},
|
||||
token: jwtauth.New("HS256", []byte("test-secret"), nil),
|
||||
}
|
||||
f.invites.users = f.users
|
||||
getUser := useCases.NewGetUserUseCase(f.users)
|
||||
acceptHandler := auth.NewAcceptInviteHandler(
|
||||
useCases.NewGetInviteUseCase(f.invites, f.hasher),
|
||||
@@ -420,7 +421,7 @@ func TestAcceptInvitationHttp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failure after the user is created leaves the invite pending and no user behind", func(t *testing.T) {
|
||||
t.Run("A failure while accepting leaves the invite pending and no user behind", func(t *testing.T) {
|
||||
f := newAcceptInviteFixture(t, 100)
|
||||
stored := f.seedPending()
|
||||
f.invites.fail.failNext("MarkAccepted")
|
||||
@@ -496,6 +497,7 @@ func TestAcceptInvitationHttp(t *testing.T) {
|
||||
func TestAcceptInviteUseCase(t *testing.T) {
|
||||
newUseCase := func() (*useCases.AcceptInviteUseCase, *mockUserRepo, *inviteFakeRepo, *stubHasher) {
|
||||
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
|
||||
invites.users = users
|
||||
return useCases.NewAcceptInviteUseCase(users, invites, hasher), users, invites, hasher
|
||||
}
|
||||
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
|
||||
@@ -558,6 +560,24 @@ func TestAcceptInviteUseCase(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an address that got an account because the invite was just accepted is invalid", func(t *testing.T) {
|
||||
users, invites, hasher := inviteInviterRepo(""), newInviteFakeRepo(), &stubHasher{}
|
||||
invites.users = users
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
seeded := invites.seed(stored)
|
||||
racing := &atomicInviteRepoDecorator{Repository: invites, afterGetAll: func() {
|
||||
acceptedAt := time.Now()
|
||||
seeded.AcceptedAt = &acceptedAt
|
||||
invites.invites[seeded.ID] = seeded
|
||||
users.users[8] = user.User{ID: 8, Username: "lea2", Email: acceptEmail}
|
||||
}}
|
||||
uc := useCases.NewAcceptInviteUseCase(users, racing, hasher)
|
||||
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) {
|
||||
t.Errorf("expected ErrInviteInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hash failure creates nothing", func(t *testing.T) {
|
||||
uc, users, invites, hasher := newUseCase()
|
||||
stored := pendingInvite(acceptEmail)
|
||||
@@ -580,16 +600,30 @@ func TestAcceptInviteUseCase(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rollback failure is joined with the accept failure", func(t *testing.T) {
|
||||
t.Run("accept failure leaves no user and no delete call", func(t *testing.T) {
|
||||
uc, users, invites, _ := newUseCase()
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
seeded := invites.seed(stored)
|
||||
invites.fail.failNextWith("MarkAccepted", errors.New("mark down"))
|
||||
users.fail.failNextWith("Delete", errors.New("delete down"))
|
||||
_, err := uc.AcceptInvite(command)
|
||||
if err == nil || !strings.Contains(err.Error(), "mark down") || strings.Contains(err.Error(), "delete down") {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
if len(users.users) != 1 || invites.invites[seeded.ID].AcceptedAt != nil {
|
||||
t.Errorf("expected no user and a pending invite")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an invite that was taken meanwhile is invalid", func(t *testing.T) {
|
||||
uc, users, invites, _ := newUseCase()
|
||||
stored := pendingInvite(acceptEmail)
|
||||
stored.HashedCode = "hashed:" + acceptCode
|
||||
invites.seed(stored)
|
||||
invites.fail.failNextWith("MarkAccepted", errors.New("mark down"))
|
||||
users.fail.failNextWith("Delete", errors.New("delete down"))
|
||||
_, err := uc.AcceptInvite(command)
|
||||
if err == nil || !strings.Contains(err.Error(), "mark down") || !strings.Contains(err.Error(), "delete down") {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
invites.fail.failNextWith("MarkAccepted", invite.ErrInviteNotFound)
|
||||
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) || len(users.users) != 1 {
|
||||
t.Errorf("expected ErrInviteInvalid, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ type inviteFakeRepo struct {
|
||||
invites map[uint32]invite.Invite
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
users *mockUserRepo
|
||||
}
|
||||
|
||||
func newInviteFakeRepo() *inviteFakeRepo {
|
||||
@@ -75,17 +76,21 @@ func (m *inviteFakeRepo) Delete(id uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *inviteFakeRepo) MarkAccepted(id uint32, at time.Time) error {
|
||||
func (m *inviteFakeRepo) AcceptWithUser(id uint32, at time.Time, newUser user.User) (user.User, error) {
|
||||
if err := m.fail.trigger("MarkAccepted"); err != nil {
|
||||
return err
|
||||
return user.User{}, err
|
||||
}
|
||||
stored, ok := m.invites[id]
|
||||
if !ok {
|
||||
return invite.ErrInviteNotFound
|
||||
if !ok || stored.AcceptedAt != nil {
|
||||
return user.User{}, invite.ErrInviteNotFound
|
||||
}
|
||||
created, err := m.users.Create(newUser)
|
||||
if err != nil {
|
||||
return user.User{}, err
|
||||
}
|
||||
stored.AcceptedAt = &at
|
||||
m.invites[id] = stored
|
||||
return nil
|
||||
return created, nil
|
||||
}
|
||||
|
||||
var _ invite.Repository = &inviteFakeRepo{}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type inviteEmailCase struct {
|
||||
Email string `json:"email"`
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
func loadInviteEmailCases(t *testing.T) []inviteEmailCase {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/invite_emails.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
var cases []inviteEmailCase
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatalf("decode fixture: %v", err)
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
func TestInviteEmailValidationHttp(t *testing.T) {
|
||||
t.Run("The same address gets the same verdict on both sides", func(t *testing.T) {
|
||||
for _, tc := range loadInviteEmailCases(t) {
|
||||
t.Run("["+tc.Email+"]", func(t *testing.T) {
|
||||
f := newInviteFixture(t, 100)
|
||||
body, _ := json.Marshal(map[string]string{"email": tc.Email})
|
||||
|
||||
rec := f.post(t, string(body))
|
||||
|
||||
if tc.Valid && rec.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !tc.Valid && rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A rejected address stores nothing and sends nothing", func(t *testing.T) {
|
||||
f := newInviteFixture(t, 100)
|
||||
|
||||
rec := f.post(t, `{"email": "user@localhost"}`)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
if len(f.invites.invites) != 0 || len(f.mail.SentEmails) != 0 {
|
||||
t.Errorf("nothing may be stored or sent, got %d invites and %d emails", len(f.invites.invites), len(f.mail.SentEmails))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/auth"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"bytes"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type resetMailFailureHasher struct{}
|
||||
|
||||
func (resetMailFailureHasher) Hash(value string) (string, error) {
|
||||
return "hash-of-" + value, nil
|
||||
}
|
||||
|
||||
func (resetMailFailureHasher) Compare(hashed string, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func captureStandardLog(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
previousOutput, previousFlags := log.Writer(), log.Flags()
|
||||
log.SetOutput(&buffer)
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(previousOutput)
|
||||
log.SetFlags(previousFlags)
|
||||
})
|
||||
return &buffer
|
||||
}
|
||||
|
||||
func resetRequestWith(mailRepo *mockMailRepo, email string) (*httptest.ResponseRecorder, string) {
|
||||
userRepo := newMockUserRepo()
|
||||
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
|
||||
userRepo.nextID = 1
|
||||
hasher := resetMailFailureHasher{}
|
||||
handler := auth.NewPasswordResetHandler(
|
||||
useCases.NewRequestPasswordResetUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
|
||||
useCases.NewResetPasswordUseCase(userRepo, hasher),
|
||||
)
|
||||
router := chi.NewRouter()
|
||||
router.Post("/v1/auth/password-reset", handler.RequestPasswordReset)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/auth/password-reset", bytes.NewBufferString(`{"email": "`+email+`"}`))
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder, userRepo.lastUpdatePasswordResetCodeCode
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetMailFailure(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
sendFailure := errors.New("smtp: connection refused")
|
||||
|
||||
failingMail := func() *mockMailRepo {
|
||||
mailRepo := newMockMailRepo()
|
||||
mailRepo.fail.failNextWith("Send", sendFailure)
|
||||
return mailRepo
|
||||
}
|
||||
|
||||
t.Run("A failed send is logged and the response is unchanged", func(t *testing.T) {
|
||||
logged := captureStandardLog(t)
|
||||
|
||||
recorder, _ := resetRequestWith(failingMail(), "alice@example.com")
|
||||
|
||||
if recorder.Code != http.StatusOK || strings.TrimSpace(recorder.Body.String()) != requestPasswordResetGenericBody {
|
||||
t.Errorf("expected the generic 200, got %d %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if jwtCookieOf(recorder) != nil {
|
||||
t.Errorf("no jwt cookie may be set")
|
||||
}
|
||||
line := logged.String()
|
||||
if !strings.Contains(line, "Send") || !strings.Contains(line, "smtp: connection refused") {
|
||||
t.Errorf("log must name the failed send and its error, got %q", line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The response is identical for a failed send, a successful send and an unknown email", func(t *testing.T) {
|
||||
captureStandardLog(t)
|
||||
|
||||
failed, _ := resetRequestWith(failingMail(), "alice@example.com")
|
||||
sent, _ := resetRequestWith(newMockMailRepo(), "alice@example.com")
|
||||
unknown, _ := resetRequestWith(newMockMailRepo(), "nobody@example.com")
|
||||
|
||||
for name, other := range map[string]*httptest.ResponseRecorder{"successful send": sent, "unknown email": unknown} {
|
||||
if failed.Code != other.Code || failed.Body.String() != other.Body.String() {
|
||||
t.Errorf("%s differs: %d %q vs %d %q", name, failed.Code, failed.Body.String(), other.Code, other.Body.String())
|
||||
}
|
||||
if !reflect.DeepEqual(failed.Header(), other.Header()) {
|
||||
t.Errorf("%s headers differ: %v vs %v", name, failed.Header(), other.Header())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The log does not contain the raw reset code", func(t *testing.T) {
|
||||
logged := captureStandardLog(t)
|
||||
|
||||
_, storedHash := resetRequestWith(failingMail(), "alice@example.com")
|
||||
|
||||
rawCode := strings.TrimPrefix(storedHash, "hash-of-")
|
||||
if rawCode == "" || strings.Contains(logged.String(), rawCode) || strings.Contains(logged.String(), storedHash) {
|
||||
t.Errorf("log must contain neither the raw code nor its hash, got %q", logged.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestProductionSQLiteDSNWaitsForTheWriteLock(t *testing.T) {
|
||||
t.Run("A second writer waits for the first instead of failing with a locked database", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "busy.db")
|
||||
open := func() *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
first, second := open(), open()
|
||||
if err := first.AutoMigrate(&models.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
tx := first.Begin()
|
||||
if err := tx.Create(&models.User{Username: "a", Password: "x", Email: "a@x.example"}).Error; err != nil {
|
||||
t.Fatalf("first write: %v", err)
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
tx.Commit()
|
||||
}()
|
||||
|
||||
err := second.Create(&models.User{Username: "b", Password: "x", Email: "b@x.example"}).Error
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("second writer must wait for the lock, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{ "email": "sam.oliveira@renew.example", "valid": true },
|
||||
{ "email": "Sam.Oliveira@Renew.example", "valid": true },
|
||||
{ "email": "sam+news@renew.example", "valid": true },
|
||||
{ "email": "sam@mail.renew.example", "valid": true },
|
||||
{ "email": "sam@-renew.example", "valid": true },
|
||||
{ "email": "user@localhost", "valid": false },
|
||||
{ "email": "\"sam smith\"@renew.example", "valid": false },
|
||||
{ "email": "sam@renew", "valid": false },
|
||||
{ "email": "sam@.renew.example", "valid": false },
|
||||
{ "email": "sam@renew..example", "valid": false },
|
||||
{ "email": "sam@renew.example.", "valid": false },
|
||||
{ "email": "sam @renew.example", "valid": false },
|
||||
{ "email": "@renew.example", "valid": false },
|
||||
{ "email": "not-an-email", "valid": false },
|
||||
{ "email": "", "valid": false }
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserRepositoryDelete(t *testing.T) {
|
||||
t.Run("Deleting a user removes only that row", func(t *testing.T) {
|
||||
db := openRealSqlite(t)
|
||||
for _, name := range []string{"one", "two", "three"} {
|
||||
seedRealUser(t, db, models.User{Username: name, Password: "x", Email: name + "@renew.example"})
|
||||
}
|
||||
repo := persistence.NewUserRepository(db)
|
||||
|
||||
if err := repo.Delete(2); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.Get(2); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("user 2 must be gone, got %v", err)
|
||||
}
|
||||
for _, id := range []uint32{1, 3} {
|
||||
if _, err := repo.Get(id); err != nil {
|
||||
t.Errorf("user %d must remain, got %v", id, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deleting a missing user reports not found", func(t *testing.T) {
|
||||
repo := persistence.NewUserRepository(openRealSqlite(t))
|
||||
|
||||
if err := repo.Delete(99); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user