package handler import ( "context" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/warmbly/warmbly/internal/api/middleware" "github.com/warmbly/warmbly/internal/app/audit" "github.com/warmbly/warmbly/internal/app/emailsend" "github.com/warmbly/warmbly/internal/app/unibox" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" ) type fakeForwardUnibox struct { unibox.UniboxService mailbox uuid.UUID read int } func (f *fakeForwardUnibox) ForwardSource(context.Context, uuid.UUID, uuid.UUID) (*models.ForwardedMessage, *errx.Error) { f.read++ return &models.ForwardedMessage{EmailID: f.mailbox, Subject: "Pricing"}, nil } type fakeForwardSend struct{ req *emailsend.SendEmailRequest } func (f *fakeForwardSend) SendEmail(_ context.Context, _, _, _ uuid.UUID, req *emailsend.SendEmailRequest) (*emailsend.SendEmailResponse, *errx.Error) { f.req = req return &emailsend.SendEmailResponse{SendMode: "instant"}, nil } // Forwarding discloses a stored message, so a key that cannot read the inbox // is refused before the message is read, and a restricted key must be allowed // the message's mailbox as well as the sending one. func TestUniboxReplyForwardNeedsReadAccess(t *testing.T) { gin.SetMode(gin.TestMode) sender, source := uuid.New(), uuid.New() for _, tc := range []struct { name string perms uint64 allowed []uuid.UUID status int read bool }{ {"write-only key", models.APIPermWriteUnibox, nil, http.StatusForbidden, false}, {"read and write key", models.APIPermWriteUnibox | models.APIPermReadUnibox, nil, http.StatusOK, true}, {"key not allowed the source mailbox", models.APIPermWriteUnibox | models.APIPermReadUnibox, []uuid.UUID{sender}, http.StatusForbidden, true}, } { t.Run(tc.name, func(t *testing.T) { ub := &fakeForwardUnibox{mailbox: source} send := &fakeForwardSend{} h := &Handler{UniboxService: ub, EmailSendService: send, AuditService: audit.NewNoOpService()} router := gin.New() router.Use(func(c *gin.Context) { c.Set(middleware.UserIDKey, uuid.NewString()) c.Set(middleware.OrganizationIDKey, uuid.New()) c.Set(middleware.AuthTypeKey, middleware.AuthTypeAPIKey) c.Set(middleware.APIKeyPermissionsKey, tc.perms) if tc.allowed != nil { c.Set(middleware.APIKeyAllowedEmailAccountsKey, tc.allowed) } }) router.POST("/unibox/reply", h.UniboxReply) body := `{"email_account_id":"` + sender.String() + `","to":["new@example.com"],"subject":"Fwd: Pricing","forward_message_id":"` + uuid.NewString() + `"}` req := httptest.NewRequest(http.MethodPost, "/unibox/reply", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tc.status { t.Fatalf("status %d, want %d: %s", rec.Code, tc.status, rec.Body.String()) } if (ub.read > 0) != tc.read { t.Fatalf("message read %d times, want read=%v", ub.read, tc.read) } if tc.status == http.StatusOK && (send.req == nil || send.req.Forward == nil || send.req.Forward.Subject != "Pricing") { t.Fatalf("forward did not reach the send: %+v", send.req) } if tc.status != http.StatusOK && send.req != nil { t.Fatal("a refused forward was sent") } }) } } type fakeForwardThreadUnibox struct { fakeForwardUnibox parentLookups int } func (f *fakeForwardThreadUnibox) LatestMessageIDInThread(context.Context, uuid.UUID, string) (string, *errx.Error) { f.parentLookups++ return "", nil } // A forward from an open conversation answers nobody, so no In-Reply-To is // resolved from the thread it was forwarded from; a reply still gets one. func TestUniboxReplyForwardFromAThreadIsNoReply(t *testing.T) { gin.SetMode(gin.TestMode) sender := uuid.New() for _, tc := range []struct { name string forward bool parent bool }{ {"forward", true, false}, {"reply", false, true}, } { t.Run(tc.name, func(t *testing.T) { ub := &fakeForwardThreadUnibox{fakeForwardUnibox: fakeForwardUnibox{mailbox: sender}} send := &fakeForwardSend{} h := &Handler{UniboxService: ub, EmailSendService: send, AuditService: audit.NewNoOpService()} router := gin.New() router.Use(func(c *gin.Context) { c.Set(middleware.UserIDKey, uuid.NewString()) c.Set(middleware.OrganizationIDKey, uuid.New()) c.Set(middleware.AuthTypeKey, middleware.AuthTypeAPIKey) c.Set(middleware.APIKeyPermissionsKey, models.APIPermWriteUnibox|models.APIPermReadUnibox) }) router.POST("/unibox/reply", h.UniboxReply) body := `{"email_account_id":"` + sender.String() + `","to":["team@example.com"],"subject":"Fwd: Pricing","body_plain":"FYI","thread_id":"conversation-1"` if tc.forward { body += `,"forward_message_id":"` + uuid.NewString() + `"` } body += `}` req := httptest.NewRequest(http.MethodPost, "/unibox/reply", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } if got := len(send.req.InReplyTo) > 0; got != tc.parent { t.Fatalf("in_reply_to %v, want a parent: %v", send.req.InReplyTo, tc.parent) } if (ub.parentLookups > 0) != tc.parent { t.Fatalf("parent looked up %d times", ub.parentLookups) } }) } }