From 22f1daf4311cbdc34dffda9c298ac6a0f6f3846d Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 17 May 2026 19:08:12 -0400 Subject: [PATCH] =?UTF-8?q?test(canary):=20e2e=20suite=20=E2=80=94=20creat?= =?UTF-8?q?e=E2=86=92trigger=E2=86=92notify=E2=86=92manage=20across=20all?= =?UTF-8?q?=207=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit F23: integration tests covered repositories and individual handlers but no test exercised the full chain. The PDF padding bug (F2) had passing unit tests for years because the substring-only assertion masked the embedded underscore padding. New //go:build integration test in internal/server spins up Postgres via testcontainers + miniredis, walks each of webbug, slowredirect, docx, pdf, kubeconfig, envfile, mysql: POSTs /api/tokens, extracts the trigger URL from the artifact bytes the way a victim would (regex for non-binary, ZIP walk for docx, base64 decode for pdf), GETs the URL, then asserts the manage view sees the event and the notifier fired. PDF subtest asserts the byte after the embedded /c/ is not '_' — the structural check that catches F2-style regressions. --- .../backend/internal/server/e2e_test.go | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/server/e2e_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/server/e2e_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/server/e2e_test.go new file mode 100644 index 00000000..b23eff6f --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/server/e2e_test.go @@ -0,0 +1,468 @@ +// ©AngelaMos | 2026 +// e2e_test.go + +//go:build integration + +package server_test + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/go-chi/chi/v5" + "github.com/jmoiron/sqlx" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/registry" +) + +const ( + e2eBaseURL = "https://canary.example.com" + e2eManageURL = "https://canary.example.com" + e2eTelegramBot = "111:AAA" + e2eTelegramChat = "12345" + e2eMySQLPubHost = "localhost" + e2eMySQLPubPort = 3306 + e2eClientIP = "203.0.113.10" + e2eClientUA = "E2E/1.0" + e2eEventualWait = 3 * time.Second + e2eEventualTick = 20 * time.Millisecond + e2eSendTimeout = 2 * time.Second + e2eDedupTTL = 15 * time.Minute +) + +type e2eSender struct { + mu sync.Mutex + calls []event.NotifyInfo +} + +func (s *e2eSender) Channel() string { return "telegram" } + +func (s *e2eSender) Send( + _ context.Context, + info event.NotifyInfo, + _ *event.Event, +) error { + s.mu.Lock() + defer s.mu.Unlock() + s.calls = append(s.calls, info) + return nil +} + +func (s *e2eSender) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.calls) +} + +type e2eRegistryAdapter struct{ r registry.Registry } + +func (a e2eRegistryAdapter) Get( + t token.Type, +) (token.Generator, bool) { + g, ok := a.r[t] + return g, ok +} + +type e2eRecorderAdapter struct{ svc *event.Service } + +func (a e2eRecorderAdapter) Record( + ctx context.Context, + t *token.Token, + evt *event.Event, +) error { + return a.svc.Record(ctx, t.NotifyInfo(), evt) +} + +type e2eStack struct { + router chi.Router + notifySvc *notify.Service + sender *e2eSender + eventRepo *event.Repository + tokenRepo *token.Repository +} + +func setupE2EStack(t *testing.T) *e2eStack { + t.Helper() + + db := sqlx.NewDb(testutil.NewTestDB(t), "pgx") + + mr, err := miniredis.Run() + require.NoError(t, err) + t.Cleanup(mr.Close) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { + if cErr := rdb.Close(); cErr != nil { + t.Logf("redis close: %v", cErr) + } + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + tokenRepo := token.NewRepository(db) + eventRepo := event.NewRepository(db) + sender := &e2eSender{} + + notifySvc := notify.NewService(eventRepo, + notify.WithLogger(logger), + notify.WithSendTimeout(e2eSendTimeout), + ) + notifySvc.Register(sender) + + eventSvc := event.NewService( + eventRepo, + tokenRepo, + rdb, + notifySvc, + event.ServiceConfig{ + DedupTTL: e2eDedupTTL, + Logger: logger, + }, + ) + + genReg := registry.Build(registry.Config{ + BaseURL: e2eBaseURL, + MySQLPublicHost: e2eMySQLPubHost, + MySQLPublicPort: e2eMySQLPubPort, + }) + tokenSvc := token.NewService( + tokenRepo, + e2eRegistryAdapter{r: genReg}, + token.ServiceConfig{ + BaseURL: e2eBaseURL, + ManageURL: e2eManageURL, + }, + ) + + tokenH := token.NewHandler( + tokenSvc, + e2eRecorderAdapter{svc: eventSvc}, + nil, + eventRepo, + eventSvc, + logger, + ) + + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.Recovery(logger)) + tokenH.RegisterTriggerRoutes(r) + r.Route("/api", func(api chi.Router) { + api.Get("/tokens/types", tokenH.GetTypes) + api.Post("/tokens", tokenH.CreateToken) + tokenH.RegisterManageRoutes(api) + }) + + return &e2eStack{ + router: r, + notifySvc: notifySvc, + sender: sender, + eventRepo: eventRepo, + tokenRepo: tokenRepo, + } +} + +type e2eCreateResponse struct { + Success bool `json:"success"` + Data struct { + Token token.Response `json:"token"` + Artifact token.ArtifactJSON `json:"artifact"` + } `json:"data"` +} + +func createE2EToken( + t *testing.T, + st *e2eStack, + kind token.Type, + metadata string, +) e2eCreateResponse { + t.Helper() + + body := `{ + "type": "` + string(kind) + `", + "memo": "e2e ` + string(kind) + `", + "alert_channel": "telegram", + "telegram_bot": "` + e2eTelegramBot + `", + "telegram_chat": "` + e2eTelegramChat + `"` + if metadata != "" { + body += `, "metadata": ` + metadata + } + body += `}` + + req := httptest.NewRequest( + http.MethodPost, + "/api/tokens", + strings.NewReader(body), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + st.router.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code, "body=%s", w.Body.String()) + + var resp e2eCreateResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.True(t, resp.Success) + require.NotEmpty(t, resp.Data.Token.ID) + return resp +} + +func extractEmbeddedTriggerURL( + t *testing.T, + kind token.Type, + art token.ArtifactJSON, + tokenID string, +) string { + t.Helper() + switch art.Kind { + case "url": + require.NotEmpty(t, art.URL) + return art.URL + case "file": + raw, err := base64.StdEncoding.DecodeString(art.ContentB64) + require.NoError(t, err) + if kind == token.TypeDocx { + return matchTriggerURLInZip(t, raw, tokenID) + } + return matchTriggerURL(t, raw, tokenID) + case "text": + return matchTriggerURL(t, []byte(art.Content), tokenID) + default: + t.Fatalf("unhandled artifact kind for HTTP trigger: %s", art.Kind) + return "" + } +} + +func matchTriggerURLInZip( + t *testing.T, + raw []byte, + tokenID string, +) string { + t.Helper() + r, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + require.NoError(t, err, "docx artifact must be a valid zip") + needle := []byte("/c/" + tokenID) + for _, f := range r.File { + rc, oErr := f.Open() + if oErr != nil { + continue + } + content, rErr := io.ReadAll(rc) + _ = rc.Close() + if rErr != nil { + continue + } + if bytes.Contains(content, needle) { + return matchTriggerURL(t, content, tokenID) + } + } + t.Fatalf( + "docx: no zip member contains /c/%s (members=%d)", + tokenID, len(r.File), + ) + return "" +} + +func matchTriggerURL(t *testing.T, raw []byte, tokenID string) string { + t.Helper() + pattern := regexp.MustCompile( + `https?://[A-Za-z0-9.\-:]+/(?:c|k)/` + + regexp.QuoteMeta(tokenID) + + `(?:\?[A-Za-z0-9_=&\-%.]*)?`, + ) + match := pattern.Find(raw) + require.NotNilf( + t, + match, + "embedded trigger URL for token %s not found in artifact (len=%d)", + tokenID, len(raw), + ) + return string(match) +} + +func TestE2E_AllTokenTypes_CreateTriggerNotifyRecord(t *testing.T) { + st := setupE2EStack(t) + + cases := []struct { + name string + kind token.Type + metadata string + httpable bool + }{ + { + name: "webbug", + kind: token.TypeWebbug, + metadata: "", + httpable: true, + }, + { + name: "slowredirect", + kind: token.TypeSlowRedirect, + metadata: `{"destination_url":"https://example.com/out"}`, + httpable: true, + }, + { + name: "docx", + kind: token.TypeDocx, + metadata: "", + httpable: true, + }, + { + name: "pdf", + kind: token.TypePDF, + metadata: "", + httpable: true, + }, + { + name: "kubeconfig", + kind: token.TypeKubeconfig, + metadata: "", + httpable: true, + }, + { + name: "envfile", + kind: token.TypeEnvfile, + metadata: `{"include_keys":["aws"]}`, + httpable: true, + }, + { + name: "mysql", + kind: token.TypeMySQL, + metadata: "", + httpable: false, + }, + } + + var expectedNotifications int + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + resp := createE2EToken(t, st, c.kind, c.metadata) + tokenID := resp.Data.Token.ID + + if c.kind == token.TypePDF { + raw, err := base64.StdEncoding.DecodeString( + resp.Data.Artifact.ContentB64, + ) + require.NoError(t, err) + idx := bytes.Index(raw, []byte("/c/"+tokenID)) + require.GreaterOrEqualf( + t, + idx, + 0, + "PDF artifact must embed /c/ (id=%s)", + tokenID, + ) + after := raw[idx+len("/c/"+tokenID)] + require.NotEqualf( + t, + byte('_'), + after, + "audit finding F2 regression: the byte immediately after "+ + "the PDF-embedded /c/ must not be '_' — otherwise "+ + "Acrobat fetches /c/____ and the canary silently "+ + "no-ops on lookup (id=%s, after=%q)", + tokenID, after, + ) + } + + if !c.httpable { + require.Equal(t, "connection_string", resp.Data.Artifact.Kind) + require.NotEmpty(t, resp.Data.Artifact.ConnectionString) + return + } + + triggerURL := extractEmbeddedTriggerURL( + t, + c.kind, + resp.Data.Artifact, + tokenID, + ) + require.NotEmpty(t, triggerURL) + + trigReq := httptest.NewRequest(http.MethodGet, triggerURL, nil) + trigReq.Header.Set("CF-Connecting-IP", e2eClientIP) + trigReq.Header.Set("User-Agent", e2eClientUA) + tw := httptest.NewRecorder() + st.router.ServeHTTP(tw, trigReq) + require.Truef( + t, + tw.Code == http.StatusOK || + tw.Code == http.StatusFound || + tw.Code == http.StatusForbidden, + "trigger expected 200/302/403 (kubeconfig camouflages "+ + "as K8s 403), got %d for %s (url=%s)", + tw.Code, c.kind, triggerURL, + ) + + expectedNotifications++ + require.Eventuallyf( + t, + func() bool { + return st.sender.count() >= expectedNotifications + }, + e2eEventualWait, + e2eEventualTick, + "notifier should fire for %s (got=%d want>=%d)", + c.kind, st.sender.count(), expectedNotifications, + ) + + count, err := st.eventRepo.CountByToken( + context.Background(), + tokenID, + ) + require.NoError(t, err) + require.GreaterOrEqualf( + t, + count, + int64(1), + "at least one event must be recorded for %s", + c.kind, + ) + + manageReq := httptest.NewRequest( + http.MethodGet, + "/api/m/"+resp.Data.Token.ManageID, + nil, + ) + mw := httptest.NewRecorder() + st.router.ServeHTTP(mw, manageReq) + require.Equal(t, http.StatusOK, mw.Code) + var manage struct { + Success bool `json:"success"` + Data struct { + EventsTotal int64 `json:"events_total"` + } `json:"data"` + } + require.NoError(t, json.NewDecoder(mw.Body).Decode(&manage)) + require.True(t, manage.Success) + require.GreaterOrEqual( + t, + manage.Data.EventsTotal, + int64(1), + "manage view must show at least one event for "+c.name, + ) + }) + } + + st.notifySvc.Wait() +}