771 lines
30 KiB
Go
771 lines
30 KiB
Go
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, newMockVariantFileRepo(), 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)
|
|
})
|
|
}
|
|
})
|
|
}
|