package console import ( "context" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/objstore" ) func storeNotifyFixture(t *testing.T) *alertFixture { t.Helper() return newCapturedFixture(t) } func TestStoreApplicationNotifiesCanonicalConfiguredFleetOperators(t *testing.T) { fx := storeNotifyFixture(t) fx.srv.cfg.StoreOperators = []string{" OPS@Keymail.dev ", "ops@keymail.dev", "listed-only@keymail.dev"} for _, address := range []string{"ops@keymail.dev", "fleet-only@keymail.dev"} { if err := fx.st.SetOperator(address, true); err != nil { t.Fatal(err) } if err := fx.st.SetGrantToken(address, "grant-"+address, time.Now()); err != nil { t.Fatal(err) } } fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) if len(fx.sent) != 1 { t.Fatalf("sent %d messages, want one canonical, deduplicated intersection: %+v", len(fx.sent), fx.sent) } if got := fx.sent[0]; got.to != "ops@keymail.dev" || got.token != "grant-ops@keymail.dev" { t.Fatalf("sent to %q with %q, want the configured fleet operator's own token", got.to, got.token) } } func TestStoreApplicationSkipsAnOperatorWithoutAGrant(t *testing.T) { fx := storeNotifyFixture(t) fx.srv.cfg.StoreOperators = []string{"ready@keymail.dev", "missing@keymail.dev"} for _, address := range fx.srv.cfg.StoreOperators { if err := fx.st.SetOperator(address, true); err != nil { t.Fatal(err) } } if err := fx.st.SetGrantToken("ready@keymail.dev", "ready-token", time.Now()); err != nil { t.Fatal(err) } fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) if len(fx.sent) != 1 || fx.sent[0].to != "ready@keymail.dev" { t.Fatalf("sent %+v, want only the operator with a grant", fx.sent) } } func TestStoreDecisionTellsApplicantWithoutLeakingOperatorFields(t *testing.T) { fx := storeNotifyFixture(t) if err := fx.st.SetGrantToken("owner@example.test", "owner-token", time.Now()); err != nil { t.Fatal(err) } e := &objstore.Entitlement{ Applied: &objstore.Applied{By: "owner@example.test", Note: "secret applicant note"}, Decision: &objstore.Decision{Approved: false, By: "decider@example.test", Reason: "secret decline reason"}, } fx.srv.notifyStoreEntitlement("bab", e) if len(fx.sent) != 1 { t.Fatalf("sent %d messages, want one: %+v", len(fx.sent), fx.sent) } got := fx.sent[0] if got.to != "owner@example.test" || got.token != "owner-token" { t.Fatalf("sent to %q with %q, want the applicant's own grant", got.to, got.token) } for _, secret := range []string{"secret applicant note", "secret decline reason", "decider@example.test", "owner-token"} { if strings.Contains(got.subject+got.body, secret) { t.Fatalf("tenant mail leaked %q: %q %q", secret, got.subject, got.body) } } for _, want := range []string{"declined", "bab", fx.srv.cfg.Origin + "/a/bab/settings"} { if !strings.Contains(got.subject+got.body, want) { t.Fatalf("tenant mail is missing %q: %q %q", want, got.subject, got.body) } } } // An applicant with no Keymail grant gets the decision by email; one with a // grant still gets it through Keymail and not by email. func TestStoreDecisionNoticeByEmail(t *testing.T) { fx := storeNotifyFixture(t) m := &fakeConsoleMail{ready: true} fx.srv.consoleMail = m e := &objstore.Entitlement{ Applied: &objstore.Applied{By: "Owner@Example.test", Note: "secret applicant note"}, Decision: &objstore.Decision{Approved: true, By: "decider@example.test", Reason: "secret reason"}, } fx.srv.notifyStoreEntitlement("bab", e) if len(fx.sent) != 0 { t.Fatalf("keymail sends without a grant: %+v", fx.sent) } sent := m.sentCopy() if len(sent) != 1 { t.Fatalf("console mailer got %d messages, want 1: %q", len(sent), sent) } to, body, _ := strings.Cut(sent[0], "|") if to != "owner@example.test" { t.Fatalf("notice went to %q", to) } for _, want := range []string{"approved", "bab", fx.srv.cfg.Origin + "/a/bab/settings"} { if !strings.Contains(body, want) { t.Fatalf("notice missing %q:\n%s", want, body) } } for _, secret := range []string{"secret applicant note", "secret reason", "decider@example.test"} { if strings.Contains(body, secret) { t.Fatalf("notice leaked %q:\n%s", secret, body) } } if err := fx.st.SetGrantToken("owner@example.test", "owner-token", time.Now()); err != nil { t.Fatal(err) } fx.srv.notifyStoreEntitlement("bab", e) if len(fx.sent) != 1 || fx.sent[0].token != "owner-token" { t.Fatalf("grant holder not sent through keymail: %+v", fx.sent) } if got := m.sentCopy(); len(got) != 1 { t.Fatalf("grant holder was also emailed: %q", got) } } func TestStoreNotificationIsBestEffortAndNilSafe(t *testing.T) { fx := storeNotifyFixture(t) fx.srv.cfg.StoreOperators = []string{"ops@keymail.dev"} if err := fx.st.SetOperator("ops@keymail.dev", true); err != nil { t.Fatal(err) } if err := fx.st.SetGrantToken("ops@keymail.dev", "ops-token", time.Now()); err != nil { t.Fatal(err) } fx.err = errors.New("send failed") fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) if len(fx.sent) != 1 { t.Fatalf("send failure attempted %d messages, want one", len(fx.sent)) } fx.srv.sendApp = nil fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) } func TestStoreNotificationUsesABackgroundTimeout(t *testing.T) { fx := storeNotifyFixture(t) fx.srv.cfg.StoreOperators = []string{"ops@keymail.dev"} if err := fx.st.SetOperator("ops@keymail.dev", true); err != nil { t.Fatal(err) } if err := fx.st.SetGrantToken("ops@keymail.dev", "ops-token", time.Now()); err != nil { t.Fatal(err) } fx.srv.sendApp = func(ctx context.Context, _, _, _, _ string) (string, error) { deadline, ok := ctx.Deadline() if !ok || time.Until(deadline) > 15*time.Second || time.Until(deadline) < 14*time.Second { t.Errorf("send context deadline = %v, want about 15 seconds", deadline) } return "message", nil } fx.srv.notifyStoreEntitlement("bab", &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.test"}}) } func TestStoreWritesFinishWhileNotificationWaits(t *testing.T) { fx := newConsoleFixture(t) token := entitlementOperatorToken(t, fx, "paul@keymail.dev", "notify-async") if err := fx.st.SetGrantToken("paul@keymail.dev", "notification-grant", time.Now()); err != nil { t.Fatal(err) } entered := make(chan struct{}, 2) releaseSend := make(chan struct{}) finished := make(chan struct{}, 2) fx.srv.sendApp = func(ctx context.Context, _, _, _, _ string) (string, error) { entered <- struct{}{} select { case <-releaseSend: case <-ctx.Done(): } finished <- struct{}{} return "", errors.New("delivery failed") } defer close(releaseSend) status, _ := postJSON(t, entitlementApplyURL(fx, fx.sqid), map[string]any{"note": "uploads"}, token) if status != 200 || !storedEntitlement(t, fx, fx.sqid).Pending() { t.Fatalf("apply=%d", status) } select { case <-entered: case <-time.After(time.Second): t.Fatal("apply notification never started") } status, _ = postJSON(t, entitlementDecisionURL(fx, fx.sqid, "decline"), map[string]any{"reason": "private reason"}, token) if status != 200 || storedEntitlement(t, fx, fx.sqid).Decision == nil { t.Fatalf("decision=%d", status) } select { case <-entered: case <-time.After(time.Second): t.Fatal("decision notification never started") } // Both writes returned while their sends were blocked. Release the callbacks // before the fixture closes its database and listener. releaseSend <- struct{}{} releaseSend <- struct{}{} <-finished <-finished }