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