feat: activity log producers

This commit is contained in:
fsylvain committed 2026-10-06 22:25:23 +02:00
1 parent 50b1649965
commit e37a46a027
93 files changed
+3396 -440

No files matched your search

+1 -1
View File
@@ -116,7 +116,7 @@ func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Rep
hasher := atomicPlainHasher{}
handler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(invites, hasher),
useCases.NewAcceptInviteUseCase(users, invites, hasher),
useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder),
jwtauth.New("HS256", []byte("test-secret"), nil),
true,
)
+16 -16
View File
@@ -56,20 +56,20 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
getUser := useCases.NewGetUserUseCase(f.users)
acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder),
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser)
listHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
nil,
nil,
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
)
registerHandler := auth.NewLoginHandler(
f.token,
useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL),
useCases.NewCreateUserUseCase(f.users, f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(f.users, f.hasher, discardRecorder),
true,
)
@@ -500,13 +500,13 @@ 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
return useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder), users, invites, hasher
}
command := useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword}
t.Run("unknown code is invalid", func(t *testing.T) {
uc, _, _, _ := newUseCase()
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) {
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
@@ -514,7 +514,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
t.Run("empty code is invalid without hashing", func(t *testing.T) {
uc, _, invites, hasher := newUseCase()
invites.seed(pendingInvite(acceptEmail))
if _, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Username: "lea", Password: acceptPassword}); !errors.Is(err, invite.ErrInviteInvalid) || hasher.compareCalls != 0 {
if _, err := uc.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Username: "lea", Password: acceptPassword}); !errors.Is(err, invite.ErrInviteInvalid) || hasher.compareCalls != 0 {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
@@ -524,7 +524,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
stored := expiredInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteExpired) {
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteExpired) {
t.Errorf("expected ErrInviteExpired, got %v", err)
}
})
@@ -534,7 +534,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
stored := pendingInvite(acceptEmail)
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
_, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Code: acceptCode, Username: "marta", Password: acceptPassword})
_, err := uc.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: acceptCode, Username: "marta", Password: acceptPassword})
if !errors.Is(err, user.ErrUsernameTaken) || len(users.users) != 1 {
t.Errorf("expected ErrUsernameTaken, got %v", err)
}
@@ -546,7 +546,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
users.fail.failNext("GetByUsername")
if _, err := uc.AcceptInvite(command); !errors.Is(err, errRepositoryFailure) {
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
})
@@ -557,7 +557,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
users.users[8] = user.User{ID: 8, Username: "leam", Email: "LEA.martin@renew.example"}
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrEmailHasAccount) {
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrEmailHasAccount) {
t.Errorf("expected ErrEmailHasAccount, got %v", err)
}
})
@@ -574,8 +574,8 @@ func TestAcceptInviteUseCase(t *testing.T) {
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) {
uc := useCases.NewAcceptInviteUseCase(users, racing, hasher, discardRecorder)
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
@@ -586,7 +586,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
hasher.hashErr = errors.New("hash boom")
if _, err := uc.AcceptInvite(command); err == nil || len(users.users) != 1 {
if _, err := uc.AcceptInvite(testCtx, command); err == nil || len(users.users) != 1 {
t.Errorf("expected failure without a user, got %v", err)
}
})
@@ -597,7 +597,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
stored.HashedCode = "hashed:" + acceptCode
seeded := invites.seed(stored)
users.fail.failNext("Create")
if _, err := uc.AcceptInvite(command); !errors.Is(err, errRepositoryFailure) || invites.invites[seeded.ID].AcceptedAt != nil {
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, errRepositoryFailure) || invites.invites[seeded.ID].AcceptedAt != nil {
t.Errorf("expected repository failure and a pending invite, got %v", err)
}
})
@@ -609,7 +609,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
seeded := invites.seed(stored)
invites.fail.failNextWith("MarkAccepted", errors.New("mark down"))
users.fail.failNextWith("Delete", errors.New("delete down"))
_, err := uc.AcceptInvite(command)
_, err := uc.AcceptInvite(testCtx, command)
if err == nil || !strings.Contains(err.Error(), "mark down") || strings.Contains(err.Error(), "delete down") {
t.Errorf("unexpected error %v", err)
}
@@ -624,7 +624,7 @@ func TestAcceptInviteUseCase(t *testing.T) {
stored.HashedCode = "hashed:" + acceptCode
invites.seed(stored)
invites.fail.failNextWith("MarkAccepted", invite.ErrInviteNotFound)
if _, err := uc.AcceptInvite(command); !errors.Is(err, invite.ErrInviteInvalid) || len(users.users) != 1 {
if _, err := uc.AcceptInvite(testCtx, command); !errors.Is(err, invite.ErrInviteInvalid) || len(users.users) != 1 {
t.Errorf("expected ErrInviteInvalid, got %v", err)
}
})
+1 -1
View File
@@ -20,7 +20,7 @@ import (
func newAccountVerificationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
hasher := &stubHasher{}
verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
useCases.NewCancelRegistrationUseCase(userRepo),
true,
)
+6 -6
View File
@@ -16,9 +16,9 @@ func TestAccountVerificationUseCase(t *testing.T) {
VerificationCode: "hashed:right-code",
VerificationExpiration: time.Now().Add(time.Hour),
}
uc := useCases.NewVerifyUserUseCase(repo, &stubHasher{})
uc := useCases.NewVerifyUserUseCase(repo, &stubHasher{}, discardRecorder)
_, err := uc.Verify(3, "wrong-code")
_, err := uc.Verify(testCtx, 3, "wrong-code")
if !errors.Is(err, user.ErrInvalidVerificationCode) {
t.Fatalf("expected ErrInvalidVerificationCode, got %v", err)
@@ -35,9 +35,9 @@ func TestAccountVerificationUseCase(t *testing.T) {
VerificationCode: "hashed:right-code",
VerificationExpiration: time.Now().Add(-time.Hour),
}
uc := useCases.NewVerifyUserUseCase(repo, &stubHasher{})
uc := useCases.NewVerifyUserUseCase(repo, &stubHasher{}, discardRecorder)
_, err := uc.Verify(3, "right-code")
_, err := uc.Verify(testCtx, 3, "right-code")
if !errors.Is(err, user.ErrVerificationCodeExpired) {
t.Fatalf("expected ErrVerificationCodeExpired, got %v", err)
@@ -54,9 +54,9 @@ func TestAccountVerificationUseCase(t *testing.T) {
VerificationCode: "hashed:right-code",
VerificationExpiration: time.Now().Add(time.Hour),
}
uc := useCases.NewVerifyUserUseCase(repo, &stubHasher{})
uc := useCases.NewVerifyUserUseCase(repo, &stubHasher{}, discardRecorder)
result, err := uc.Verify(3, "right-code")
result, err := uc.Verify(testCtx, 3, "right-code")
if err != nil {
t.Fatalf("expected no error, got %v", err)
+943
View File
@@ -0,0 +1,943 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
)
var producersTimestamps = regexp.MustCompile(`"(created_at|expires_at)":"[^"]*",?`)
const (
producersUserAgent = "producers-test/1.0"
producersClientIP = "192.0.2.1"
producersOwnerName = "Marta Alves"
producersSpringName = "Spring release"
producersBannerPath = "/static/uploadedImages/"
)
type producersEnv struct {
*apiKeyEnv
audit *activityHarness
}
func newProducersEnv(t *testing.T) *producersEnv {
t.Helper()
h := newActivityHarness(t)
env := newAPIKeyEnvRecording(t, h.recorder)
owner := env.users.users[apiKeyOwnerId]
owner.DisplayName = producersOwnerName
env.users.users[apiKeyOwnerId] = owner
env.container.ListActivityUseCase = useCases.NewListActivityUseCase(h.store, env.users, env.articles, env.images, env.keys, h.clock)
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings))
env.router = router
return &producersEnv{apiKeyEnv: env, audit: h}
}
func (p *producersEnv) request(t *testing.T, req *http.Request, userId uint32) *httptest.ResponseRecorder {
t.Helper()
req.Header.Set("User-Agent", producersUserAgent)
if userId != 0 {
req.AddCookie(p.cookie(t, userId, 0))
}
rec := httptest.NewRecorder()
p.router.ServeHTTP(rec, req)
return rec
}
func (p *producersEnv) as(t *testing.T, userId uint32, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
return p.request(t, req, userId)
}
func (p *producersEnv) asKey(t *testing.T, raw string, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+raw)
return p.request(t, req, 0)
}
func (p *producersEnv) upload(t *testing.T, userId uint32, filename string, contentType string, content []byte) *httptest.ResponseRecorder {
t.Helper()
return p.request(t, newMultipartFileRequest(t, http.MethodPost, "/v1/media", filename, contentType, content), userId)
}
func (p *producersEnv) events(t *testing.T) []audit.Event {
t.Helper()
return p.audit.all(t)
}
func (p *producersEnv) single(t *testing.T) audit.Event {
t.Helper()
events := p.events(t)
if len(events) != 1 {
t.Fatalf("expected exactly one event, got %d: %+v", len(events), events)
}
return events[0]
}
func (p *producersEnv) noEvent(t *testing.T) {
t.Helper()
if events := p.events(t); len(events) != 0 {
t.Fatalf("expected no event, got %d: %+v", len(events), events)
}
}
func (p *producersEnv) seedSpring(id uint32, body string, online bool) {
p.articles.articles[id] = article.Article{ID: id, Title: producersSpringName, Body: body, IsOnline: online}
}
func (p *producersEnv) seedBanner(id uint32, filename string, hash string) {
p.images.images[id] = image.Image{ID: id, Filename: filename, ContentType: "image/png", Path: producersBannerPath + strconv.Itoa(int(id)) + ".png", SHA256: hash, UploadedByID: apiKeyOwnerId}
}
func (p *producersEnv) seedPendingInvite(email string) invite.Invite {
return p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, CreatedAt: time.Now()})
}
func paramsJSON(t *testing.T, event audit.Event) string {
t.Helper()
encoded, err := json.Marshal(event.Params)
if err != nil {
t.Fatal(err)
}
return string(encoded)
}
func changesJSON(t *testing.T, event audit.Event) string {
t.Helper()
encoded, err := json.Marshal(event.Changes)
if err != nil {
t.Fatal(err)
}
return string(encoded)
}
func assertProducerEvent(t *testing.T, event audit.Event, action string, category audit.Category, outcome audit.Outcome) {
t.Helper()
if event.Action != action || event.Category != category || event.Outcome != outcome {
t.Fatalf("event = %s/%s/%s, expected %s/%s/%s", event.Action, event.Category, event.Outcome, action, category, outcome)
}
}
func assertProducerTarget(t *testing.T, event audit.Event, targetType string, id string, name string) {
t.Helper()
if event.Target != (audit.Target{Type: targetType, ID: id, Name: name}) {
t.Errorf("target = %+v, expected {%s %s %s}", event.Target, targetType, id, name)
}
}
func assertProducerFailure(t *testing.T, event audit.Event, reason string) {
t.Helper()
if event.Outcome != audit.OutcomeFailed || event.FailureReason == nil || *event.FailureReason != reason {
t.Errorf("outcome = %s, failure_reason = %v, expected failed with %q", event.Outcome, event.FailureReason, reason)
}
}
func assertProducerOwnerOrigin(t *testing.T, event audit.Event) {
t.Helper()
if event.Actor.Type != audit.ActorUser || event.Actor.ID == nil || *event.Actor.ID != apiKeyOwnerId || event.Actor.Name != producersOwnerName {
t.Errorf("actor = %+v", event.Actor)
}
if event.Source != audit.SourceWeb {
t.Errorf("source = %s", event.Source)
}
if event.IP == nil || *event.IP != producersClientIP || event.UserAgent == nil || *event.UserAgent != producersUserAgent {
t.Errorf("ip = %v, user agent = %v", event.IP, event.UserAgent)
}
}
func TestActivityLogProducersRouteContext(t *testing.T) {
t.Run("A producing route carries the origin of the request", func(t *testing.T) {
t.Run("POST /v1/article with the owner's jwt cookie", func(t *testing.T) {
p := newProducersEnv(t)
rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`)
assertAPIKeyStatus(t, rec, http.StatusCreated)
assertProducerOwnerOrigin(t, p.single(t))
})
t.Run("POST /v1/article with an API key Deploy bot", func(t *testing.T) {
p := newProducersEnv(t)
raw, keyId := p.issueKey(apiKeyDevId, "Deploy bot")
rec := p.asKey(t, raw, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`)
assertAPIKeyStatus(t, rec, http.StatusCreated)
event := p.single(t)
if event.Actor.Type != audit.ActorAPIKey || event.Actor.ID == nil || *event.Actor.ID != keyId || event.Actor.Name != "Deploy bot" || event.Source != audit.SourceAPIKey {
t.Errorf("actor = %+v, source = %s", event.Actor, event.Source)
}
if event.IP == nil || *event.IP != producersClientIP || event.UserAgent == nil || *event.UserAgent != producersUserAgent {
t.Errorf("ip = %v, user agent = %v", event.IP, event.UserAgent)
}
})
t.Run("POST /v1/media with an API key Deploy bot", func(t *testing.T) {
p := newProducersEnv(t)
raw, keyId := p.issueKey(apiKeyDevId, "Deploy bot")
req := newMultipartFileRequest(t, http.MethodPost, "/v1/media", "spring-banner.png", "image/png", pngBytes(t, 3, 2))
req.Header.Set("Authorization", "Bearer "+raw)
rec := p.request(t, req, 0)
assertAPIKeyStatus(t, rec, http.StatusCreated)
event := p.single(t)
if event.Actor.Type != audit.ActorAPIKey || event.Actor.ID == nil || *event.Actor.ID != keyId || event.Actor.Name != "Deploy bot" || event.Source != audit.SourceAPIKey {
t.Errorf("actor = %+v, source = %s", event.Actor, event.Source)
}
})
t.Run("POST /v1/system/update/check with the owner's jwt cookie", func(t *testing.T) {
p := newProducersEnv(t)
rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/check", "")
assertAPIKeyStatus(t, rec, http.StatusOK)
assertProducerOwnerOrigin(t, p.single(t))
})
})
}
func TestActivityLogProducersFailedAuditWrite(t *testing.T) {
type row struct {
name string
panics bool
setup func(p *producersEnv)
send func(t *testing.T, p *producersEnv) *httptest.ResponseRecorder
applied func(t *testing.T, p *producersEnv)
}
owner := func(method string, path string, body string) func(t *testing.T, p *producersEnv) *httptest.ResponseRecorder {
return func(t *testing.T, p *producersEnv) *httptest.ResponseRecorder {
return p.as(t, apiKeyOwnerId, method, path, body)
}
}
seedArticle := func(p *producersEnv) { p.seedSpring(8, "Hello", false) }
seedCopies := func(p *producersEnv) {
p.seedBanner(5, "banner.png", "same")
p.seedBanner(6, "banner-copy.png", "same")
p.articles.articles[1] = article.Article{ID: 1, Title: "Uses copy", Body: "see 6.png"}
}
rows := []row{
{name: "fails POST /v1/article", setup: func(*producersEnv) {}, send: owner(http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`), applied: func(t *testing.T, p *producersEnv) {
if len(p.articles.articles) != 1 {
t.Errorf("the article was not created")
}
}},
{name: "fails DELETE /v1/article/8", setup: seedArticle, send: owner(http.MethodDelete, "/v1/article/8", ""), applied: func(t *testing.T, p *producersEnv) {
if _, found := p.articles.articles[8]; found {
t.Errorf("the article was not deleted")
}
}},
{name: "panics PATCH /v1/article/8/publish", panics: true, setup: seedArticle, send: owner(http.MethodPatch, "/v1/article/8/publish", ""), applied: func(t *testing.T, p *producersEnv) {
if !p.articles.articles[8].IsOnline {
t.Errorf("the article was not published")
}
}},
{name: "fails POST /v1/media", setup: func(*producersEnv) {}, send: func(t *testing.T, p *producersEnv) *httptest.ResponseRecorder {
return p.upload(t, apiKeyOwnerId, "spring-banner.png", "image/png", pngBytes(t, 3, 2))
}, applied: func(t *testing.T, p *producersEnv) {
if len(p.images.images) != 1 {
t.Errorf("the image was not stored")
}
}},
{name: "fails POST /v1/media/merge", setup: seedCopies, send: owner(http.MethodPost, "/v1/media/merge", `{"keep_id":5,"merge_ids":[6]}`), applied: func(t *testing.T, p *producersEnv) {
if _, found := p.images.images[6]; found {
t.Errorf("the copy was not merged")
}
}},
{name: "fails POST /v1/user/invite", setup: func(*producersEnv) {}, send: owner(http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`), applied: func(t *testing.T, p *producersEnv) {
if len(p.invites.invites) != 1 {
t.Errorf("the invite was not created")
}
}},
{name: "fails POST /v1/system/update/restart", setup: func(*producersEnv) {}, send: owner(http.MethodPost, "/v1/system/update/restart", ""), applied: func(*testing.T, *producersEnv) {}},
}
t.Run("A failing or panicking repository leaves the response untouched", func(t *testing.T) {
for _, example := range rows {
t.Run(example.name, func(t *testing.T) {
healthy := newProducersEnv(t)
example.setup(healthy)
expected := example.send(t, healthy)
p := newProducersEnv(t)
example.setup(p)
if example.panics {
p.audit.probe.appendPanic = true
} else {
p.audit.probe.appendErr = errDiskFull
}
rec := example.send(t, p)
if rec.Code != expected.Code || producersTimestamps.ReplaceAllString(rec.Body.String(), "") != producersTimestamps.ReplaceAllString(expected.Body.String(), "") {
t.Errorf("response = %d %q, healthy response = %d %q", rec.Code, rec.Body.String(), expected.Code, expected.Body.String())
}
if rec.Code >= http.StatusBadRequest {
t.Fatalf("the request was expected to succeed, got %d: %s", rec.Code, rec.Body.String())
}
example.applied(t, p)
if !strings.Contains(p.audit.logs.String(), "audit:") {
t.Errorf("the process log does not name the failed audit write: %q", p.audit.logs.String())
}
})
}
})
}
func TestActivityLogProducersArticles(t *testing.T) {
t.Run("Creating an article is recorded", func(t *testing.T) {
p := newProducersEnv(t)
rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`)
assertAPIKeyStatus(t, rec, http.StatusCreated)
event := p.single(t)
assertProducerEvent(t, event, "article.create", audit.CategoryArticles, audit.OutcomeSuccess)
assertProducerOwnerOrigin(t, event)
assertProducerTarget(t, event, "article", "1", producersSpringName)
if event.FailureReason != nil || len(event.Changes) != 0 {
t.Errorf("failure reason = %v, changes = %+v", event.FailureReason, event.Changes)
}
if encoded, _ := json.Marshal(event); strings.Contains(string(encoded), "Hello") {
t.Errorf("the stored event holds the article body: %s", encoded)
}
})
t.Run("Editing the body records a size delta and never the text", func(t *testing.T) {
p := newProducersEnv(t)
p.seedSpring(8, strings.Repeat("a", 100), false)
newBody := strings.Repeat("secretbody", 41) + "xx"
rec := p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"body":"`+newBody+`"}`)
assertAPIKeyStatus(t, rec, http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "article.edit", audit.CategoryArticles, audit.OutcomeSuccess)
assertProducerTarget(t, event, "article", "8", producersSpringName)
if got := changesJSON(t, event); got != `[{"field":"body","kind":"body","delta":312}]` {
t.Errorf("changes = %s", got)
}
listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity", "")
assertAPIKeyStatus(t, listing, http.StatusOK)
if strings.Contains(listing.Body.String(), "secretbody") || strings.Contains(listing.Body.String(), "aaaa") {
t.Errorf("the activity response holds article text: %s", listing.Body.String())
}
})
t.Run("Saving an identical body records nothing", func(t *testing.T) {
p := newProducersEnv(t)
p.seedSpring(8, "Same text", false)
rec := p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8", `{"body":"Same text"}`)
assertAPIKeyStatus(t, rec, http.StatusOK)
p.noEvent(t)
})
t.Run("Publishing is recorded with the publish state", func(t *testing.T) {
p := newProducersEnv(t)
p.seedSpring(8, "Hello", false)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8/publish", ""), http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "article.publish", audit.CategoryArticles, audit.OutcomeSuccess)
if got := changesJSON(t, event); got != `[{"field":"online","kind":"text","before":"false","after":"true"}]` {
t.Errorf("changes = %s", got)
}
})
t.Run("Unpublishing is recorded with the publish state", func(t *testing.T) {
p := newProducersEnv(t)
p.seedSpring(8, "Hello", true)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8/unpublish", ""), http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "article.unpublish", audit.CategoryArticles, audit.OutcomeSuccess)
if got := changesJSON(t, event); got != `[{"field":"online","kind":"text","before":"true","after":"false"}]` {
t.Errorf("changes = %s", got)
}
})
t.Run("A publish state that does not change records nothing", func(t *testing.T) {
for _, example := range []struct {
state bool
path string
status int
}{{true, "publish", http.StatusOK}, {false, "unpublish", http.StatusOK}} {
t.Run(strconv.FormatBool(example.state)+" "+example.path, func(t *testing.T) {
p := newProducersEnv(t)
p.seedSpring(8, "Hello", example.state)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8/"+example.path, ""), example.status)
p.noEvent(t)
})
}
})
t.Run("Deleting an article is recorded with its name snapshot", func(t *testing.T) {
p := newProducersEnv(t)
p.seedSpring(8, "Hello", false)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/article/8", ""), http.StatusNoContent)
event := p.single(t)
assertProducerEvent(t, event, "article.delete", audit.CategoryArticles, audit.OutcomeSuccess)
assertProducerTarget(t, event, "article", "8", producersSpringName)
listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity", "")
var page struct {
Items []struct {
Target struct {
Exists bool `json:"exists"`
} `json:"target"`
} `json:"items"`
}
if err := json.Unmarshal(listing.Body.Bytes(), &page); err != nil || len(page.Items) != 1 || page.Items[0].Target.Exists {
t.Errorf("listing = %s, err = %v", listing.Body.String(), err)
}
})
t.Run("A refused article action is recorded as failed with a plain reason", func(t *testing.T) {
t.Run("the repository fails with db is locked on create", func(t *testing.T) {
p := newProducersEnv(t)
p.articles.fail.failNextWith("Create", errors.New("db is locked"))
rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`)
assertAPIKeyStatus(t, rec, http.StatusInternalServerError)
event := p.single(t)
assertProducerEvent(t, event, "article.create", audit.CategoryArticles, audit.OutcomeFailed)
assertProducerFailure(t, event, useCases.GenericFailureReason)
if strings.Contains(paramsJSON(t, event), "locked") || strings.Contains(*event.FailureReason, "locked") {
t.Errorf("the raw error leaked: %+v", event)
}
})
t.Run("the repository fails with db is locked on publish", func(t *testing.T) {
p := newProducersEnv(t)
p.seedSpring(8, "Hello", false)
p.articles.fail.failNextWith("UpdateIsOnline", errors.New("db is locked"))
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/8/publish", ""), http.StatusInternalServerError)
event := p.single(t)
assertProducerEvent(t, event, "article.publish", audit.CategoryArticles, audit.OutcomeFailed)
assertProducerFailure(t, event, useCases.GenericFailureReason)
assertProducerTarget(t, event, "article", "8", producersSpringName)
})
})
t.Run("An article that does not exist records nothing", func(t *testing.T) {
p := newProducersEnv(t)
for _, call := range []struct{ method, path, body string }{
{http.MethodDelete, "/v1/article/99", ""},
{http.MethodPatch, "/v1/article/99", `{"body":"x"}`},
{http.MethodPatch, "/v1/article/99/publish", ""},
{http.MethodPatch, "/v1/article/99/unpublish", ""},
} {
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, call.method, call.path, call.body), http.StatusNotFound)
}
p.noEvent(t)
})
t.Run("A request that fails validation records nothing", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{}`), http.StatusBadRequest)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/abc", `{"body":"x"}`), http.StatusBadRequest)
p.noEvent(t)
})
t.Run("Reads are never recorded", func(t *testing.T) {
p := newProducersEnv(t)
p.seedSpring(8, "Hello", true)
for _, path := range []string{"/v1/article", "/v1/article/8"} {
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodGet, path, ""), http.StatusOK)
}
assertAPIKeyStatus(t, p.as(t, 0, http.MethodGet, "/v1/article/8", ""), http.StatusOK)
p.noEvent(t)
})
}
func TestActivityLogProducersMedia(t *testing.T) {
t.Run("Uploading an image is recorded", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.upload(t, apiKeyOwnerId, "spring-banner.png", "image/png", pngBytes(t, 3, 2)), http.StatusCreated)
event := p.single(t)
assertProducerEvent(t, event, "media.upload", audit.CategoryMedia, audit.OutcomeSuccess)
assertProducerOwnerOrigin(t, event)
assertProducerTarget(t, event, "image", "1", "spring-banner.png")
if encoded, _ := json.Marshal(event); strings.Contains(string(encoded), "/static/") {
t.Errorf("the stored event holds a storage path: %s", encoded)
}
})
t.Run("A refused upload is recorded as failed", func(t *testing.T) {
for _, example := range []struct {
name string
contentType string
content func(t *testing.T) []byte
reason string
}{
{"a GIF", "image/gif", func(t *testing.T) []byte { return []byte("GIF89a") }, "The file must be a PNG, JPEG, WEBP or SVG image."},
{"a PNG larger than the limit", "image/png", func(t *testing.T) []byte { return paddedPngBytes(t, int(image.MaxImageSize)+10) }, "The file is too large to upload."},
{"a corrupt PNG", "image/png", func(t *testing.T) []byte { return []byte("not a png") }, "The image could not be read."},
} {
t.Run(example.name, func(t *testing.T) {
p := newProducersEnv(t)
rec := p.upload(t, apiKeyOwnerId, "spring-banner.png", example.contentType, example.content(t))
if rec.Code < http.StatusBadRequest {
t.Fatalf("expected a refusal, got %d", rec.Code)
}
event := p.single(t)
assertProducerEvent(t, event, "media.upload", audit.CategoryMedia, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
if event.Target.Name != "spring-banner.png" {
t.Errorf("target = %+v", event.Target)
}
})
}
})
t.Run("Renaming an image records the filename diff", func(t *testing.T) {
p := newProducersEnv(t)
p.seedBanner(5, "banner.png", "h")
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"spring-banner"}`), http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "media.rename", audit.CategoryMedia, audit.OutcomeSuccess)
assertProducerTarget(t, event, "image", "5", "spring-banner.png")
if got := changesJSON(t, event); got != `[{"field":"filename","kind":"text","before":"banner.png","after":"spring-banner.png"}]` {
t.Errorf("changes = %s", got)
}
})
t.Run("Renaming to the same name records nothing", func(t *testing.T) {
p := newProducersEnv(t)
p.seedBanner(5, "banner.png", "h")
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"banner"}`), http.StatusOK)
p.noEvent(t)
})
t.Run("A refused rename is recorded as failed", func(t *testing.T) {
p := newProducersEnv(t)
p.seedBanner(5, "banner.png", "h")
rec := p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"not/valid"}`)
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
event := p.single(t)
assertProducerEvent(t, event, "media.rename", audit.CategoryMedia, audit.OutcomeFailed)
assertProducerFailure(t, event, "That file name is not valid.")
assertProducerTarget(t, event, "image", "5", "banner.png")
})
t.Run("Deleting an image is recorded with its name snapshot", func(t *testing.T) {
p := newProducersEnv(t)
p.seedBanner(5, "banner.png", "h")
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/media/5", ""), http.StatusNoContent)
event := p.single(t)
assertProducerEvent(t, event, "media.delete", audit.CategoryMedia, audit.OutcomeSuccess)
assertProducerTarget(t, event, "image", "5", "banner.png")
})
t.Run("Merging images is recorded once with its counts", func(t *testing.T) {
p := newProducersEnv(t)
p.seedBanner(5, "banner.png", "same")
p.seedBanner(6, "banner-copy.png", "same")
p.seedBanner(7, "banner-copy-2.png", "same")
p.articles.articles[1] = article.Article{ID: 1, Title: "One", Body: "see 6.png"}
p.articles.articles[2] = article.Article{ID: 2, Title: "Two", Body: "see 7.png"}
rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", `{"keep_id":5,"merge_ids":[6,7]}`)
assertAPIKeyStatus(t, rec, http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "media.merge", audit.CategoryMedia, audit.OutcomeSuccess)
assertProducerTarget(t, event, "image", "5", "banner.png")
if got := paramsJSON(t, event); got != `{"articles_updated":2,"merged":2}` {
t.Errorf("params = %s", got)
}
})
t.Run("A refused merge is recorded as failed", func(t *testing.T) {
for _, example := range []struct {
name string
setup func(p *producersEnv)
body string
reason string
}{
{"ErrInvalidMergeRequest", func(p *producersEnv) { p.seedBanner(5, "banner.png", "same") }, `{"keep_id":5,"merge_ids":[5]}`, "The merge request is not valid."},
{"ErrMergeCopyMissing", func(p *producersEnv) { p.seedBanner(5, "banner.png", "same") }, `{"keep_id":5,"merge_ids":[6]}`, "One of the copies no longer exists."},
{"ErrMergeHashMismatch", func(p *producersEnv) {
p.seedBanner(5, "banner.png", "same")
p.seedBanner(6, "other.png", "different")
}, `{"keep_id":5,"merge_ids":[6]}`, "The images are not identical copies."},
} {
t.Run(example.name, func(t *testing.T) {
p := newProducersEnv(t)
example.setup(p)
rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", example.body)
if rec.Code < http.StatusBadRequest {
t.Fatalf("expected a refusal, got %d", rec.Code)
}
event := p.single(t)
assertProducerEvent(t, event, "media.merge", audit.CategoryMedia, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
})
}
})
t.Run("A manual purge is recorded once and not as separate merges", func(t *testing.T) {
p := newProducersEnv(t)
seedTwoGroupPurgePlan(p)
rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", "")
assertAPIKeyStatus(t, rec, http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "media.purge", audit.CategoryMedia, audit.OutcomeSuccess)
assertProducerOwnerOrigin(t, event)
if got := paramsJSON(t, event); got != `{"articles_updated":5,"merged":3}` {
t.Errorf("params = %s", got)
}
})
t.Run("A partly failed manual purge is a success with the failed group count", func(t *testing.T) {
p := newProducersEnv(t)
seedThreeGroupPurgePlan(p, 5)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", ""), http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "media.purge", audit.CategoryMedia, audit.OutcomeSuccess)
if !strings.Contains(paramsJSON(t, event), `"failed_groups":1`) {
t.Errorf("params = %s", paramsJSON(t, event))
}
})
t.Run("A refused manual purge is recorded as failed", func(t *testing.T) {
p := newProducersEnv(t)
seedTwoGroupPurgePlan(p)
rec := p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/purge", `{"fingerprint":"stale"}`)
assertAPIKeyStatus(t, rec, http.StatusConflict)
event := p.single(t)
assertProducerEvent(t, event, "media.purge", audit.CategoryMedia, audit.OutcomeFailed)
assertProducerFailure(t, event, "The library changed since the preview. Review it again.")
})
t.Run("Browsing and the media usage lookup are never recorded", func(t *testing.T) {
p := newProducersEnv(t)
p.seedBanner(5, "banner.png", "h")
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/media", ""), http.StatusOK)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/media/5/usage", ""), http.StatusOK)
p.noEvent(t)
})
}
func (p *producersEnv) seedArticleUsing(id uint32, body string) {
p.articles.articles[id] = article.Article{ID: id, Title: "Article " + strconv.Itoa(int(id)), Body: body}
}
func seedTwoGroupPurgePlan(p *producersEnv) {
p.seedBanner(1, "kept-a.png", "hash-a")
p.seedBanner(2, "copy-a1.png", "hash-a")
p.seedBanner(3, "copy-a2.png", "hash-a")
p.seedBanner(4, "kept-b.png", "hash-b")
p.seedBanner(5, "copy-b1.png", "hash-b")
for id, body := range map[uint32]string{1: "uses 2.png", 2: "uses 3.png", 3: "uses 5.png", 4: "uses 2.png", 5: "uses 3.png", 6: "1.png 4.png", 7: "1.png 4.png", 8: "1.png 4.png"} {
p.seedArticleUsing(id, body)
}
}
func seedThreeGroupPurgePlan(p *producersEnv, failingCopy uint32) {
p.seedBanner(1, "kept-a.png", "hash-a")
p.seedBanner(2, "copy-a.png", "hash-a")
p.seedBanner(4, "kept-b.png", "hash-b")
p.seedBanner(5, "copy-b.png", "hash-b")
p.seedBanner(7, "kept-c.png", "hash-c")
p.seedBanner(8, "copy-c.png", "hash-c")
for id, body := range map[uint32]string{1: "uses 2.png", 2: "uses 5.png", 3: "uses 8.png", 4: "1.png 4.png 7.png", 5: "1.png 4.png 7.png"} {
p.seedArticleUsing(id, body)
}
p.images.failDeleteFor = map[uint32]error{failingCopy: errors.New("storage offline")}
}
func TestActivityLogProducersUsers(t *testing.T) {
t.Run("Changing the display name records a before and after", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/user/me", `{"display_name":"Marta Alves 2"}`), http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "user.update_profile", audit.CategoryUsers, audit.OutcomeSuccess)
assertProducerTarget(t, event, "user", "1", "Marta Alves 2")
if got := changesJSON(t, event); got != `[{"field":"display_name","kind":"text","before":"Marta Alves","after":"Marta Alves 2"}]` {
t.Errorf("changes = %s", got)
}
if event.Actor.Name != producersOwnerName {
t.Errorf("the actor snapshot name = %q, expected the name held when the request began", event.Actor.Name)
}
})
t.Run("Setting a display name that does not change records nothing", func(t *testing.T) {
for _, sent := range []string{"Marta Alves", " Marta Alves "} {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/user/me", `{"display_name":"`+sent+`"}`), http.StatusOK)
p.noEvent(t)
}
})
t.Run("A display name that is too long is recorded as failed", func(t *testing.T) {
p := newProducersEnv(t)
rec := p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/user/me", `{"display_name":"`+strings.Repeat("a", 61)+`"}`)
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
event := p.single(t)
assertProducerEvent(t, event, "user.update_profile", audit.CategoryUsers, audit.OutcomeFailed)
assertProducerFailure(t, event, "That display name is too long.")
if len(event.Changes) != 0 {
t.Errorf("changes = %+v", event.Changes)
}
})
t.Run("Creating an invite is recorded", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`), http.StatusCreated)
event := p.single(t)
assertProducerEvent(t, event, "invite.create", audit.CategoryUsers, audit.OutcomeSuccess)
assertProducerOwnerOrigin(t, event)
assertProducerTarget(t, event, "invite", "1", "new@example.com")
if encoded, _ := json.Marshal(event); strings.Contains(string(encoded), "hashed:") {
t.Errorf("the stored event holds a code hash: %s", encoded)
}
})
t.Run("A refused invite action is recorded as failed", func(t *testing.T) {
for _, example := range []struct {
name string
setup func(p *producersEnv)
method string
path string
body string
action string
reason string
}{
{"ErrEmailHasAccount", func(*producersEnv) {}, http.MethodPost, "/v1/user/invite", `{"email":"dev@example.com"}`, "invite.create", "That email already has an account."},
{"ErrPendingInvite", func(p *producersEnv) { p.seedPendingInvite("new@example.com") }, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, "invite.create", "An invitation is already pending for that email."},
{"ErrInviteEmailNotSent on create", func(p *producersEnv) { p.mail.fail.failNext("Send") }, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, "invite.create", "The invitation email could not be sent."},
{"ErrInviteEmailNotSent on renew", func(p *producersEnv) { p.seedPendingInvite("new@example.com"); p.mail.fail.failNext("Send") }, http.MethodPost, "/v1/user/invite/1/renew", "", "invite.renew", "The invitation email could not be sent."},
{"ErrInviteAlreadyAccepted on renew", func(p *producersEnv) { p.seedAcceptedInvite("new@example.com") }, http.MethodPost, "/v1/user/invite/1/renew", "", "invite.renew", "That invitation was already accepted."},
{"ErrInviteAlreadyAccepted on cancel", func(p *producersEnv) { p.seedAcceptedInvite("new@example.com") }, http.MethodDelete, "/v1/user/invite/1", "", "invite.cancel", "That invitation was already accepted."},
} {
t.Run(example.name, func(t *testing.T) {
p := newProducersEnv(t)
example.setup(p)
rec := p.as(t, apiKeyOwnerId, example.method, example.path, example.body)
if rec.Code < http.StatusBadRequest {
t.Fatalf("expected a refusal, got %d", rec.Code)
}
event := p.single(t)
assertProducerEvent(t, event, example.action, audit.CategoryUsers, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
})
}
})
t.Run("Cancelling an invite is recorded", func(t *testing.T) {
p := newProducersEnv(t)
p.seedPendingInvite("new@example.com")
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/user/invite/1", ""), http.StatusNoContent)
event := p.single(t)
assertProducerEvent(t, event, "invite.cancel", audit.CategoryUsers, audit.OutcomeSuccess)
assertProducerTarget(t, event, "invite", "1", "new@example.com")
})
t.Run("Renewing an invite is recorded", func(t *testing.T) {
p := newProducersEnv(t)
p.seedPendingInvite("new@example.com")
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/user/invite/1/renew", ""), http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "invite.renew", audit.CategoryUsers, audit.OutcomeSuccess)
assertProducerTarget(t, event, "invite", "1", "new@example.com")
})
t.Run("An invite or user that does not exist records nothing", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/user/invite/99", ""), http.StatusNotFound)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/user/invite/99/renew", ""), http.StatusNotFound)
p.noEvent(t)
})
t.Run("Reads and checks are never recorded", func(t *testing.T) {
p := newProducersEnv(t)
for _, path := range []string{"/v1/user", "/v1/auth/me", "/v1/auth/registration"} {
rec := p.as(t, apiKeyOwnerId, http.MethodGet, path, "")
assertAPIKeyStatus(t, rec, http.StatusOK)
}
p.noEvent(t)
})
}
func (p *producersEnv) seedAcceptedInvite(email string) {
accepted := time.Now().Add(-time.Hour)
p.invites.seed(invite.Invite{Email: email, HashedCode: "hashed:code", ExpiresAt: time.Now().Add(24 * time.Hour), InvitedBy: apiKeyOwnerId, AcceptedAt: &accepted, CreatedAt: time.Now().Add(-2 * time.Hour)})
}
func TestActivityLogProducersSystem(t *testing.T) {
t.Run("Checking for an update is recorded", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/check", ""), http.StatusOK)
event := p.single(t)
assertProducerEvent(t, event, "system.update_checked", audit.CategorySystem, audit.OutcomeSuccess)
assertProducerTarget(t, event, "release", "v2.3.0-beta", "v2.3.0-beta")
if got := paramsJSON(t, event); got != `{"update_available":true}` {
t.Errorf("params = %s", got)
}
})
t.Run("A failed update check is recorded with a plain reason", func(t *testing.T) {
for _, example := range []struct {
name string
err error
reason string
}{
{"ErrFeedUnavailable", update.ErrFeedUnavailable, "The release feed could not be reached."},
{"ErrNoRelease", update.ErrNoRelease, "No release was found."},
} {
t.Run(example.name, func(t *testing.T) {
p := newProducersEnv(t)
p.feed.err = example.err
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/check", ""), http.StatusInternalServerError)
event := p.single(t)
assertProducerEvent(t, event, "system.update_checked", audit.CategorySystem, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
if event.Target != (audit.Target{}) {
t.Errorf("target = %+v", event.Target)
}
})
}
})
t.Run("The status poll is never recorded", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/system/update", ""), http.StatusOK)
p.noEvent(t)
})
t.Run("A restart request is recorded", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/restart", ""), http.StatusAccepted)
event := p.single(t)
assertProducerEvent(t, event, "system.restart_requested", audit.CategorySystem, audit.OutcomeSuccess)
assertProducerOwnerOrigin(t, event)
if got := paramsJSON(t, event); got != `{"kind":"restart"}` {
t.Errorf("params = %s", got)
}
if event.Target != (audit.Target{}) {
t.Errorf("target = %+v", event.Target)
}
})
}
func (p *producersEnv) listedActions(t *testing.T, category string) []string {
t.Helper()
listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity?category="+category, "")
assertAPIKeyStatus(t, listing, http.StatusOK)
var page struct {
Items []struct {
Action string `json:"action"`
Category string `json:"category"`
} `json:"items"`
}
if err := json.Unmarshal(listing.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
actions := []string{}
for _, item := range page.Items {
if item.Category != category {
t.Errorf("item category = %q, expected %q", item.Category, category)
}
actions = append(actions, item.Action)
}
return actions
}
func TestActivityLogProducersReadBackOtherCategories(t *testing.T) {
t.Run("Media events are listed under their category", func(t *testing.T) {
p := newProducersEnv(t)
p.seedBanner(5, "banner.png", "same")
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"spring"}`), http.StatusOK)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/media/5", ""), http.StatusNoContent)
if got := strings.Join(p.listedActions(t, "media"), ","); got != "media.delete,media.rename" {
t.Errorf("actions = %s", got)
}
if got := p.listedActions(t, "articles"); len(got) != 0 {
t.Errorf("articles actions = %v", got)
}
})
t.Run("User events are listed under their category", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/user/me", `{"display_name":"Marta Alves 2"}`), http.StatusOK)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`), http.StatusCreated)
if got := strings.Join(p.listedActions(t, "users"), ","); got != "invite.create,user.update_profile" {
t.Errorf("actions = %s", got)
}
})
t.Run("System events are listed under their category", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/check", ""), http.StatusOK)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/system/update/restart", ""), http.StatusAccepted)
if got := strings.Join(p.listedActions(t, "system"), ","); got != "system.restart_requested,system.update_checked" {
t.Errorf("actions = %s", got)
}
})
}
func TestActivityLogProducersReadBack(t *testing.T) {
t.Run("Producer events are listed with their documented shape", func(t *testing.T) {
p := newProducersEnv(t)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/article", `{"title":"Spring release","body":"Hello"}`), http.StatusCreated)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/1", `{"body":"Hello world"}`), http.StatusOK)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/article/1/publish", ""), http.StatusOK)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodDelete, "/v1/article/1", ""), http.StatusNoContent)
listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity?category=articles", "")
assertAPIKeyStatus(t, listing, http.StatusOK)
var page struct {
Items []struct {
Action string `json:"action"`
Category string `json:"category"`
Target struct {
Exists bool `json:"exists"`
} `json:"target"`
} `json:"items"`
}
if err := json.Unmarshal(listing.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
var actions []string
for _, item := range page.Items {
actions = append(actions, item.Action)
if item.Category != "articles" || item.Target.Exists {
t.Errorf("item = %+v", item)
}
}
if strings.Join(actions, ",") != "article.delete,article.publish,article.edit,article.create" {
t.Errorf("actions = %v", actions)
}
})
t.Run("A failed producer event is filterable", func(t *testing.T) {
p := newProducersEnv(t)
p.seedBanner(5, "banner.png", "same")
p.as(t, apiKeyOwnerId, http.MethodPost, "/v1/media/merge", `{"keep_id":5,"merge_ids":[6]}`)
assertAPIKeyStatus(t, p.as(t, apiKeyOwnerId, http.MethodPatch, "/v1/media/5", `{"filename":"spring"}`), http.StatusOK)
listing := p.as(t, apiKeyOwnerId, http.MethodGet, "/v1/activity?outcome=failed", "")
assertAPIKeyStatus(t, listing, http.StatusOK)
var page struct {
Items []struct {
Action string `json:"action"`
FailureReason *string `json:"failure_reason"`
} `json:"items"`
}
if err := json.Unmarshal(listing.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].Action != "media.merge" || page.Items[0].FailureReason == nil || *page.Items[0].FailureReason != "One of the copies no longer exists." {
t.Errorf("items = %+v", page.Items)
}
})
}
+770
View File
@@ -0,0 +1,770 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"strings"
"testing"
"time"
"unicode/utf8"
)
const producerSignalTimeout = 5 * time.Second
type producerSignalRecorder struct {
inner audit.Recorder
recorded chan struct{}
}
var _ audit.Recorder = &producerSignalRecorder{}
func newProducerSignalRecorder(inner audit.Recorder) *producerSignalRecorder {
return &producerSignalRecorder{inner: inner, recorded: make(chan struct{}, 16)}
}
func (r *producerSignalRecorder) Record(ctx context.Context, event audit.Event) {
r.inner.Record(ctx, event)
r.recorded <- struct{}{}
}
func (r *producerSignalRecorder) await(t *testing.T) {
t.Helper()
select {
case <-r.recorded:
case <-time.After(producerSignalTimeout):
t.Fatal("no event was recorded")
}
}
type producerPurgeRunner struct {
result purge.RunResult
err error
calls int
}
var _ useCases.PurgeRunner = &producerPurgeRunner{}
func (r *producerPurgeRunner) Execute(*string) (purge.RunResult, error) {
r.calls++
return r.result, r.err
}
type producerBlockingImageRepo struct {
*mockImageRepo
entered chan struct{}
release chan struct{}
}
func (r *producerBlockingImageRepo) ListDuplicates() ([]image.Image, error) {
r.entered <- struct{}{}
<-r.release
return r.mockImageRepo.ListDuplicates()
}
func producerContext(actor audit.Actor, source audit.Source) context.Context {
return audit.WithOrigin(context.Background(), audit.Origin{Actor: actor, Source: source, IP: "192.0.2.1", UserAgent: "producers-test/1.0"})
}
func producerOwnerContext() context.Context {
id := uint32(1)
return producerContext(audit.Actor{Type: audit.ActorUser, ID: &id, Name: "Marta Alves"}, audit.SourceWeb)
}
func producerAnonymousContext() context.Context {
return producerContext(audit.AnonymousActor(""), audit.SourceWeb)
}
func onlyEvent(t *testing.T, h *activityHarness) audit.Event {
t.Helper()
events := h.all(t)
if len(events) != 1 {
t.Fatalf("expected exactly one event, got %d: %+v", len(events), events)
}
return events[0]
}
func assertNoEvents(t *testing.T, h *activityHarness) {
t.Helper()
if events := h.all(t); len(events) != 0 {
t.Fatalf("expected no event, got %+v", events)
}
}
func assertEventsHoldNothing(t *testing.T, h *activityHarness, forbidden ...string) {
t.Helper()
encoded, err := json.Marshal(h.all(t))
if err != nil {
t.Fatal(err)
}
for _, secret := range forbidden {
if strings.Contains(string(encoded), secret) || strings.Contains(h.logs.String(), secret) {
t.Errorf("an event or the log holds %q: %s", secret, encoded)
}
}
}
func TestActivityLogProducersArticleChanges(t *testing.T) {
t.Run("The delta is signed and counts characters", func(t *testing.T) {
for _, example := range []struct {
before int
after int
delta int
}{{100, 412, 312}, {412, 100, -312}, {5, 5, 0}} {
t.Run(fmt.Sprintf("%d to %d", example.before, example.after), func(t *testing.T) {
h := newActivityHarness(t)
repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder)
if _, err := uc.UpdateBody(producerOwnerContext(), 8, strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil {
t.Fatal(err)
}
event := onlyEvent(t, h)
if len(event.Changes) != 1 || event.Changes[0].Delta == nil || *event.Changes[0].Delta != example.delta || event.Changes[0].Field != "body" || event.Changes[0].Kind != audit.ChangeBody {
t.Errorf("changes = %+v", event.Changes)
}
assertEventsHoldNothing(t, h, "bbbb")
})
}
})
t.Run("A body with multi-byte characters is counted in characters", func(t *testing.T) {
h := newActivityHarness(t)
repo := newMockArticleRepo()
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder)
if _, err := uc.UpdateBody(producerOwnerContext(), 8, "héllo wörld", article.BodyFormatHTML); err != nil {
t.Fatal(err)
}
event := onlyEvent(t, h)
if event.Changes[0].Delta == nil || *event.Changes[0].Delta != 6 || utf8.RuneCountInString("héllo wörld")-utf8.RuneCountInString("héllo") != 6 {
t.Errorf("changes = %+v", event.Changes)
}
})
t.Run("A title change is recorded as a text diff when a title edit exists", func(t *testing.T) {
before := article.Article{ID: 8, Title: "Spring release", Body: "x", IsOnline: false}
after := article.Article{ID: 8, Title: "Spring release 2", Body: "x", IsOnline: false}
changes := audit.ArticleChanges(before, after)
encoded, _ := json.Marshal(changes)
if string(encoded) != `[{"field":"title","kind":"text","before":"Spring release","after":"Spring release 2"}]` {
t.Errorf("changes = %s", encoded)
}
})
t.Run("Only the fields title, online and body can ever appear in an article event's changes", func(t *testing.T) {
before := article.Article{ID: 8, Title: "a", Body: "x", IsOnline: false}
after := article.Article{ID: 9, Title: "b", Body: "y", IsOnline: true}
for _, change := range audit.ArticleChanges(before, after) {
if change.Field != "title" && change.Field != "online" && change.Field != "body" {
t.Errorf("unexpected field %q", change.Field)
}
if change.Kind != audit.ChangeText && change.Kind != audit.ChangeBody {
t.Errorf("unexpected kind %q", change.Kind)
}
}
})
t.Run("A refused article title is recorded as failed with a plain reason", func(t *testing.T) {
h := newActivityHarness(t)
uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder)
_, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Title: "ab", Body: "Hello"})
if !errors.Is(err, article.ErrInvalidTitle) {
t.Fatalf("err = %v", err)
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "article.create", audit.CategoryArticles, audit.OutcomeFailed)
assertProducerFailure(t, event, "The title must be between 3 and 200 characters.")
})
}
func TestActivityLogProducersFailureReasons(t *testing.T) {
sentences := []struct {
cause error
sentence string
}{
{article.ErrInvalidTitle, "The title must be between 3 and 200 characters."},
{image.ErrImageTypeNotAllowed, "The file must be a PNG, JPEG, WEBP or SVG image."},
{image.ErrImageTooLarge, "The file is too large to upload."},
{image.ErrImageUnreadable, "The image could not be read."},
{image.ErrInvalidFilename, "That file name is not valid."},
{image.ErrInvalidMergeRequest, "The merge request is not valid."},
{image.ErrMergeCopyMissing, "One of the copies no longer exists."},
{image.ErrMergeHashMismatch, "The images are not identical copies."},
{purge.ErrPurgeConflict, "The library changed since the preview. Review it again."},
{user.ErrRegistrationClosed, "Registration is closed on this site."},
{user.ErrVerificationEmailNotSent, "The verification email could not be sent."},
{user.ErrInvalidVerificationCode, "The verification code is not valid."},
{user.ErrVerificationCodeExpired, "The verification code has expired."},
{user.ErrInvalidDisplayName, "That display name is too long."},
{user.ErrUsernameTaken, "That username is already taken."},
{invite.ErrEmailHasAccount, "That email already has an account."},
{invite.ErrPendingInvite, "An invitation is already pending for that email."},
{invite.ErrInviteEmailNotSent, "The invitation email could not be sent."},
{invite.ErrInviteAlreadyAccepted, "That invitation was already accepted."},
{invite.ErrInviteInvalid, "That invitation is not valid."},
{invite.ErrInviteExpired, "That invitation has expired."},
{update.ErrFeedUnavailable, "The release feed could not be reached."},
{update.ErrNoRelease, "No release was found."},
{update.ErrNotPrepared, "This server cannot apply updates."},
{update.ErrJobInProgress, "An update is already being downloaded."},
{update.ErrRestartPending, "A restart is already pending."},
{update.ErrNothingToUpdate, "There is nothing to update."},
{update.ErrPreflightRefused, "The update cannot be downloaded."},
{update.ErrRestartKindMismatch, "The requested restart does not match the server state."},
}
dueSettings := func() *mockPurgeSettingsRepo {
repo := newMockPurgeSettingsRepo()
repo.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 1, EnabledAt: purgeTime("2026-09-01T00:00:00Z")}
return repo
}
failWith := func(t *testing.T, err error) audit.Event {
t.Helper()
h := newActivityHarness(t)
scheduler := useCases.NewPurgeSchedulerUseCase(dueSettings(), &producerPurgeRunner{err: err}, h.recorder)
if _, _, tickErr := scheduler.Tick(*purgeTime("2026-10-05T09:00:00Z")); !errors.Is(tickErr, err) {
t.Fatalf("tick err = %v", tickErr)
}
return onlyEvent(t, h)
}
t.Run("Failure reasons are mapped from domain sentinels", func(t *testing.T) {
for _, example := range sentences {
t.Run(example.cause.Error(), func(t *testing.T) {
event := failWith(t, fmt.Errorf("while working: %w", example.cause))
assertProducerFailure(t, event, example.sentence)
})
}
})
t.Run("Unmapped errors become the generic sentence", func(t *testing.T) {
for name, err := range map[string]error{
"a plain error": errors.New("db is locked"),
"an error carrying an SMTP host": errors.New("dial smtp.internal.example:587 with password hunter2"),
"an error carrying a file system path": errors.New(`open C:\data\secret\file.db`),
"an error wrapping a sentinel and a raw cause": errors.Join(update.ErrFeedUnavailable, errors.New("token=abc123")),
} {
t.Run(name, func(t *testing.T) {
event := failWith(t, err)
if name == "an error wrapping a sentinel and a raw cause" {
assertProducerFailure(t, event, "The release feed could not be reached.")
} else {
assertProducerFailure(t, event, useCases.GenericFailureReason)
}
encoded, _ := json.Marshal(event)
for _, leaked := range []string{"locked", "hunter2", "smtp.internal", "secret", "abc123"} {
if strings.Contains(string(encoded), leaked) {
t.Errorf("the event leaks %q: %s", leaked, encoded)
}
}
})
}
})
t.Run("Every failure reason fits the stored limit", func(t *testing.T) {
for _, example := range sentences {
if len(example.sentence) > 255 {
t.Errorf("%q is %d characters", example.sentence, len(example.sentence))
}
}
if len(useCases.GenericFailureReason) > 255 {
t.Errorf("the generic sentence is too long")
}
})
t.Run("A failed event keeps its target when the target is known", func(t *testing.T) {
h := newActivityHarness(t)
images := newMockImageRepo()
images.images[5] = image.Image{ID: 5, Filename: "banner.png", Path: "/static/uploadedImages/5.png"}
uc := useCases.NewRenameMediaUseCase(images, h.recorder)
if _, err := uc.Rename(producerOwnerContext(), 5, "not valid!"); !errors.Is(err, image.ErrInvalidFilename) {
t.Fatalf("err = %v", err)
}
assertProducerTarget(t, onlyEvent(t, h), "image", "5", "banner.png")
})
}
func TestActivityLogProducersScheduledPurge(t *testing.T) {
due := func() *mockPurgeSettingsRepo {
repo := newMockPurgeSettingsRepo()
repo.settings = purge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 10, EnabledAt: purgeTime("2026-09-20T09:00:00Z")}
return repo
}
now := *purgeTime("2026-10-05T09:00:00Z")
t.Run("The scheduled purge is a system event", func(t *testing.T) {
h := newActivityHarness(t)
runner := &producerPurgeRunner{result: purge.RunResult{GroupsTotal: 2, GroupsPurged: 2, Merged: 3, ArticlesUpdated: 5}}
scheduler := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder)
if _, ran, err := scheduler.Tick(now); err != nil || !ran {
t.Fatalf("ran = %v, err = %v", ran, err)
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "system.media_purge", audit.CategorySystem, audit.OutcomeSuccess)
if event.Actor.Type != audit.ActorSystem || event.Actor.Name != "System" || event.Source != audit.SourceSystem || event.IP != nil || event.UserAgent != nil {
t.Errorf("event = %+v", event)
}
if got := paramsJSON(t, event); got != `{"articles_updated":5,"merged":3}` {
t.Errorf("params = %s", got)
}
})
t.Run("A scheduled purge with a failed group records the failed group count", func(t *testing.T) {
h := newActivityHarness(t)
runner := &producerPurgeRunner{result: purge.RunResult{GroupsTotal: 3, GroupsPurged: 2, FailedGroups: 1, Merged: 2, ArticlesUpdated: 2}}
scheduler := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder)
if _, _, err := scheduler.Tick(now); err != nil {
t.Fatal(err)
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "system.media_purge", audit.CategorySystem, audit.OutcomeSuccess)
if !strings.Contains(paramsJSON(t, event), `"failed_groups":1`) {
t.Errorf("params = %s", paramsJSON(t, event))
}
})
t.Run("A scheduled purge that is not due or merges nothing records nothing", func(t *testing.T) {
h := newActivityHarness(t)
notDue := newMockPurgeSettingsRepo()
runner := &producerPurgeRunner{}
if _, ran, err := useCases.NewPurgeSchedulerUseCase(notDue, runner, h.recorder).Tick(now); err != nil || ran {
t.Fatalf("ran = %v, err = %v", ran, err)
}
if _, ran, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(now); err != nil || !ran {
t.Fatalf("ran = %v, err = %v", ran, err)
}
assertNoEvents(t, h)
})
t.Run("A scheduled purge that cannot record its run is a failed system event", func(t *testing.T) {
h := newActivityHarness(t)
runner := &producerPurgeRunner{err: errRepositoryFailure}
if _, _, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(now); err == nil {
t.Fatal("expected an error")
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "system.media_purge", audit.CategorySystem, audit.OutcomeFailed)
assertProducerFailure(t, event, useCases.GenericFailureReason)
})
t.Run("A refused manual purge is recorded as failed when a purge is already running", func(t *testing.T) {
h := newActivityHarness(t)
images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})}
articles := newMockArticleRepo()
storage := newMockImageStorage()
merge := useCases.NewMergeMediaUseCase(images, articles, storage, h.recorder)
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder)
finished := make(chan struct{})
go func() {
defer close(finished)
_, _ = run.Run(producerOwnerContext(), nil)
}()
<-images.entered
_, err := run.Run(producerOwnerContext(), nil)
close(images.release)
<-finished
if !errors.Is(err, purge.ErrPurgeBusy) {
t.Fatalf("err = %v", err)
}
events := h.all(t)
if len(events) != 2 {
t.Fatalf("events = %+v", events)
}
busy := events[1]
if events[0].Outcome == audit.OutcomeFailed {
busy = events[0]
}
assertProducerEvent(t, busy, "media.purge", audit.CategoryMedia, audit.OutcomeFailed)
assertProducerFailure(t, busy, "A purge is already running.")
})
}
func TestActivityLogProducersAccounts(t *testing.T) {
const password = "s3cretpassw0rd"
newUsers := func() *mockUserRepo {
users := newMockUserRepo()
return users
}
t.Run("Registering is recorded with the new account as actor", func(t *testing.T) {
h := newActivityHarness(t)
uc := useCases.NewCreateUserUseCase(newUsers(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", h.recorder)
created, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"})
if err != nil {
t.Fatal(err)
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "user.register", audit.CategoryUsers, audit.OutcomeSuccess)
if event.Actor.Type != audit.ActorUser || event.Actor.ID == nil || *event.Actor.ID != created.ID || event.Actor.Name != "marta" || event.Source != audit.SourceWeb {
t.Errorf("actor = %+v, source = %s", event.Actor, event.Source)
}
assertProducerTarget(t, event, "user", "1", "marta")
assertEventsHoldNothing(t, h, password, "hashed:", created.VerificationCode)
})
t.Run("A refused registration is recorded as failed", func(t *testing.T) {
for _, example := range []struct {
cause string
setup func(users *mockUserRepo, mail *mockMailRepo)
reason string
}{
{"registration is closed", func(users *mockUserRepo, _ *mockMailRepo) {
users.users[9] = user.User{ID: 9, Username: "owner", IsVerified: true}
}, "Registration is closed on this site."},
{"the verification email cannot be sent", func(_ *mockUserRepo, mail *mockMailRepo) { mail.fail.failNext("Send") }, "The verification email could not be sent."},
} {
t.Run(example.cause, func(t *testing.T) {
h := newActivityHarness(t)
users := newUsers()
mail := newMockMailRepo()
example.setup(users, mail)
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, mail, "https://cms.example.com", h.recorder)
if _, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"}); err == nil {
t.Fatal("expected a refusal")
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "user.register", audit.CategoryUsers, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
if event.Actor.Type != audit.ActorAnonymous || event.Actor.Name != "marta" || event.Target != (audit.Target{}) {
t.Errorf("actor = %+v, target = %+v", event.Actor, event.Target)
}
assertEventsHoldNothing(t, h, password)
})
}
})
t.Run("A failed cleanup after an unsent verification email keeps both causes", func(t *testing.T) {
h := newActivityHarness(t)
users := newUsers()
mail := newMockMailRepo()
mail.fail.failNext("Send")
cleanupFailure := errors.New("cleanup failure")
users.fail.failNextWith("Delete", cleanupFailure)
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, mail, "https://cms.example.com", h.recorder)
_, err := uc.CreateUser(producerAnonymousContext(), useCases.CreateUserCommand{Username: "marta", Password: password, Email: "marta@example.com"})
if !errors.Is(err, user.ErrVerificationEmailNotSent) || !errors.Is(err, cleanupFailure) {
t.Errorf("err = %v", err)
}
assertProducerFailure(t, onlyEvent(t, h), "The verification email could not be sent.")
})
t.Run("Verifying an account is recorded", func(t *testing.T) {
h := newActivityHarness(t)
users := newUsers()
users.users[4] = user.User{ID: 4, Username: "lea", VerificationCode: "hashed:the-code", VerificationExpiration: time.Now().Add(time.Hour)}
uc := useCases.NewVerifyUserUseCase(users, &stubHasher{}, h.recorder)
if _, err := uc.Verify(producerAnonymousContext(), 4, "the-code"); err != nil {
t.Fatal(err)
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "user.verify", audit.CategoryUsers, audit.OutcomeSuccess)
if event.Actor.Type != audit.ActorUser || event.Actor.ID == nil || *event.Actor.ID != 4 {
t.Errorf("actor = %+v", event.Actor)
}
assertProducerTarget(t, event, "user", "4", "lea")
assertEventsHoldNothing(t, h, "the-code", "hashed:")
})
t.Run("A refused verification is recorded as failed", func(t *testing.T) {
for _, example := range []struct {
name string
code string
expiration time.Time
reason string
}{
{"ErrInvalidVerificationCode", "wrong", time.Now().Add(time.Hour), "The verification code is not valid."},
{"ErrVerificationCodeExpired", "the-code", time.Now().Add(-time.Hour), "The verification code has expired."},
} {
t.Run(example.name, func(t *testing.T) {
h := newActivityHarness(t)
users := newUsers()
users.users[4] = user.User{ID: 4, Username: "lea", VerificationCode: "hashed:the-code", VerificationExpiration: example.expiration}
uc := useCases.NewVerifyUserUseCase(users, &stubHasher{}, h.recorder)
if _, err := uc.Verify(producerAnonymousContext(), 4, example.code); err == nil {
t.Fatal("expected a refusal")
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "user.verify", audit.CategoryUsers, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
})
}
})
t.Run("Accepting an invite is recorded with the new account as actor", func(t *testing.T) {
h := newActivityHarness(t)
users := newUsers()
invites := newInviteFakeRepo()
invites.users = users
seeded := invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", ExpiresAt: time.Now().Add(time.Hour)})
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, h.recorder)
created, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: "the-code", Username: "sam", Password: password})
if err != nil {
t.Fatal(err)
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "invite.accept", audit.CategoryUsers, audit.OutcomeSuccess)
if event.Actor.Type != audit.ActorUser || event.Actor.ID == nil || *event.Actor.ID != created.ID || event.Actor.Name != "sam" || event.Source != audit.SourceWeb {
t.Errorf("actor = %+v", event.Actor)
}
assertProducerTarget(t, event, "invite", fmt.Sprint(seeded.ID), "new@example.com")
assertEventsHoldNothing(t, h, "the-code", "hashed:", password)
})
t.Run("A refused acceptance is recorded as failed", func(t *testing.T) {
for _, example := range []struct {
name string
code string
expiresIn time.Duration
existing []user.User
reason string
target bool
}{
{"ErrInviteInvalid", "wrong", time.Hour, nil, "That invitation is not valid.", false},
{"ErrInviteExpired", "the-code", -time.Hour, nil, "That invitation has expired.", false},
{"ErrUsernameTaken", "the-code", time.Hour, []user.User{{ID: 9, Username: "sam", Email: "sam@example.com"}}, "That username is already taken.", true},
{"ErrEmailHasAccount", "the-code", time.Hour, []user.User{{ID: 9, Username: "other", Email: "new@example.com"}}, "That email already has an account.", true},
} {
t.Run(example.name, func(t *testing.T) {
h := newActivityHarness(t)
users := newUsers()
for _, existing := range example.existing {
users.users[existing.ID] = existing
}
invites := newInviteFakeRepo()
invites.users = users
invites.seed(invite.Invite{Email: "new@example.com", HashedCode: "hashed:the-code", ExpiresAt: time.Now().Add(example.expiresIn)})
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, h.recorder)
if _, err := uc.AcceptInvite(producerAnonymousContext(), useCases.AcceptInviteCommand{Code: example.code, Username: "sam", Password: password}); err == nil {
t.Fatal("expected a refusal")
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "invite.accept", audit.CategoryUsers, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
if event.Actor.Type != audit.ActorAnonymous || event.Actor.Name != "sam" {
t.Errorf("actor = %+v", event.Actor)
}
if (event.Target != audit.Target{}) != example.target {
t.Errorf("target = %+v", event.Target)
}
assertEventsHoldNothing(t, h, "the-code", password)
})
}
})
}
func TestActivityLogProducersUpdateDownload(t *testing.T) {
cancelledOwnerContext := func() (context.Context, context.CancelFunc) {
return context.WithCancel(producerOwnerContext())
}
newRig := func(t *testing.T) (*downloadRig, *activityHarness, *producerSignalRecorder) {
t.Helper()
h := newActivityHarness(t)
signal := newProducerSignalRecorder(h.recorder)
rig := newDownloadRig(t)
rig.recorder = signal
rig.rebuild()
return rig, h, signal
}
t.Run("A download that completes is recorded when the job is ready", func(t *testing.T) {
rig, h, signal := newRig(t)
ctx, cancel := cancelledOwnerContext()
if _, err := rig.start.Start(ctx); err != nil {
t.Fatal(err)
}
cancel()
signal.await(t)
event := onlyEvent(t, h)
assertProducerEvent(t, event, "system.update_downloaded", audit.CategorySystem, audit.OutcomeSuccess)
assertProducerTarget(t, event, "release", downloadTag, downloadTag)
if event.Actor.Type != audit.ActorUser || event.Actor.Name != "Marta Alves" || event.Source != audit.SourceWeb || event.IP == nil || *event.IP != "192.0.2.1" {
t.Errorf("event = %+v", event)
}
assertEventsHoldNothing(t, h, "https://", rig.updatesDir)
})
t.Run("A download that fails is recorded with a reason that hides the cause", func(t *testing.T) {
for _, example := range []struct {
kind string
setup func(rig *downloadRig)
reason string
}{
{"download", func(rig *downloadRig) { rig.fetcher.errors[rig.assetName] = errDownloadNetwork }, "The update could not be downloaded."},
{"verification", func(rig *downloadRig) {
_, otherKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
rig.signChecksums(rig.checksumsFor(rig.assetName, rig.fetcher.files[rig.assetName]), otherKey)
rig.publishAssets()
}, "The downloaded update failed its integrity check."},
} {
t.Run(example.kind, func(t *testing.T) {
rig, h, signal := newRig(t)
example.setup(rig)
if _, err := rig.start.Start(producerOwnerContext()); err != nil {
t.Fatal(err)
}
signal.await(t)
event := onlyEvent(t, h)
assertProducerEvent(t, event, "system.update_downloaded", audit.CategorySystem, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
assertEventsHoldNothing(t, h, "https://", rig.updatesDir, errDownloadNetwork.Error())
})
}
})
t.Run("A download refused at the start is recorded as failed", func(t *testing.T) {
for _, example := range []struct {
name string
setup func(rig *downloadRig)
reason string
}{
{"ErrNotPrepared", func(rig *downloadRig) { rig.helper.installed = false }, "This server cannot apply updates."},
{"ErrJobInProgress", func(rig *downloadRig) {
rig.jobs.put(update.Job{Tag: downloadTag, State: update.StateDownloading, StartedAt: time.Now()})
}, "An update is already being downloaded."},
{"ErrRestartPending", func(rig *downloadRig) {
_ = rig.requests.Write(update.RestartRequest{Kind: update.RestartKindRestart, RequestedAt: updateTestCheckedAt})
}, "A restart is already pending."},
{"ErrNothingToUpdate", func(rig *downloadRig) { rig.store.found = false }, "There is nothing to update."},
{"ErrPreflightRefused", func(rig *downloadRig) { rig.dropAsset(update.ChecksumsAssetName) }, "The update cannot be downloaded."},
} {
t.Run(example.name, func(t *testing.T) {
rig, h, _ := newRig(t)
example.setup(rig)
if _, err := rig.start.Start(producerOwnerContext()); err == nil {
t.Fatal("expected a refusal")
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "system.update_downloaded", audit.CategorySystem, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
})
}
})
}
func TestActivityLogProducersRestart(t *testing.T) {
newRequest := func(t *testing.T) (*restartRig, *activityHarness, *useCases.RequestRestartUseCase) {
t.Helper()
h := newActivityHarness(t)
rig := newRestartRig(updateRunning)
request := useCases.NewRequestRestartUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, updateRunning, rig.clock.Now, h.recorder)
return rig, h, request
}
t.Run("A restart request is recorded", func(t *testing.T) {
_, h, request := newRequest(t)
if _, err := request.Request(producerOwnerContext(), updateOwnerId, ""); err != nil {
t.Fatal(err)
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "system.restart_requested", audit.CategorySystem, audit.OutcomeSuccess)
if got := paramsJSON(t, event); got != `{"kind":"restart"}` {
t.Errorf("params = %s", got)
}
if event.Target != (audit.Target{}) {
t.Errorf("target = %+v", event.Target)
}
})
t.Run("A restart that applies a downloaded update records the kind and version", func(t *testing.T) {
rig, h, request := newRequest(t)
rig.readyJob()
if _, err := request.Request(producerOwnerContext(), updateOwnerId, ""); err != nil {
t.Fatal(err)
}
if got := paramsJSON(t, onlyEvent(t, h)); got != `{"kind":"update","tag":"`+downloadTag+`"}` {
t.Errorf("params = %s", got)
}
})
t.Run("A refused restart is recorded as failed", func(t *testing.T) {
for _, example := range []struct {
name string
setup func(rig *restartRig)
kind string
reason string
}{
{"ErrNotPrepared", func(rig *restartRig) { rig.helper.installed = false }, "", "This server cannot apply updates."},
{"ErrJobInProgress", func(rig *restartRig) {
rig.jobs.put(update.Job{Tag: downloadTag, State: update.StateDownloading, StartedAt: restartBaseTime})
}, "", "An update is still being downloaded."},
{"ErrRestartPending", func(rig *restartRig) {
_ = rig.requests.Write(update.RestartRequest{Kind: update.RestartKindRestart, RequestedAt: restartBaseTime})
}, "", "A restart is already pending."},
{"ErrRestartKindMismatch", func(*restartRig) {}, update.RestartKindUpdate, "The requested restart does not match the server state."},
} {
t.Run(example.name, func(t *testing.T) {
rig, h, request := newRequest(t)
example.setup(rig)
if _, err := request.Request(producerOwnerContext(), updateOwnerId, example.kind); err == nil {
t.Fatal("expected a refusal")
}
event := onlyEvent(t, h)
assertProducerEvent(t, event, "system.restart_requested", audit.CategorySystem, audit.OutcomeFailed)
assertProducerFailure(t, event, example.reason)
})
}
})
}
+35 -20
View File
@@ -6,6 +6,7 @@ import (
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
@@ -15,6 +16,8 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"regexp"
@@ -45,6 +48,7 @@ type apiKeyEnv struct {
articles *mockArticleRepo
images *mockImageRepo
invites *inviteFakeRepo
mail *mockMailRepo
feed *fakeReleaseFeed
updates *fakeUpdateStore
token *jwtauth.JWTAuth
@@ -57,6 +61,11 @@ type apiKeyEnv struct {
}
func newAPIKeyEnv(t *testing.T) *apiKeyEnv {
t.Helper()
return newAPIKeyEnvRecording(t, discardRecorder)
}
func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
t.Helper()
validator.InitValidator()
@@ -89,41 +98,47 @@ func newAPIKeyEnv(t *testing.T) *apiKeyEnv {
clock := func() time.Time { return env.now }
hasher := &stubHasher{}
mail := newMockMailRepo()
env.mail = mail
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), recorder)
purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{}
env.container = &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com"),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, discardRecorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", discardRecorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, discardRecorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com"),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com"),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites),
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage()),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), recorder),
MergeMediaUseCase: mergeMedia,
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake()),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, discardRecorder),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, discardRecorder),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock),
}
router := chi.NewRouter()
+3 -3
View File
@@ -43,11 +43,11 @@ const (
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer),
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, renderer),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder),
getUserUseCase,
)
+3 -3
View File
@@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)
+3 -3
View File
@@ -148,11 +148,11 @@ var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
res, err := uc.CreateArticle(cmd)
res, err := uc.CreateArticle(testCtx, cmd)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
@@ -163,7 +163,7 @@ func TestCreateArticle(t *testing.T) {
t.Run("Short Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Ab", Body: "Content"}
_, err := uc.CreateArticle(cmd)
_, err := uc.CreateArticle(testCtx, cmd)
if !errors.Is(err, article.ErrInvalidTitle) {
t.Errorf("Expected ErrInvalidTitle")
}
+5 -5
View File
@@ -26,11 +26,11 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(repo),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)
@@ -87,10 +87,10 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
title := strings.Repeat("\U0001F600", tc.runeCount)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
if tc.created {
if err != nil {
+3 -3
View File
@@ -20,11 +20,11 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)
@@ -19,12 +19,12 @@ func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
useCases.NewCancelRegistrationUseCase(userRepo),
true,
)
+4 -3
View File
@@ -52,17 +52,18 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
Platform: rig.platform,
FeedURL: downloadFeedURL,
Now: now,
Recorder: discardRecorder,
})
routes := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
StartUpdateDownloadUseCase: start,
RequestRestartUseCase: useCases.NewRequestRestartUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, now),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, now, discardRecorder),
}
return &containerHarness{
updateHarness: &updateHarness{router: api.NewSystemRouter(routes), token: token, feed: feed, store: rig.store},
updateHarness: &updateHarness{router: api.NewSystemRouter(routes, nil), token: token, feed: feed, store: rig.store},
fetcher: rig.fetcher,
requests: rig.requests,
jobs: rig.jobs,
+3 -3
View File
@@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)
+1 -1
View File
@@ -21,7 +21,7 @@ func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
meHandler := auth.NewMeHandler(getUserUseCase)
verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
useCases.NewCancelRegistrationUseCase(userRepo),
true,
)
+6 -6
View File
@@ -34,9 +34,9 @@ func (s *spyHasher) Compare(hashed string, value string) error {
func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) {
t.Run("stores hashed password and hashed verification code, returns raw code", func(t *testing.T) {
repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
result, err := uc.CreateUser(useCases.CreateUserCommand{
result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice",
Password: "s3cretPassw0rd",
Email: "alice@example.com",
@@ -59,9 +59,9 @@ func TestCreateUserUseCaseHashesPasswordAndVerificationCode(t *testing.T) {
t.Run("a hashing failure persists nothing", func(t *testing.T) {
repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{hashErr: errStubHashFailure}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(useCases.CreateUserCommand{
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice",
Password: "s3cretPassw0rd",
Email: "alice@example.com",
@@ -156,7 +156,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
@@ -174,7 +174,7 @@ func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
+5 -5
View File
@@ -249,8 +249,8 @@ var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage())
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), discardRecorder)
t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{
@@ -259,7 +259,7 @@ func TestImageUseCases(t *testing.T) {
ContentType: "image/png",
}
res, err := createUc.CreateImage(input)
res, err := createUc.CreateImage(testCtx, input)
if err != nil {
t.Fatalf("CreateImage failed: %v", err)
}
@@ -274,7 +274,7 @@ func TestImageUseCases(t *testing.T) {
t.Run("Delete Image", func(t *testing.T) {
repo.images[99] = imageDomain.Image{ID: 99, Path: "/static/delete-me.png"}
err := deleteUc.Delete(99)
err := deleteUc.Delete(testCtx, 99)
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
@@ -285,7 +285,7 @@ func TestImageUseCases(t *testing.T) {
})
t.Run("Delete unknown image", func(t *testing.T) {
if err := deleteUc.Delete(12345); !errors.Is(err, imageDomain.ErrImageNotFound) {
if err := deleteUc.Delete(testCtx, 12345); !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected ErrImageNotFound, got %v", err)
}
})
+11 -11
View File
@@ -39,9 +39,9 @@ func TestImageUploadRules(t *testing.T) {
for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} {
t.Run(contentType, func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
result, err := createUc.CreateImage(inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType))
result, err := createUc.CreateImage(testCtx, inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType))
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -58,9 +58,9 @@ func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage rejects a disallowed content type before reaching the repository", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
_, err := createUc.CreateImage(inputOf([]byte("%PDF"), "application/pdf"))
_, err := createUc.CreateImage(testCtx, inputOf([]byte("%PDF"), "application/pdf"))
if !errors.Is(err, imageDomain.ErrImageTypeNotAllowed) {
t.Fatalf("expected ErrImageTypeNotAllowed, got %v", err)
@@ -72,9 +72,9 @@ func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage rejects a file that exceeds the size limit", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
_, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
_, err := createUc.CreateImage(testCtx, inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
if !errors.Is(err, imageDomain.ErrImageTooLarge) {
t.Fatalf("expected ErrImageTooLarge, got %v", err)
@@ -86,9 +86,9 @@ func TestImageUploadRules(t *testing.T) {
t.Run("A file exactly at the size limit is accepted, one byte over is not", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
_, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png"))
_, err := createUc.CreateImage(testCtx, inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png"))
if err != nil {
t.Fatalf("expected no error at the exact limit, got %v", err)
}
@@ -96,7 +96,7 @@ func TestImageUploadRules(t *testing.T) {
t.Fatalf("expected Create to be called once, got %d calls", len(repo.images))
}
_, err = createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
_, err = createUc.CreateImage(testCtx, inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
if !errors.Is(err, imageDomain.ErrImageTooLarge) {
t.Fatalf("expected ErrImageTooLarge, got %v", err)
}
@@ -108,9 +108,9 @@ func TestImageUploadRules(t *testing.T) {
t.Run("A repository failure unrelated to type or size still propagates unchanged", func(t *testing.T) {
repo := newMockImageRepo()
repo.fail.failNextWith("Create", errRepositoryFailure)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
_, err := createUc.CreateImage(inputOf(pngBytes(t, 3, 2), "image/png"))
_, err := createUc.CreateImage(testCtx, inputOf(pngBytes(t, 3, 2), "image/png"))
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the generic repository error, got %v", err)
+11 -11
View File
@@ -152,7 +152,7 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
}
getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
nil,
nil,
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
@@ -536,7 +536,7 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
hasher := &stubHasher{}
handler := auth.NewLoginHandler(
jwtauth.New("HS256", []byte("test-secret"), nil),
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL),
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder),
true,
)
@@ -601,12 +601,12 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
func TestCreateInviteUseCase(t *testing.T) {
newUseCase := func() (*useCases.CreateInviteUseCase, *mockUserRepo, *inviteFakeRepo, *mockMailRepo, *stubHasher) {
users, invites, mail, hasher := inviteInviterRepo("Marta A"), newInviteFakeRepo(), newMockMailRepo(), &stubHasher{}
return useCases.NewCreateInviteUseCase(users, invites, hasher, mail, invitePublicURL), users, invites, mail, hasher
return useCases.NewCreateInviteUseCase(users, invites, hasher, mail, invitePublicURL, discardRecorder), users, invites, mail, hasher
}
t.Run("lower-cases the email and returns the stored invite", func(t *testing.T) {
uc, _, _, _, _ := newUseCase()
got, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "A@B.Example", InvitedBy: 7})
got, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "A@B.Example", InvitedBy: 7})
if err != nil || got.Email != "a@b.example" || got.ID == 0 {
t.Fatalf("unexpected result %+v, %v", got, err)
}
@@ -614,7 +614,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("unknown inviter", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 99})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 99})
if !errors.Is(err, user.ErrUserNotFound) || len(invites.invites) != 0 {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
@@ -623,7 +623,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("existing account is refused case-insensitively", func(t *testing.T) {
uc, users, _, _, _ := newUseCase()
users.users[8] = user.User{ID: 8, Email: "Jonas@Renew.example"}
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "jonas@renew.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "jonas@renew.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrEmailHasAccount) {
t.Errorf("expected ErrEmailHasAccount, got %v", err)
}
@@ -632,7 +632,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("pending invite is refused", func(t *testing.T) {
uc, _, invites, _, _ := newUseCase()
invites.seed(pendingInvite("a@b.example"))
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrPendingInvite) {
t.Errorf("expected ErrPendingInvite, got %v", err)
}
@@ -645,7 +645,7 @@ func TestCreateInviteUseCase(t *testing.T) {
accepted.AcceptedAt = &acceptedAt
invites.seed(accepted)
invites.seed(pendingInvite("other@b.example"))
if _, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}); err != nil {
if _, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7}); err != nil {
t.Errorf("unexpected error %v", err)
}
if len(invites.invites) != 3 {
@@ -656,7 +656,7 @@ func TestCreateInviteUseCase(t *testing.T) {
t.Run("hash failure stores and sends nothing", func(t *testing.T) {
uc, _, invites, mail, hasher := newUseCase()
hasher.hashErr = errors.New("hash boom")
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if err == nil || len(invites.invites) != 0 || len(mail.SentEmails) != 0 {
t.Errorf("expected failure without side effects, got %v", err)
}
@@ -666,7 +666,7 @@ func TestCreateInviteUseCase(t *testing.T) {
uc, _, invites, mail, _ := newUseCase()
invites.seed(expiredInvite("a@b.example"))
invites.fail.failNext("Delete")
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 {
t.Errorf("expected repository failure, got %v", err)
}
@@ -676,7 +676,7 @@ func TestCreateInviteUseCase(t *testing.T) {
uc, _, invites, mail, _ := newUseCase()
mail.fail.failNextWith("Send", errors.New("smtp down"))
invites.fail.failNextWith("Delete", errors.New("cleanup down"))
_, err := uc.CreateInvite(useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
_, err := uc.CreateInvite(testCtx, useCases.CreateInviteCommand{Email: "a@b.example", InvitedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !strings.Contains(err.Error(), "smtp down") || !strings.Contains(err.Error(), "cleanup down") {
t.Errorf("unexpected error %v", err)
}
+16 -16
View File
@@ -55,14 +55,14 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
useCases.NewCancelInviteUseCase(f.invites),
useCases.NewRenewInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL),
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewCancelInviteUseCase(f.invites, discardRecorder),
useCases.NewRenewInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
)
acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, f.hasher, discardRecorder),
f.token,
true,
)
@@ -480,9 +480,9 @@ func TestManageInviteUseCases(t *testing.T) {
mail := newMockMailRepo()
seeded := invites.seed(pendingInvite("a@renew.example"))
mail.fail.failNextWith("Send", errors.New("smtp down"))
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL)
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || errors.Is(err, errRepositoryFailure) || invites.invites[1] != seeded {
t.Errorf("unexpected result %v", err)
@@ -495,9 +495,9 @@ func TestManageInviteUseCases(t *testing.T) {
mail := newMockMailRepo()
invites.seed(pendingInvite("a@renew.example"))
mail.fail.failNextWith("Send", errors.New("smtp down"))
renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, &stubHasher{}, mail, invitePublicURL)
renew := useCases.NewRenewInviteUseCase(users, &renewRestoreFailRepo{inviteFakeRepo: invites}, &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
if !errors.Is(err, invite.ErrInviteEmailNotSent) || !errors.Is(err, errRepositoryFailure) {
t.Errorf("unexpected result %v", err)
@@ -511,15 +511,15 @@ func TestManageInviteUseCases(t *testing.T) {
at := time.Now()
accepted.AcceptedAt = &at
invites.seed(accepted)
cancel := useCases.NewCancelInviteUseCase(invites)
cancel := useCases.NewCancelInviteUseCase(invites, discardRecorder)
if err := cancel.CancelInvite(9); !errors.Is(err, invite.ErrInviteNotFound) {
if err := cancel.CancelInvite(testCtx, 9); !errors.Is(err, invite.ErrInviteNotFound) {
t.Errorf("expected not found, got %v", err)
}
if err := cancel.CancelInvite(2); !errors.Is(err, invite.ErrInviteAlreadyAccepted) {
if err := cancel.CancelInvite(testCtx, 2); !errors.Is(err, invite.ErrInviteAlreadyAccepted) {
t.Errorf("expected already accepted, got %v", err)
}
if err := cancel.CancelInvite(1); err != nil {
if err := cancel.CancelInvite(testCtx, 1); err != nil {
t.Errorf("unexpected %v", err)
}
})
@@ -530,9 +530,9 @@ func TestManageInviteUseCases(t *testing.T) {
mail := newMockMailRepo()
seeded := invites.seed(pendingInvite("a@renew.example"))
invites.fail.failNextWith("Renew", errRepositoryFailure)
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL)
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
_, err := renew.RenewInvite(useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
_, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7})
if !errors.Is(err, errRepositoryFailure) || len(mail.SentEmails) != 0 || invites.invites[1] != seeded {
t.Errorf("unexpected result %v", err)
@@ -544,9 +544,9 @@ func TestManageInviteUseCases(t *testing.T) {
invites := newInviteFakeRepo()
mail := newMockMailRepo()
invites.seed(pendingInvite("a@renew.example"))
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL)
renew := useCases.NewRenewInviteUseCase(users, invites, &stubHasher{}, mail, invitePublicURL, discardRecorder)
if _, err := renew.RenewInvite(useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}); err != nil {
if _, err := renew.RenewInvite(testCtx, useCases.RenewInviteCommand{ID: 1, RenewedBy: 7}); err != nil {
t.Fatalf("unexpected %v", err)
}
if inviteMailData(t, mail).InvitedBy != "Marta L." {
@@ -36,7 +36,7 @@ func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *
token := jwtauth.New("HS256", []byte("test-secret"), nil)
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
+1 -1
View File
@@ -19,7 +19,7 @@ func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
+2 -2
View File
@@ -60,9 +60,9 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env.seedArticle(id, "Article", true, env.urlOf(3))
}
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage)
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, discardRecorder)
if _, err := uc.Merge(2, []uint32{3}); err != nil {
if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
+8 -8
View File
@@ -60,26 +60,26 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, discardRecorder),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0)),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
+14 -14
View File
@@ -947,9 +947,9 @@ func TestMediaMerging(t *testing.T) {
t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
env := newEnv(t)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
_, err := uc.Merge(2, []uint32{3, 3})
_, err := uc.Merge(testCtx, 2, []uint32{3, 3})
if !errors.Is(err, domainImage.ErrInvalidMergeRequest) {
t.Errorf("expected ErrInvalidMergeRequest, got %v", err)
@@ -996,10 +996,10 @@ func TestMediaMerging(t *testing.T) {
func TestMediaSentinels(t *testing.T) {
t.Run("Create reports unreadable bytes as ErrImageUnreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage())
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder)
for _, contentType := range domainImage.AllowedContentTypes() {
_, err := uc.CreateImage(inputOf([]byte("not an image"), contentType))
_, err := uc.CreateImage(testCtx, inputOf([]byte("not an image"), contentType))
if !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("%s: expected ErrImageUnreadable, got %v", contentType, err)
}
@@ -1007,9 +1007,9 @@ func TestMediaSentinels(t *testing.T) {
})
t.Run("An svg without an svg root is unreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage())
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder)
_, err := uc.CreateImage(inputOf([]byte("<html/>"), "image/svg+xml"))
_, err := uc.CreateImage(testCtx, inputOf([]byte("<html/>"), "image/svg+xml"))
if !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
@@ -1017,9 +1017,9 @@ func TestMediaSentinels(t *testing.T) {
})
t.Run("Bytes of another raster format than the declared type are unreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage())
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder)
_, err := uc.CreateImage(inputOf(jpegBytes(t, 3, 2), "image/png"))
_, err := uc.CreateImage(testCtx, inputOf(jpegBytes(t, 3, 2), "image/png"))
if !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
@@ -1029,9 +1029,9 @@ func TestMediaSentinels(t *testing.T) {
t.Run("Rename reports an invalid stem as ErrInvalidFilename", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = domainImage.Image{ID: 1, Filename: "a.png"}
uc := useCases.NewRenameMediaUseCase(repo)
uc := useCases.NewRenameMediaUseCase(repo, discardRecorder)
if _, err := uc.Rename(1, "a b"); !errors.Is(err, domainImage.ErrInvalidFilename) {
if _, err := uc.Rename(testCtx, 1, "a b"); !errors.Is(err, domainImage.ErrInvalidFilename) {
t.Errorf("expected ErrInvalidFilename, got %v", err)
}
})
@@ -1042,15 +1042,15 @@ func TestMediaSentinels(t *testing.T) {
env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
if _, err := uc.Merge(1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
}
if _, err := uc.Merge(1, []uint32{2}); !errors.Is(err, domainImage.ErrMergeHashMismatch) {
if _, err := uc.Merge(testCtx, 1, []uint32{2}); !errors.Is(err, domainImage.ErrMergeHashMismatch) {
t.Errorf("expected ErrMergeHashMismatch, got %v", err)
}
if _, err := uc.Merge(3, []uint32{4}); !errors.Is(err, domainImage.ErrMergeHashMismatch) {
if _, err := uc.Merge(testCtx, 3, []uint32{4}); !errors.Is(err, domainImage.ErrMergeHashMismatch) {
t.Errorf("expected images without a hash never to merge, got %v", err)
}
})
+5 -5
View File
@@ -31,13 +31,13 @@ type schedulerEnv struct {
func newSchedulerEnv(t *testing.T) *schedulerEnv {
t.Helper()
env := newPurgeEnv(t)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
now := func() time.Time { return env.clock }
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0))
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder)
return &schedulerEnv{
mediaEnv: env,
runPurge: runPurge,
scheduler: useCases.NewPurgeSchedulerUseCase(env.purgeSettings, runPurge),
scheduler: useCases.NewPurgeSchedulerUseCase(env.purgeSettings, runPurge, discardRecorder),
update: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
}
}
@@ -326,7 +326,7 @@ func TestPurgeRunKeepsSettingsChangedWhileItRuns(t *testing.T) {
}
runDone := make(chan error, 1)
go func() {
_, err := env.runPurge.Run(nil)
_, err := env.runPurge.Run(testCtx, nil)
runDone <- err
}()
waitFor(t, writing)
@@ -430,7 +430,7 @@ func TestPurgeSchedulerFailures(t *testing.T) {
}
manualDone := make(chan error, 1)
go func() {
_, err := env.runPurge.Run(nil)
_, err := env.runPurge.Run(testCtx, nil)
manualDone <- err
}()
waitFor(t, entered)
+10 -10
View File
@@ -32,8 +32,8 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0))
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder)
}
t.Run("a non-matching fingerprint is a conflict and writes nothing", func(t *testing.T) {
@@ -42,7 +42,7 @@ func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
env.useImages(10, 1)
wrong := "wrong"
_, err := newRun(env).Run(&wrong)
_, err := newRun(env).Run(testCtx, &wrong)
if !errors.Is(err, purge.ErrPurgeConflict) || errors.Is(err, purge.ErrPurgeBusy) {
t.Errorf("expected only ErrPurgeConflict, got %v", err)
@@ -68,13 +68,13 @@ func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
run := newRun(env)
done := make(chan error, 1)
go func() {
_, err := run.Run(nil)
_, err := run.Run(testCtx, nil)
done <- err
}()
<-entered
savesBefore := env.purgeSettings.saveCalls
_, err := run.Run(nil)
_, err := run.Run(testCtx, nil)
if !errors.Is(err, purge.ErrPurgeBusy) || errors.Is(err, purge.ErrPurgeConflict) {
t.Errorf("expected only ErrPurgeBusy, got %v", err)
@@ -93,7 +93,7 @@ func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
env.seedGroup(hashA, 1, 2)
env.useImages(10, 1)
result, err := newRun(env).Run(nil)
result, err := newRun(env).Run(testCtx, nil)
if err != nil || result != (purge.RunResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1}) {
t.Errorf("unexpected result %+v, %v", result, err)
@@ -148,9 +148,9 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 1, 2)
env.useImages(3, 1)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
result, err := merge.Merge(1, []uint32{2})
result, err := merge.Merge(testCtx, 1, []uint32{2})
if err != nil || result.Merged != 1 || result.ArticlesUpdated != 2 || !reflect.DeepEqual(result.UpdatedArticleIDs, []uint32{1, 2}) {
t.Errorf("unexpected result %+v, %v", result, err)
@@ -163,9 +163,9 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 2)
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
result, err := merge.Merge(1, []uint32{2})
result, err := merge.Merge(testCtx, 1, []uint32{2})
if err == nil || result.Merged != 0 || result.ArticlesUpdated != 0 || len(result.UpdatedArticleIDs) != 0 {
t.Errorf("unexpected result %+v, %v", result, err)
+11 -11
View File
@@ -24,7 +24,7 @@ func pngUpload(t *testing.T) domainImage.ImageInput {
func uploadPng(t *testing.T, uc *useCases.CreateImageUseCase) domainImage.Image {
t.Helper()
created, err := uc.CreateImage(pngUpload(t))
created, err := uc.CreateImage(testCtx, pngUpload(t))
if err != nil {
t.Fatalf("CreateImage failed: %v", err)
}
@@ -35,7 +35,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
t.Run("The row points at the stored name and the blob holds the bytes", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store))
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
if !strings.HasPrefix(created.Path, domainImage.PublicPathPrefix) {
t.Errorf("unexpected path %q", created.Path)
@@ -49,7 +49,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
store.failPut = errRepositoryFailure
_, err := useCases.NewCreateImageUseCase(repo, store).CreateImage(pngUpload(t))
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder).CreateImage(testCtx, pngUpload(t))
if !errors.Is(err, errRepositoryFailure) || repo.createCalls != 0 {
t.Errorf("got %v with %d Create calls", err, repo.createCalls)
@@ -60,7 +60,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
repo.fail.failNextWith("Create", errRepositoryFailure)
_, err := useCases.NewCreateImageUseCase(repo, store).CreateImage(pngUpload(t))
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder).CreateImage(testCtx, pngUpload(t))
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the repository failure, got %v", err)
@@ -74,9 +74,9 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("Both disappear", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store))
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
if err := useCases.NewDeleteMediaUseCase(repo, store).Delete(created.ID); err != nil {
if err := useCases.NewDeleteMediaUseCase(repo, store, discardRecorder).Delete(testCtx, created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if len(store.blobs) != 0 || len(repo.images) != 0 {
@@ -87,7 +87,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
err := useCases.NewDeleteMediaUseCase(repo, store).Delete(7)
err := useCases.NewDeleteMediaUseCase(repo, store, discardRecorder).Delete(testCtx, 7)
if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
t.Errorf("got %v, deleted %v", err, store.deleted)
@@ -96,10 +96,10 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("A blob failure keeps the row so the delete can be retried", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store))
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
store.failDelete = errRepositoryFailure
err := useCases.NewDeleteMediaUseCase(repo, store).Delete(created.ID)
err := useCases.NewDeleteMediaUseCase(repo, store, discardRecorder).Delete(testCtx, created.ID)
if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 {
t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls)
@@ -109,10 +109,10 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
repo, articles, store := newMockImageRepo(), newMockArticleRepo(), newMockImageStorage()
create := useCases.NewCreateImageUseCase(repo, store)
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
result, err := useCases.NewMergeMediaUseCase(repo, articles, store).Merge(kept.ID, []uint32{copyOne.ID, copyTwo.ID})
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, discardRecorder).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
if err != nil || result.Merged != 2 {
t.Fatalf("merge returned %+v, %v", result, err)
+4 -4
View File
@@ -126,17 +126,17 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)
hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
+3 -3
View File
@@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)
+3 -3
View File
@@ -24,11 +24,11 @@ import (
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)
@@ -42,7 +42,7 @@ func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, p
hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, publicURL),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, publicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
@@ -218,9 +218,9 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
userRepo.nextID = 1
userRepo.fail.failNext("Create")
mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(useCases.CreateUserCommand{
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "bob",
Password: "hunter2example12",
Email: "bob@example.com",
@@ -240,9 +240,9 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
userRepo.users[2] = user.User{ID: 2, Username: "bob", Email: "bob@example.com", IsVerified: false}
userRepo.nextID = 2
mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
result, err := uc.CreateUser(useCases.CreateUserCommand{
result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "carol",
Password: "hunter2example12",
Email: "carol@example.com",
@@ -268,9 +268,9 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
t.Run("A failure hashing the verification code persists nothing", func(t *testing.T) {
userRepo := newMockUserRepo()
mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, &sequentialHasher{}, mailRepo, "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(userRepo, &sequentialHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(useCases.CreateUserCommand{
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice",
Password: "hunter2example12",
Email: "alice@example.com",
@@ -291,9 +291,9 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
userRepo := newMockUserRepo()
mailRepo := newMockMailRepo()
mailRepo.fail.failNext("Send")
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(useCases.CreateUserCommand{
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice",
Password: "hunter2example12",
Email: "alice@example.com",
@@ -312,9 +312,9 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
mailRepo := newMockMailRepo()
mailRepo.fail.failNext("Send")
userRepo.fail.failNext("Delete")
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(useCases.CreateUserCommand{
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice",
Password: "hunter2example12",
Email: "alice@example.com",
@@ -18,7 +18,7 @@ import (
func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
hasher := &stubHasher{}
createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com")
createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder)
loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true)
r := chi.NewRouter()
@@ -162,13 +162,13 @@ func TestRegistrationStatusEndpointHttp(t *testing.T) {
userRepo.users[1] = user.User{ID: 1, Username: "alice", Email: "alice@example.com", IsVerified: true}
userRepo.nextID = 1
mailRepo := newMockMailRepo()
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(userRepo, &stubHasher{}, mailRepo, "https://cms.example.com", discardRecorder)
if uc.IsRegistrationOpen() {
t.Fatalf("expected IsRegistrationOpen to return false")
}
_, err := uc.CreateUser(useCases.CreateUserCommand{
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "bob",
Password: "hunter2example12",
Email: "bob@example.com",
+9 -9
View File
@@ -35,7 +35,7 @@ func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mock
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
isProduction,
)
@@ -129,22 +129,22 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
return &api.UseCases{
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage()),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), discardRecorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), discardRecorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
}
}
+5 -5
View File
@@ -23,21 +23,21 @@ import (
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
useCases.NewCancelRegistrationUseCase(userRepo),
true,
)
@@ -23,7 +23,7 @@ func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo
token := jwtauth.New("HS256", []byte("test-secret"), nil)
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
@@ -155,7 +155,7 @@ func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *jwtauth.JWTA
hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
+3 -3
View File
@@ -39,10 +39,10 @@ func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: rig.status,
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
StartUpdateDownloadUseCase: rig.start,
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: rig.store}
return &updateHarness{router: api.NewSystemRouter(container, nil), token: token, feed: feed, store: rig.store}
}
func waitForHTTPState(t *testing.T, h *updateHarness, states ...string) map[string]any {
@@ -597,7 +597,7 @@ func TestFailedUpdateJobRemovesItsStagedDirectoryEvenWhenRemovalFails(t *testing
rig.fetcher.errors[rig.assetName] = errDownloadNetwork
rig.staging.removeErr = errDownloadDisk
if _, err := rig.start.Start(); err != nil {
if _, err := rig.start.Start(testCtx); err != nil {
t.Fatal(err)
}
job := rig.waitForJob(update.StateFailed)
+22 -12
View File
@@ -1,6 +1,7 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
@@ -313,6 +314,7 @@ type downloadRig struct {
staging *faultyStagingArea
realBackup update.DatabaseBackup
logger *fakeUpdateLogger
recorder audit.Recorder
start *useCases.StartUpdateDownloadUseCase
status *useCases.GetUpdateStatusUseCase
}
@@ -385,10 +387,18 @@ func (r *downloadRig) rebuild() {
Platform: r.platform,
FeedURL: downloadFeedURL,
Now: func() time.Time { return updateTestCheckedAt },
Recorder: r.auditRecorder(),
})
r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, false, time.Now)
}
func (r *downloadRig) auditRecorder() audit.Recorder {
if r.recorder == nil {
return discardRecorder
}
return r.recorder
}
func (r *downloadRig) checksumsFor(name string, content []byte) string {
sum := sha256.Sum256(content)
return hex.EncodeToString(sum[:]) + " " + name + "\n"
@@ -484,7 +494,7 @@ func TestAssetNameFollowsThePlatform(t *testing.T) {
}
}
if _, err := rig.start.Start(); err != nil {
if _, err := rig.start.Start(testCtx); err != nil {
t.Fatal(err)
}
rig.waitForJob(update.StateReady, update.StateFailed)
@@ -543,7 +553,7 @@ func TestStartUpdateDownloadUseCaseHappyPath(t *testing.T) {
rig.backup.gate = gate
rig.backup.entered = entered
status, err := rig.start.Start()
status, err := rig.start.Start(testCtx)
if err != nil || status.State != update.StateDownloading || status.StartedAt == nil {
t.Fatalf("status = %+v, err = %v", status, err)
@@ -582,7 +592,7 @@ func TestStartUpdateDownloadUseCaseGuards(t *testing.T) {
t.Run("without the helper nothing starts", func(t *testing.T) {
rig := newDownloadRig(t)
rig.helper.installed = false
if _, err := rig.start.Start(); !errors.Is(err, update.ErrNotPrepared) {
if _, err := rig.start.Start(testCtx); !errors.Is(err, update.ErrNotPrepared) {
t.Errorf("err = %v", err)
}
if rig.fetcher.totalCalls() != 0 {
@@ -594,7 +604,7 @@ func TestStartUpdateDownloadUseCaseGuards(t *testing.T) {
rig := newDownloadRig(t)
rig.helper.installed = false
rig.store.found = false
if _, err := rig.start.Start(); !errors.Is(err, update.ErrNotPrepared) {
if _, err := rig.start.Start(testCtx); !errors.Is(err, update.ErrNotPrepared) {
t.Errorf("err = %v", err)
}
})
@@ -603,7 +613,7 @@ func TestStartUpdateDownloadUseCaseGuards(t *testing.T) {
rig := newDownloadRig(t)
rig.jobs.put(update.Job{State: update.StateReady})
rig.store.found = false
if _, err := rig.start.Start(); !errors.Is(err, update.ErrJobInProgress) {
if _, err := rig.start.Start(testCtx); !errors.Is(err, update.ErrJobInProgress) {
t.Errorf("err = %v", err)
}
})
@@ -611,7 +621,7 @@ func TestStartUpdateDownloadUseCaseGuards(t *testing.T) {
t.Run("nothing stored", func(t *testing.T) {
rig := newDownloadRig(t)
rig.store.found = false
if _, err := rig.start.Start(); !errors.Is(err, update.ErrNothingToUpdate) {
if _, err := rig.start.Start(testCtx); !errors.Is(err, update.ErrNothingToUpdate) {
t.Errorf("err = %v", err)
}
})
@@ -619,7 +629,7 @@ func TestStartUpdateDownloadUseCaseGuards(t *testing.T) {
t.Run("a job record that cannot be saved is an error", func(t *testing.T) {
rig := newDownloadRig(t)
rig.jobs.saveErr = errRepositoryFailure
if _, err := rig.start.Start(); !errors.Is(err, errRepositoryFailure) {
if _, err := rig.start.Start(testCtx); !errors.Is(err, errRepositoryFailure) {
t.Errorf("err = %v", err)
}
if rig.fetcher.totalCalls() != 0 {
@@ -643,7 +653,7 @@ func TestTwoStartsAtOnceRunOneJob(t *testing.T) {
go func() {
ready.Done()
ready.Wait()
_, err := rig.start.Start()
_, err := rig.start.Start(testCtx)
results <- err
}()
}
@@ -922,7 +932,7 @@ func TestStartUpdateDownloadUseCaseProgressCadence(t *testing.T) {
}
}
if _, err := rig.start.Start(); err != nil {
if _, err := rig.start.Start(testCtx); err != nil {
t.Fatal(err)
}
rig.waitForJob(update.StateReady, update.StateFailed)
@@ -938,7 +948,7 @@ func TestStartUpdateDownloadUseCaseAcceptsAnUndeclaredSize(t *testing.T) {
rig := newDownloadRig(t)
rig.setAsset(rig.assetName, func(asset *update.Asset) { asset.Size = 0 })
if _, err := rig.start.Start(); err != nil {
if _, err := rig.start.Start(testCtx); err != nil {
t.Fatal(err)
}
@@ -961,7 +971,7 @@ func TestStartUpdateDownloadUseCaseReportsAFailedRecordSave(t *testing.T) {
}
}
if _, err := rig.start.Start(); err != nil {
if _, err := rig.start.Start(testCtx); err != nil {
t.Fatal(err)
}
@@ -978,7 +988,7 @@ func TestStartUpdateDownloadUseCaseReportsAFailedRecordSave(t *testing.T) {
rig := newDownloadRig(t)
rig.fetcher.errors[rig.assetName] = errDownloadNetwork
if _, err := rig.start.Start(); err != nil {
if _, err := rig.start.Start(testCtx); err != nil {
t.Fatal(err)
}
rig.waitForJob(update.StateFailed)
+2 -2
View File
@@ -64,9 +64,9 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now, discardRecorder),
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: store}
return &updateHarness{router: api.NewSystemRouter(container, nil), token: token, feed: feed, store: store}
}
func (h *updateHarness) send(t *testing.T, method, path string, userId uint32, tokenVersion uint32) *httptest.ResponseRecorder {
+2 -2
View File
@@ -61,9 +61,9 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now),
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now, discardRecorder),
}
h.router = api.NewSystemRouter(container)
h.router = api.NewSystemRouter(container, nil)
return h
}
+8 -8
View File
@@ -31,7 +31,7 @@ func newRestartRig(running string) *restartRig {
requests: &fakeRestartRequestStore{},
clock: &fakeClock{now: restartBaseTime},
}
rig.request = useCases.NewRequestRestartUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now)
rig.request = useCases.NewRequestRestartUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now, discardRecorder)
rig.status = useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, false, rig.clock.Now)
return rig
}
@@ -50,7 +50,7 @@ func TestRequestRestartUseCase(t *testing.T) {
rig.readyJob()
rig.results.put(update.Result{Outcome: update.OutcomeRolledBack})
status, err := rig.request.Request(updateOwnerId, "")
status, err := rig.request.Request(testCtx, updateOwnerId, "")
if err != nil {
t.Fatal(err)
@@ -81,7 +81,7 @@ func TestRequestRestartUseCase(t *testing.T) {
rig := newRestartRig(updateRunning)
prepare(rig)
status, err := rig.request.Request(updateOwnerId, update.RestartKindRestart)
status, err := rig.request.Request(testCtx, updateOwnerId, update.RestartKindRestart)
marker, _ := rig.requests.Read()
if err != nil || marker.Kind != update.RestartKindRestart || marker.Tag != "" || status.State != update.StateRestarting {
@@ -107,7 +107,7 @@ func TestRequestRestartUseCase(t *testing.T) {
tc.prepare(rig)
jobBefore, _ := rig.jobs.Load()
_, err := rig.request.Request(updateOwnerId, tc.kind)
_, err := rig.request.Request(testCtx, updateOwnerId, tc.kind)
if !errors.Is(err, update.ErrRestartKindMismatch) {
t.Fatalf("err = %v", err)
@@ -140,7 +140,7 @@ func TestRequestRestartUseCase(t *testing.T) {
resultBefore, foundBefore := rig.results.Read()
writesBefore := rig.requests.writes
_, err := rig.request.Request(updateOwnerId, "")
_, err := rig.request.Request(testCtx, updateOwnerId, "")
resultAfter, foundAfter := rig.results.Read()
if !errors.Is(err, tc.want) || rig.requests.writes != writesBefore || resultAfter != resultBefore || foundAfter != foundBefore || rig.results.deletes != 0 {
@@ -154,7 +154,7 @@ func TestRequestRestartUseCase(t *testing.T) {
rig := newRestartRig(updateRunning)
rig.results.put(update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-6 * time.Minute)})
_, err := rig.request.Request(updateOwnerId, "")
_, err := rig.request.Request(testCtx, updateOwnerId, "")
if err != nil || rig.requests.writes != 1 {
t.Errorf("err = %v, writes = %d", err, rig.requests.writes)
@@ -167,7 +167,7 @@ func TestRequestRestartUseCase(t *testing.T) {
rig.requests.writeErr = errors.New("disk full at /var/lib/renewcms")
jobBefore, _ := rig.jobs.Load()
_, err := rig.request.Request(updateOwnerId, "")
_, err := rig.request.Request(testCtx, updateOwnerId, "")
jobAfter, _ := rig.jobs.Load()
if err == nil || errors.Is(err, update.ErrRestartKindMismatch) || rig.requests.present || jobAfter != jobBefore {
@@ -183,7 +183,7 @@ func TestRequestRestartUseCase(t *testing.T) {
wait.Add(1)
go func() {
defer wait.Done()
_, errs[i] = rig.request.Request(uint32(i+1), "")
_, errs[i] = rig.request.Request(testCtx, uint32(i+1), "")
}()
}
wait.Wait()
+4 -4
View File
@@ -199,7 +199,7 @@ func TestCheckForUpdateUseCase(t *testing.T) {
store := &fakeUpdateStore{}
feed := &fakeReleaseFeed{release: newRelease}
status, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check()
status, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now, discardRecorder).Check(testCtx)
if err != nil || !status.Available || status.Latest != "v2.3.0-beta" {
t.Fatalf("status = %+v, err = %v", status, err)
@@ -211,7 +211,7 @@ func TestCheckForUpdateUseCase(t *testing.T) {
t.Run("nothing newer", func(t *testing.T) {
feed := &fakeReleaseFeed{release: update.Release{Tag: "v2.2.0-beta"}}
status, err := useCases.NewCheckForUpdateUseCase(feed, &fakeUpdateStore{}, "v2.2.0-beta", now).Check()
status, err := useCases.NewCheckForUpdateUseCase(feed, &fakeUpdateStore{}, "v2.2.0-beta", now, discardRecorder).Check(testCtx)
if err != nil || status.Available {
t.Errorf("status = %+v, err = %v", status, err)
}
@@ -221,7 +221,7 @@ func TestCheckForUpdateUseCase(t *testing.T) {
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
feed := &fakeReleaseFeed{err: update.ErrFeedUnavailable}
_, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check()
_, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now, discardRecorder).Check(testCtx)
if !errors.Is(err, update.ErrFeedUnavailable) {
t.Errorf("err = %v", err)
@@ -234,7 +234,7 @@ func TestCheckForUpdateUseCase(t *testing.T) {
t.Run("store failure is returned", func(t *testing.T) {
store := &fakeUpdateStore{saveErr: errRepositoryFailure}
feed := &fakeReleaseFeed{release: newRelease}
if _, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check(); !errors.Is(err, errRepositoryFailure) {
if _, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now, discardRecorder).Check(testCtx); !errors.Is(err, errRepositoryFailure) {
t.Errorf("err = %v", err)
}
})
+31 -31
View File
@@ -29,10 +29,10 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
for _, tc := range cases {
t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
if tc.expectValid {
if err != nil {
@@ -62,9 +62,9 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{})
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
_, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
@@ -242,8 +242,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateBody(1, "New body", article.BodyFormatHTML)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
result, err := uc.UpdateBody(testCtx, 1, "New body", article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -254,8 +254,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateBody(9, "New body", article.BodyFormatHTML)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
_, err := uc.UpdateBody(testCtx, 9, "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -263,8 +263,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.UpdateIsOnline(1, true, article.BodyFormatHTML)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
result, err := uc.UpdateIsOnline(testCtx, 1, true, article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -275,8 +275,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.UpdateIsOnline(9, true, article.BodyFormatHTML)
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder)
_, err := uc.UpdateIsOnline(testCtx, 9, true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -287,9 +287,9 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo)
uc := useCases.NewDeleteArticleUseCase(repo, discardRecorder)
err := uc.DeleteArticle(1)
err := uc.DeleteArticle(testCtx, 1)
if err != nil {
t.Errorf("expected no error, got %v", err)
}
@@ -298,9 +298,9 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
t.Run("id_9", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo)
uc := useCases.NewDeleteArticleUseCase(repo, discardRecorder)
err := uc.DeleteArticle(9)
err := uc.DeleteArticle(testCtx, 9)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -311,9 +311,9 @@ func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1}
repo.fail.failNext("Delete")
uc := useCases.NewDeleteArticleUseCase(repo)
uc := useCases.NewDeleteArticleUseCase(repo, discardRecorder)
err := uc.DeleteArticle(1)
err := uc.DeleteArticle(testCtx, 1)
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
@@ -334,13 +334,13 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
for _, tc := range cases {
t.Run(strings.ReplaceAll(tc.contentType, "/", "_"), func(t *testing.T) {
repo := newMockImageRepo()
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
content := []byte("fake-image-data")
if tc.valid {
content = validBytesFor(t, tc.contentType)
}
result, err := uc.CreateImage(imageDomain.ImageInput{
result, err := uc.CreateImage(testCtx, imageDomain.ImageInput{
Content: bytes.NewReader(content),
Filename: "test.img",
ContentType: tc.contentType,
@@ -368,9 +368,9 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
func TestCreateImagePropagatesRepositoryFailure(t *testing.T) {
repo := newMockImageRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
_, err := uc.CreateImage(imageDomain.ImageInput{
_, err := uc.CreateImage(testCtx, imageDomain.ImageInput{
Content: bytes.NewReader(pngBytes(t, 3, 2)),
Filename: "test.png",
ContentType: "image/png",
@@ -385,9 +385,9 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage())
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), discardRecorder)
err := uc.Delete(1)
err := uc.Delete(testCtx, 1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -399,9 +399,9 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage())
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), discardRecorder)
err := uc.Delete(9)
err := uc.Delete(testCtx, 9)
if !errors.Is(err, imageDomain.ErrImageNotFound) {
t.Errorf("expected image.ErrImageNotFound, got %v", err)
}
@@ -410,10 +410,10 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
func TestCreateUserAssignsVerificationCode(t *testing.T) {
repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
before := time.Now()
result, err := uc.CreateUser(useCases.CreateUserCommand{
result, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice",
Password: "hunter2example",
Email: "alice@example.com",
@@ -441,7 +441,7 @@ func TestCreateUserAssignsVerificationCode(t *testing.T) {
t.Errorf("expected verification_expiration about 2 hours from now, got %v", result.VerificationExpiration)
}
second, err := uc.CreateUser(useCases.CreateUserCommand{
second, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "bob",
Password: "hunter2example",
Email: "bob@example.com",
@@ -457,9 +457,9 @@ func TestCreateUserAssignsVerificationCode(t *testing.T) {
func TestCreateUserPropagatesRepositoryFailure(t *testing.T) {
repo := newMockUserRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
_, err := uc.CreateUser(useCases.CreateUserCommand{
_, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{
Username: "alice",
Password: "hunter2example",
Email: "alice@example.com",
+6 -6
View File
@@ -25,12 +25,12 @@ var displayNameResponseKeys = []string{"id", "username", "email", "is_verified",
func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
hasher := &stubHasher{}
getUser := useCases.NewGetUserUseCase(userRepo)
update := useCases.NewUpdateUserProfileUseCase(userRepo)
update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder)
handler := userhandler.NewHandler(update)
meHandler := auth.NewMeHandler(getUser)
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
@@ -149,7 +149,7 @@ func TestUpdateUserProfileUseCase(t *testing.T) {
for _, tc := range successCases {
t.Run(tc.name, func(t *testing.T) {
repo := newRepo()
got, err := useCases.NewUpdateUserProfileUseCase(repo).UpdateDisplayName(7, tc.input)
got, err := useCases.NewUpdateUserProfileUseCase(repo, discardRecorder).UpdateDisplayName(testCtx, 7, tc.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -161,7 +161,7 @@ func TestUpdateUserProfileUseCase(t *testing.T) {
t.Run("61 emoji runes are refused without calling the repository", func(t *testing.T) {
repo := newRepo()
_, err := useCases.NewUpdateUserProfileUseCase(repo).UpdateDisplayName(7, strings.Repeat("😀", 61))
_, err := useCases.NewUpdateUserProfileUseCase(repo, discardRecorder).UpdateDisplayName(testCtx, 7, strings.Repeat("😀", 61))
if !errors.Is(err, user.ErrInvalidDisplayName) {
t.Fatalf("expected ErrInvalidDisplayName, got %v", err)
}
@@ -171,7 +171,7 @@ func TestUpdateUserProfileUseCase(t *testing.T) {
})
t.Run("unknown user", func(t *testing.T) {
_, err := useCases.NewUpdateUserProfileUseCase(newRepo()).UpdateDisplayName(99, "Marta")
_, err := useCases.NewUpdateUserProfileUseCase(newRepo(), discardRecorder).UpdateDisplayName(testCtx, 99, "Marta")
if !errors.Is(err, user.ErrUserNotFound) {
t.Fatalf("expected ErrUserNotFound, got %v", err)
}
@@ -180,7 +180,7 @@ func TestUpdateUserProfileUseCase(t *testing.T) {
t.Run("repository failure is returned unchanged", func(t *testing.T) {
repo := newRepo()
repo.fail.failNext("UpdateDisplayName")
_, err := useCases.NewUpdateUserProfileUseCase(repo).UpdateDisplayName(7, "Marta")
_, err := useCases.NewUpdateUserProfileUseCase(repo, discardRecorder).UpdateDisplayName(testCtx, 7, "Marta")
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected repository failure, got %v", err)
}
+2 -2
View File
@@ -21,12 +21,12 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"),
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
true,
)
verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher),
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
useCases.NewCancelRegistrationUseCase(userRepo),
true,
)
+7 -7
View File
@@ -50,19 +50,19 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
getUser := useCases.NewGetUserUseCase(f.users)
loginHandler := auth.NewLoginHandler(
f.token,
useCases.NewCreateUserUseCase(f.users, hasher, f.mail, invitePublicURL),
useCases.NewCreateUserUseCase(f.users, hasher, f.mail, invitePublicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(f.users, hasher, discardRecorder),
true,
)
acceptHandler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(f.invites, hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, hasher),
useCases.NewAcceptInviteUseCase(f.users, f.invites, hasher, discardRecorder),
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser)
inviteHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL),
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL, discardRecorder),
nil,
nil,
useCases.NewListUsersAndInvitesUseCase(f.users, f.invites),
@@ -352,9 +352,9 @@ func TestUserRolesHttp(t *testing.T) {
func TestUserRolesUseCases(t *testing.T) {
t.Run("CreateUser returns an owner", func(t *testing.T) {
users := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, newMockMailRepo(), invitePublicURL)
uc := useCases.NewCreateUserUseCase(users, &stubHasher{}, newMockMailRepo(), invitePublicURL, discardRecorder)
created, err := uc.CreateUser(useCases.CreateUserCommand{Username: "marta", Password: "hunter2example12", Email: "marta@renew.example"})
created, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "marta", Password: "hunter2example12", Email: "marta@renew.example"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -371,9 +371,9 @@ func TestUserRolesUseCases(t *testing.T) {
pending := pendingInvite(acceptEmail)
pending.HashedCode = "hashed:" + acceptCode
invites.seed(pending)
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{})
uc := useCases.NewAcceptInviteUseCase(users, invites, &stubHasher{}, discardRecorder)
created, err := uc.AcceptInvite(useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword})
created, err := uc.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: acceptCode, Username: "lea", Password: acceptPassword})
if err != nil {
t.Fatalf("unexpected error: %v", err)
+2 -2
View File
@@ -231,7 +231,7 @@ var _ user.Repository = &mockUserRepo{}
func TestCreateUser(t *testing.T) {
repo := newMockUserRepo()
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com")
uc := useCases.NewCreateUserUseCase(repo, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder)
cmd := useCases.CreateUserCommand{
Username: "testuser",
@@ -239,7 +239,7 @@ func TestCreateUser(t *testing.T) {
Email: "test@example.com",
}
res, err := uc.CreateUser(cmd)
res, err := uc.CreateUser(testCtx, cmd)
if err != nil {
t.Fatalf("User creation failed: %v", err)
}
+3 -3
View File
@@ -21,11 +21,11 @@ import (
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)
+3 -3
View File
@@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
getUserUseCase,
)