mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
317 lines
15 KiB
Go
317 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/analytics"
|
|
"github.com/warmbly/warmbly/internal/app/audit"
|
|
"github.com/warmbly/warmbly/internal/app/campaign"
|
|
"github.com/warmbly/warmbly/internal/app/organization"
|
|
"github.com/warmbly/warmbly/internal/app/sequence"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
// Issue #531: exercise teammate requests through the real permission, handler, service and SQL boundaries.
|
|
func TestLiveSharedCampaignAccess(t *testing.T) {
|
|
dsn := os.Getenv("WARMBLY_TEST_DB")
|
|
if dsn == "" {
|
|
t.Skip("WARMBLY_TEST_DB not set")
|
|
}
|
|
ctx := context.Background()
|
|
handle, err := db.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(handle.Pool.Close)
|
|
exec := func(query string, args ...any) {
|
|
t.Helper()
|
|
if _, err := handle.Exec(ctx, query, args...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
author, teammate, viewer := uuid.New(), uuid.New(), uuid.New()
|
|
org, otherOrg, campaignID, otherCampaign, siblingCampaign := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
|
step, foreignStep, siblingStep, contact := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
|
for _, user := range []uuid.UUID{author, teammate, viewer} {
|
|
exec(`INSERT INTO users (id, email, first_name, last_name) VALUES ($1, $2, 'Issue531', 'Test')`, user, user.String()+"@test.local")
|
|
}
|
|
t.Cleanup(func() {
|
|
exec(`DELETE FROM campaigns WHERE organization_id = ANY($1)`, []uuid.UUID{org, otherOrg})
|
|
exec(`DELETE FROM contacts WHERE organization_id = ANY($1)`, []uuid.UUID{org, otherOrg})
|
|
exec(`DELETE FROM organizations WHERE id = ANY($1)`, []uuid.UUID{org, otherOrg})
|
|
exec(`DELETE FROM users WHERE id = ANY($1)`, []uuid.UUID{author, teammate, viewer})
|
|
})
|
|
for _, id := range []uuid.UUID{org, otherOrg} {
|
|
exec(`INSERT INTO organizations (id, name, slug, owner_user_id) VALUES ($1, 'Issue531', $2, $3)`, id, id.String(), teammate)
|
|
exec(`INSERT INTO organization_members (organization_id, user_id, role, permissions, accepted_at) VALUES ($1, $2, 'owner', $5, NOW()), ($1, $3, 'admin', $5, NOW()), ($1, $4, 'viewer', $6, NOW())`, id, teammate, author, viewer, int64(models.PermViewCampaigns|models.PermManageCampaigns|models.PermViewAnalytics), int64(models.PermViewCampaigns))
|
|
}
|
|
for _, c := range []struct{ id, org uuid.UUID }{{campaignID, org}, {siblingCampaign, org}, {otherCampaign, otherOrg}} {
|
|
exec(`INSERT INTO campaigns (id, user_id, organization_id, name, description, days, updated_at, created_at) VALUES ($1, $2, $3, 'Shared campaign', '', 62, NOW(), NOW())`, c.id, author, c.org)
|
|
}
|
|
for _, s := range []struct{ id, campaign, org uuid.UUID }{{step, campaignID, org}, {foreignStep, otherCampaign, otherOrg}, {siblingStep, siblingCampaign, org}} {
|
|
exec(`INSERT INTO sequences (id, campaign_id, organization_id, name, subject, body_plain, body_html, position) VALUES ($1, $2, $3, 'Intro', 'Hello', 'Original body', '<div></div>', 1)`, s.id, s.campaign, s.org)
|
|
}
|
|
exec(`INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, updated_at, created_at) VALUES ($1, $2, $3, $4, 'A', 'Lead', '', '', '{}', NOW(), NOW())`, contact, author, org, contact.String()+"@test.local")
|
|
exec(`INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at, opened_at) VALUES ($1, $2, $3, '2026-09-16T12:00:00Z', '2026-09-16T12:05:00Z')`, campaignID, contact, step)
|
|
campaignRepo := repository.NewCampaignRepostory(handle)
|
|
h := &Handler{
|
|
CampaignService: campaign.NewService(campaignRepo, nil, nil, repository.NewCampaignLogRepository(handle), nil, nil, nil, nil, nil),
|
|
SequenceService: sequence.NewService(repository.NewSequenceRepostory(handle)),
|
|
AnalyticsService: analytics.NewService(repository.NewAnalyticsRepository(handle), nil, campaignRepo, nil, nil),
|
|
AuditService: audit.NewNoOpService(),
|
|
}
|
|
m := &middleware.Handler{OrganizationService: organization.NewService(repository.NewOrganizationRepository(handle.Pool), nil, nil, nil, nil)}
|
|
type route struct {
|
|
method, path string
|
|
handler gin.HandlerFunc
|
|
orgPerm models.OrganizationPermission
|
|
apiPerm uint64
|
|
}
|
|
routes := []route{
|
|
{"GET", "/campaigns/:id", h.GetCampaign, models.PermViewCampaigns, models.APIPermReadCampaigns},
|
|
{"PATCH", "/campaigns/:id", h.UpdateCampaign, models.PermManageCampaigns, models.APIPermWriteCampaigns},
|
|
{"GET", "/campaigns/:id/logs", h.GetCampaignLogs, models.PermViewCampaigns, models.APIPermReadCampaigns},
|
|
{"GET", "/campaigns/:id/steps", h.GetSequences, models.PermViewCampaigns, models.APIPermReadCampaigns},
|
|
{"POST", "/campaigns/:id/steps", h.CreateSequence, models.PermManageCampaigns, models.APIPermWriteCampaigns},
|
|
{"PATCH", "/campaigns/:id/steps/:sid", h.UpdateSequence, models.PermManageCampaigns, models.APIPermWriteCampaigns},
|
|
{"DELETE", "/campaigns/:id/steps/:sid", h.DeleteSequence, models.PermManageCampaigns, models.APIPermWriteCampaigns},
|
|
{"PATCH", "/campaigns/:id/step-layout", h.PatchSequenceLayout, models.PermManageCampaigns, models.APIPermWriteCampaigns},
|
|
{"GET", "/analytics/campaigns/:id", h.GetCampaignAnalytics, models.PermViewAnalytics, models.APIPermReadAnalytics},
|
|
{"GET", "/analytics/campaigns/:id/daily", h.GetCampaignDailyStats, models.PermViewAnalytics, models.APIPermReadAnalytics},
|
|
{"GET", "/analytics/campaigns/:id/hourly", h.GetCampaignHourlyStats, models.PermViewAnalytics, models.APIPermReadAnalytics},
|
|
{"GET", "/analytics/campaigns/compare", h.CompareCampaigns, models.PermViewAnalytics, models.APIPermReadAnalytics},
|
|
}
|
|
request := func(t *testing.T, user uuid.UUID, selectedOrg *uuid.UUID, apiPerms *uint64, method, path, body string, status int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
router := gin.New()
|
|
router.Use(func(c *gin.Context) {
|
|
c.Set(middleware.UserIDKey, user.String())
|
|
if selectedOrg != nil {
|
|
c.Set(middleware.OrganizationIDKey, *selectedOrg)
|
|
}
|
|
if apiPerms != nil {
|
|
c.Set(middleware.AuthTypeKey, middleware.AuthTypeAPIKey)
|
|
c.Set(middleware.APIKeyPermissionsKey, *apiPerms)
|
|
}
|
|
})
|
|
for _, r := range routes {
|
|
router.Handle(r.method, r.path, m.RequireAccess(r.orgPerm, r.apiPerm), r.handler)
|
|
}
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != status {
|
|
t.Fatalf("%s %s: status %d, want %d: %s", method, path, rec.Code, status, rec.Body.String())
|
|
}
|
|
return rec
|
|
}
|
|
campaignPath := "/campaigns/" + campaignID.String()
|
|
analyticsPath := "/analytics/campaigns/" + campaignID.String()
|
|
dates := "from=2026-09-16&to=2026-09-17"
|
|
for _, caller := range []struct {
|
|
name string
|
|
user uuid.UUID
|
|
perms *uint64
|
|
}{
|
|
{"author", author, nil}, {"teammate", teammate, nil}, {"api_key", teammate, ptrCampaignAccess(models.APIPermReadCampaigns | models.APIPermWriteCampaigns | models.APIPermReadAnalytics)},
|
|
} {
|
|
t.Run(caller.name, func(t *testing.T) {
|
|
get := func(t *testing.T, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return request(t, caller.user, &org, caller.perms, "GET", path, "", 200)
|
|
}
|
|
t.Run("campaign", func(t *testing.T) { get(t, campaignPath) })
|
|
t.Run("steps", func(t *testing.T) {
|
|
rec := get(t, campaignPath+"/steps")
|
|
var steps []models.Sequence
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &steps); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(steps) != 1 || steps[0].ID != step {
|
|
t.Fatalf("steps = %s, want original step", rec.Body.String())
|
|
}
|
|
})
|
|
t.Run("analytics", func(t *testing.T) {
|
|
rec := get(t, analyticsPath)
|
|
var result models.CampaignAnalytics
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Summary.EmailsSent != 1 || result.Summary.UniqueOpens != 1 {
|
|
t.Fatalf("summary = %+v, want 1 send and 1 open", result.Summary)
|
|
}
|
|
})
|
|
for _, path := range []string{analyticsPath + "/daily?" + dates, analyticsPath + "/hourly?date=2026-09-16"} {
|
|
t.Run(path, func(t *testing.T) {
|
|
rec := get(t, path)
|
|
var result struct{ Data []struct{ Sent, Opens int } }
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sent, opens := 0, 0
|
|
for _, row := range result.Data {
|
|
sent += row.Sent
|
|
opens += row.Opens
|
|
}
|
|
if sent != 1 || opens != 1 {
|
|
t.Fatalf("stats = %s, want 1 send and 1 open", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
t.Run("compare", func(t *testing.T) {
|
|
rec := get(t, "/analytics/campaigns/compare?ids="+campaignID.String()+"&"+dates)
|
|
var result models.CampaignComparison
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(result.Campaigns) != 1 || result.Campaigns[0].EmailsSent != 1 {
|
|
t.Fatalf("comparison = %s", rec.Body.String())
|
|
}
|
|
})
|
|
t.Run("logs", func(t *testing.T) { get(t, campaignPath+"/logs") })
|
|
t.Run("settings", func(t *testing.T) {
|
|
rec := request(t, caller.user, &org, caller.perms, "PATCH", campaignPath, `{"description":"Edited by teammate"}`, 200)
|
|
var updated models.Campaign
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if updated.Description != "Edited by teammate" {
|
|
t.Fatalf("description = %q", updated.Description)
|
|
}
|
|
})
|
|
t.Run("partial_ramp", func(t *testing.T) {
|
|
request(t, caller.user, &org, caller.perms, "PATCH", campaignPath, `{"ramp_start":1}`, 200)
|
|
})
|
|
t.Run("folders_only", func(t *testing.T) {
|
|
request(t, caller.user, &org, caller.perms, "PATCH", campaignPath, `{"folders":[]}`, 200)
|
|
})
|
|
t.Run("edit_step", func(t *testing.T) {
|
|
exec(`UPDATE sequences SET body_plain = 'Original body', body_html = '<div></div>' WHERE id = $1`, step)
|
|
rec := request(t, caller.user, &org, caller.perms, "PATCH", campaignPath+"/steps/"+step.String(), `{"body_plain":"Edited body"}`, 200)
|
|
var updated models.Sequence
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if updated.BodyPlain != "Edited body" || !strings.Contains(updated.BodyHTML, "Edited body") {
|
|
t.Fatalf("step body was not saved and rendered: %+v", updated)
|
|
}
|
|
})
|
|
t.Run("layout", func(t *testing.T) {
|
|
exec(`UPDATE sequences SET x = 0, y = 0 WHERE id = $1`, step)
|
|
request(t, caller.user, &org, caller.perms, "PATCH", campaignPath+"/step-layout", fmt.Sprintf(`{"positions":[{"id":%q,"x":123,"y":456}]}`, step), 200)
|
|
var x, y float64
|
|
if err := handle.QueryRow(ctx, `SELECT x, y FROM sequences WHERE id = $1`, step).Scan(&x, &y); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if x != 123 || y != 456 {
|
|
t.Fatalf("layout = %v,%v, want 123,456", x, y)
|
|
}
|
|
})
|
|
t.Run("create_delete_step", func(t *testing.T) {
|
|
rec := request(t, caller.user, &org, caller.perms, "POST", campaignPath+"/steps", "", 200)
|
|
var created models.Sequence
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var inheritedOrg uuid.UUID
|
|
if err := handle.QueryRow(ctx, `SELECT organization_id FROM sequences WHERE id = $1`, created.ID).Scan(&inheritedOrg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if inheritedOrg != org {
|
|
t.Fatalf("step organization = %s, want %s", inheritedOrg, org)
|
|
}
|
|
request(t, caller.user, &org, caller.perms, "DELETE", campaignPath+"/steps/"+created.ID.String(), "", 200)
|
|
var remaining int
|
|
if err := handle.QueryRow(ctx, `SELECT COUNT(*) FROM sequences WHERE id = $1`, created.ID).Scan(&remaining); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if remaining != 0 {
|
|
t.Fatal("deleted step still exists")
|
|
}
|
|
})
|
|
})
|
|
}
|
|
t.Run("workspace_isolation", func(t *testing.T) {
|
|
for _, path := range []string{campaignPath, campaignPath + "/logs", analyticsPath, analyticsPath + "/daily?" + dates, analyticsPath + "/hourly?date=2026-09-16", "/analytics/campaigns/compare?ids=" + campaignID.String() + "," + otherCampaign.String() + "&" + dates} {
|
|
t.Run(path, func(t *testing.T) { request(t, author, &otherOrg, nil, "GET", path, "", 404) })
|
|
}
|
|
t.Run("steps", func(t *testing.T) {
|
|
rec := request(t, author, &otherOrg, nil, "GET", campaignPath+"/steps", "", 200)
|
|
if rec.Body.String() != "[]" {
|
|
t.Fatalf("foreign steps exposed: %s", rec.Body.String())
|
|
}
|
|
})
|
|
for _, mutation := range []struct{ method, path, body string }{
|
|
{"PATCH", campaignPath, `{"description":"forbidden"}`},
|
|
{"POST", campaignPath + "/steps", ""},
|
|
{"PATCH", campaignPath + "/steps/" + step.String(), `{"subject":"forbidden"}`},
|
|
{"DELETE", campaignPath + "/steps/" + step.String(), ""},
|
|
} {
|
|
t.Run(mutation.method+mutation.path, func(t *testing.T) {
|
|
request(t, author, &otherOrg, nil, mutation.method, mutation.path, mutation.body, 404)
|
|
})
|
|
}
|
|
})
|
|
t.Run("step_must_belong_to_campaign", func(t *testing.T) {
|
|
for _, id := range []uuid.UUID{foreignStep, siblingStep} {
|
|
for _, method := range []string{"PATCH", "DELETE"} {
|
|
t.Run(method+id.String(), func(t *testing.T) {
|
|
request(t, author, &org, nil, method, campaignPath+"/steps/"+id.String(), `{"subject":"forbidden"}`, 404)
|
|
})
|
|
}
|
|
}
|
|
})
|
|
t.Run("layout_isolation", func(t *testing.T) {
|
|
exec(`UPDATE sequences SET x = 0, y = 0 WHERE id = ANY($1)`, []uuid.UUID{step, foreignStep, siblingStep})
|
|
request(t, author, &otherOrg, nil, "PATCH", campaignPath+"/step-layout", fmt.Sprintf(`{"positions":[{"id":%q,"x":99,"y":99}]}`, step), 200)
|
|
for _, id := range []uuid.UUID{foreignStep, siblingStep} {
|
|
request(t, author, &org, nil, "PATCH", campaignPath+"/step-layout", fmt.Sprintf(`{"positions":[{"id":%q,"x":99,"y":99}]}`, id), 200)
|
|
}
|
|
var changed int
|
|
if err := handle.QueryRow(ctx, `SELECT COUNT(*) FROM sequences WHERE id = ANY($1) AND (x != 0 OR y != 0)`, []uuid.UUID{step, foreignStep, siblingStep}).Scan(&changed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if changed != 0 {
|
|
t.Fatal("layout changed a step outside the selected workspace or campaign")
|
|
}
|
|
})
|
|
t.Run("creator_attribution", func(t *testing.T) {
|
|
var creator uuid.UUID
|
|
if err := handle.QueryRow(ctx, `SELECT user_id FROM campaigns WHERE id = $1`, campaignID).Scan(&creator); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if creator != author {
|
|
t.Fatal("editing a shared campaign changed its creator")
|
|
}
|
|
})
|
|
t.Run("permissions", func(t *testing.T) {
|
|
request(t, viewer, &org, nil, "GET", campaignPath+"/steps", "", 200)
|
|
request(t, viewer, &org, nil, "GET", analyticsPath, "", 403)
|
|
for _, r := range routes {
|
|
path := strings.ReplaceAll(strings.ReplaceAll(r.path, ":id", campaignID.String()), ":sid", step.String())
|
|
t.Run(r.method+path, func(t *testing.T) {
|
|
request(t, teammate, nil, ptrCampaignAccess(r.apiPerm), r.method, path, `{}`, 400)
|
|
request(t, teammate, &org, ptrCampaignAccess(0), r.method, path, `{}`, 403)
|
|
if r.method != "GET" {
|
|
request(t, viewer, &org, nil, r.method, path, `{}`, 403)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func ptrCampaignAccess(v uint64) *uint64 { return &v }
|