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) }) } }) }