diff --git a/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go index 38592634..9dee8612 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go @@ -92,43 +92,11 @@ func run(configPath string) error { logger.Info("redis connected") notifySvc, eventSvc := buildEventStack( - cfg, - logger, - eventRepo, - tokenRepo, - rdb, + cfg, logger, eventRepo, tokenRepo, rdb, ) - - genRegistry := registry.Build(registry.Config{ - BaseURL: cfg.Canary.BaseURL, - MySQLPublicHost: cfg.MySQL.PublicHost, - MySQLPublicPort: cfg.MySQL.PublicPort, - }) - tokenSvc := token.NewService( - tokenRepo, - registryAdapter{r: genRegistry}, - token.ServiceConfig{ - BaseURL: cfg.Canary.BaseURL, - ManageURL: cfg.Canary.ManageURL, - }, + tokenSvc, verifier, healthH, tokenH := buildHTTPDeps( + cfg, logger, db, rdb, eventRepo, tokenRepo, eventSvc, ) - - verifier := turnstile.NewVerifier( - turnstile.Config{SecretKey: cfg.Turnstile.SecretKey}, - rdb.Client, - ) - - healthH := health.NewHandler(db, rdb) - tokenH := token.NewHandler( - tokenSvc, - &eventRecorderAdapter{svc: eventSvc}, - &fingerprintRecorderAdapter{ - repo: eventRepo, - window: cfg.Notify.FingerprintWindow, - }, - logger, - ) - srv := mountRouter(cfg, logger, rdb, healthH, tokenH, verifier) var wg sync.WaitGroup @@ -152,6 +120,47 @@ func run(configPath string) error { return shutdownErr } +func buildHTTPDeps( + cfg *config.Config, + logger *slog.Logger, + db *core.Database, + rdb *core.Redis, + eventRepo *event.Repository, + tokenRepo *token.Repository, + eventSvc *event.Service, +) (*token.Service, *turnstile.Verifier, *health.Handler, *token.Handler) { + genRegistry := registry.Build(registry.Config{ + BaseURL: cfg.Canary.BaseURL, + MySQLPublicHost: cfg.MySQL.PublicHost, + MySQLPublicPort: cfg.MySQL.PublicPort, + }) + tokenSvc := token.NewService( + tokenRepo, + registryAdapter{r: genRegistry}, + token.ServiceConfig{ + BaseURL: cfg.Canary.BaseURL, + ManageURL: cfg.Canary.ManageURL, + }, + ) + verifier := turnstile.NewVerifier( + turnstile.Config{SecretKey: cfg.Turnstile.SecretKey}, + rdb.Client, + ) + healthH := health.NewHandler(db, rdb) + tokenH := token.NewHandler( + tokenSvc, + &eventRecorderAdapter{svc: eventSvc}, + &fingerprintRecorderAdapter{ + repo: eventRepo, + window: cfg.Notify.FingerprintWindow, + }, + eventRepo, + eventSvc, + logger, + ) + return tokenSvc, verifier, healthH, tokenH +} + func buildEventStack( cfg *config.Config, logger *slog.Logger, @@ -301,6 +310,7 @@ func mountRouter( createHour, middleware.TurnstileVerify(verifier), ).Post("/tokens", tokenH.CreateToken) + tokenH.RegisterManageRoutes(api) }) return srv diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go index 0a029828..d145bd3c 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go @@ -124,6 +124,8 @@ func setupIntgStack(t *testing.T) *intgStack { tokenSvc, intgRecorderAdapter{svc: eventSvc}, nil, + eventRepo, + eventSvc, logger, ) @@ -134,6 +136,7 @@ func setupIntgStack(t *testing.T) *intgStack { r.Route("/api", func(api chi.Router) { api.Get("/tokens/types", tokenH.GetTypes) api.Post("/tokens", tokenH.CreateToken) + tokenH.RegisterManageRoutes(api) }) return &intgStack{ @@ -350,6 +353,154 @@ func TestIntegration_DedupTTLExpiry(t *testing.T) { require.Equal(t, 2, sender.callCount(), "after TTL expiry next trigger notifies again") } +func TestIntegration_ManagePageReturnsTokenAndEventsAndSilencedCount(t *testing.T) { + t.Parallel() + st := setupIntgStack(t) + + createBody := `{ + "type": "webbug", + "memo": "manage-flow", + "alert_channel": "telegram", + "telegram_bot": "111:AAA", + "telegram_chat": "12345" + }` + req := httptest.NewRequest(http.MethodPost, "/api/tokens", + strings.NewReader(createBody)) + 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 createResp struct { + Data struct { + Token struct { + ID string `json:"id"` + ManageID string `json:"manage_id"` + } `json:"token"` + } `json:"data"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&createResp)) + tokenID := createResp.Data.Token.ID + manageID := createResp.Data.Token.ManageID + + for range 3 { + trigReq := httptest.NewRequest(http.MethodGet, "/c/"+tokenID, nil) + trigReq.Header.Set("CF-Connecting-IP", "203.0.113.50") + tw := httptest.NewRecorder() + st.router.ServeHTTP(tw, trigReq) + require.Equal(t, http.StatusOK, tw.Code) + } + st.notifySvc.Wait() + + mw := httptest.NewRecorder() + st.router.ServeHTTP(mw, httptest.NewRequest(http.MethodGet, + "/api/m/"+manageID, nil)) + require.Equal(t, http.StatusOK, mw.Code, "body=%s", mw.Body.String()) + + var manageResp struct { + Success bool `json:"success"` + Data token.ManageResponse `json:"data"` + } + require.NoError(t, json.NewDecoder(mw.Body).Decode(&manageResp)) + require.True(t, manageResp.Success) + + require.Equal(t, tokenID, manageResp.Data.Token.ID) + require.Equal(t, "https://canary.example.com/c/"+tokenID, + manageResp.Data.Token.TriggerURL) + require.Equal(t, int64(3), manageResp.Data.Token.TriggerCount) + + require.Equal(t, int64(3), manageResp.Data.EventsTotal, + "all 3 events recorded (one sent + two deduped)") + require.Equal(t, int64(2), manageResp.Data.EventsSilencedActive, + "two triggers deduped within 15-min window") + + require.Len(t, manageResp.Data.Events, 3, "events page payload") + require.False(t, manageResp.Data.Page.HasMore, + "3 events fits in a 20-page; no next cursor") + + statuses := []event.NotifyStatus{} + for _, e := range manageResp.Data.Events { + statuses = append(statuses, e.NotifyStatus) + } + var sent, deduped int + for _, s := range statuses { + switch s { + case event.NotifySent: + sent++ + case event.NotifyDeduped: + deduped++ + } + } + require.Equal(t, 1, sent) + require.Equal(t, 2, deduped) +} + +func TestIntegration_ManagePageReturns404OnUnknownManageID(t *testing.T) { + t.Parallel() + st := setupIntgStack(t) + + w := httptest.NewRecorder() + st.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, + "/api/m/00000000-0000-0000-0000-000000000000", nil)) + require.Equal(t, http.StatusNotFound, w.Code) +} + +func TestIntegration_ManageDeleteCascadesEvents(t *testing.T) { + t.Parallel() + st := setupIntgStack(t) + + createBody := `{ + "type": "webbug", + "memo": "delete-flow", + "alert_channel": "telegram", + "telegram_bot": "111:AAA", + "telegram_chat": "12345" + }` + req := httptest.NewRequest(http.MethodPost, "/api/tokens", + strings.NewReader(createBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + st.router.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + var createResp struct { + Data struct { + Token struct { + ID string `json:"id"` + ManageID string `json:"manage_id"` + } `json:"token"` + } `json:"data"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&createResp)) + + for i := range 2 { + trigReq := httptest.NewRequest(http.MethodGet, "/c/"+createResp.Data.Token.ID, nil) + ip := "203.0.113." + string(rune('1'+i)) + trigReq.Header.Set("CF-Connecting-IP", ip) + tw := httptest.NewRecorder() + st.router.ServeHTTP(tw, trigReq) + require.Equal(t, http.StatusOK, tw.Code) + } + st.notifySvc.Wait() + + count, err := st.eventRepo.CountByToken(context.Background(), createResp.Data.Token.ID) + require.NoError(t, err) + require.Equal(t, int64(2), count) + + dw := httptest.NewRecorder() + st.router.ServeHTTP(dw, httptest.NewRequest(http.MethodDelete, + "/api/m/"+createResp.Data.Token.ManageID, nil)) + require.Equal(t, http.StatusNoContent, dw.Code) + + count, err = st.eventRepo.CountByToken(context.Background(), createResp.Data.Token.ID) + require.NoError(t, err) + require.Equal(t, int64(0), count, "FK cascade removes events") + + tok, err := st.tokenRepo.GetByID(context.Background(), createResp.Data.Token.ID) + require.Nil(t, tok) + require.ErrorIs(t, err, token.ErrNotFound) +} + func TestIntegration_RetentionLoopPrunes(t *testing.T) { t.Parallel() db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")