From 9adb3d2eb858a64e71079852d697876c90b37205 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Thu, 14 May 2026 06:57:39 -0400 Subject: [PATCH] feat(canary): wire /api/admin mount + integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd/canary/main.go: mountAdminRoutes helper keeps run() and mountRouter under funlen 100. Admin routes mounted INSIDE the /api subrouter so they inherit read-tier rate limit + CORS; gated by OperatorBearer(cfg.Operator.Token). If OPERATOR_TOKEN is unset, the entire mount is skipped (no Bearer middleware, no routes — admin endpoints simply do not exist), and a structured warn log records the reason. This is the production safety: misconfiguration leaves the endpoint nonexistent rather than reachable. - internal/admin/integration_test.go: testcontainers-backed end-to-end tests exercising the full /api/admin/* stack: * 404 on missing Authorization (no WWW-Authenticate leak) * 404 on wrong token * 200 on /stats with seeded mixed tokens + events * 200 on /tokens?limit=2 with HasMore/NextOffset accuracy * 204 on disable + DB-level verification token.Enabled flipped * 404 on disable for a missing id All requests go through the OperatorBearer middleware to verify the wire-up matches the unit behavior. --- .../backend/cmd/canary/main.go | 23 +- .../internal/admin/integration_test.go | 214 ++++++++++++++++++ 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/admin/integration_test.go 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 9dee8612..6283ac3e 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go +++ b/PROJECTS/beginner/canary-token-generator/backend/cmd/canary/main.go @@ -19,6 +19,7 @@ import ( "github.com/go-chi/chi/v5" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/admin" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event" @@ -97,7 +98,8 @@ func run(configPath string) error { tokenSvc, verifier, healthH, tokenH := buildHTTPDeps( cfg, logger, db, rdb, eventRepo, tokenRepo, eventSvc, ) - srv := mountRouter(cfg, logger, rdb, healthH, tokenH, verifier) + adminH := admin.NewHandler(tokenRepo, eventRepo, tokenSvc, logger) + srv := mountRouter(cfg, logger, rdb, healthH, tokenH, adminH, verifier) var wg sync.WaitGroup spawnMySQLListener(ctx, cfg, logger, &wg, tokenSvc, eventSvc) @@ -252,6 +254,7 @@ func mountRouter( rdb *core.Redis, healthH *health.Handler, tokenH *token.Handler, + adminH *admin.Handler, verifier *turnstile.Verifier, ) *server.Server { srv := server.New(server.Config{ @@ -311,11 +314,29 @@ func mountRouter( middleware.TurnstileVerify(verifier), ).Post("/tokens", tokenH.CreateToken) tokenH.RegisterManageRoutes(api) + mountAdminRoutes(api, cfg, logger, adminH) }) return srv } +func mountAdminRoutes( + api chi.Router, + cfg *config.Config, + logger *slog.Logger, + adminH *admin.Handler, +) { + if cfg.Operator.Token == "" { + logger.Warn("operator admin endpoints disabled", + "reason", "OPERATOR_TOKEN unset") + return + } + api.Route("/admin", func(adm chi.Router) { + adm.Use(middleware.OperatorBearer(cfg.Operator.Token)) + adminH.Register(adm) + }) +} + func keyByCreateMin(r *http.Request) string { return "ratelimit:create:min:" + middleware.ExtractFingerprint(r) } diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/integration_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/integration_test.go new file mode 100644 index 00000000..25f6ee82 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/integration_test.go @@ -0,0 +1,214 @@ +// ©AngelaMos | 2026 +// integration_test.go + +//go:build integration + +package admin_test + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/admin" + "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/testutil" + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token" +) + +const ( + intgBaseURL = "https://canary.example.com" + intgManageURL = "https://canary.example.com" + intgOperatorToken = "test-operator-token-CONSTANT-COMPARE-OK" +) + +type intgURLBuilder struct{} + +func (intgURLBuilder) TriggerURL(id string) string { + return intgBaseURL + "/c/" + id +} + +func (intgURLBuilder) ManageURL(manageID string) string { + return intgManageURL + "/m/" + manageID +} + +type intgStack struct { + router chi.Router + tokenRepo *token.Repository + eventRepo *event.Repository +} + +func setupIntgStack(t *testing.T) *intgStack { + t.Helper() + db := sqlx.NewDb(testutil.NewTestDB(t), "pgx") + + tokenRepo := token.NewRepository(db) + eventRepo := event.NewRepository(db) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + adminH := admin.NewHandler(tokenRepo, eventRepo, intgURLBuilder{}, logger) + + r := chi.NewRouter() + r.Route("/api", func(api chi.Router) { + api.Route("/admin", func(adm chi.Router) { + adm.Use(middleware.OperatorBearer(intgOperatorToken)) + adminH.Register(adm) + }) + }) + + return &intgStack{router: r, tokenRepo: tokenRepo, eventRepo: eventRepo} +} + +func seedIntgToken( + t *testing.T, + repo *token.Repository, + id string, + typ token.Type, + ch token.AlertChannel, +) *token.Token { + t.Helper() + tok := &token.Token{ + ID: id, + ManageID: uuid.New().String(), + Type: typ, + Memo: "admin-intg", + AlertChannel: ch, + CreatedIP: "203.0.113.42", + CreatedFP: "abcdef0123456789", + Metadata: json.RawMessage(`{}`), + Enabled: true, + } + switch ch { + case token.ChannelTelegram: + tok.TelegramBot = testutil.Ptr("111:AAA") + tok.TelegramChat = testutil.Ptr("12345") + case token.ChannelWebhook: + tok.WebhookURL = testutil.Ptr("https://example.com/hook") + } + require.NoError(t, repo.Insert(context.Background(), tok)) + return tok +} + +func authedGet(t *testing.T, r chi.Router, path, tok string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func authedPost(t *testing.T, r chi.Router, path, tok string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, nil) + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestIntegration_AdminBearer_404OnMissing(t *testing.T) { + st := setupIntgStack(t) + w := authedGet(t, st.router, "/api/admin/stats", "") + require.Equal(t, http.StatusNotFound, w.Code, + "missing Authorization must return 404, not 401") + require.Empty(t, w.Header().Get("WWW-Authenticate")) +} + +func TestIntegration_AdminBearer_404OnWrongToken(t *testing.T) { + st := setupIntgStack(t) + w := authedGet(t, st.router, "/api/admin/stats", "wrong-token") + require.Equal(t, http.StatusNotFound, w.Code) +} + +func TestIntegration_AdminStats_Authorized(t *testing.T) { + st := setupIntgStack(t) + ctx := context.Background() + + tok := seedIntgToken(t, st.tokenRepo, "adminstats01", token.TypeWebbug, token.ChannelTelegram) + seedIntgToken(t, st.tokenRepo, "adminstats02", token.TypeDocx, token.ChannelWebhook) + + require.NoError(t, st.eventRepo.Insert(ctx, &event.Event{ + TokenID: tok.ID, SourceIP: "203.0.113.50", + })) + + w := authedGet(t, st.router, "/api/admin/stats", intgOperatorToken) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var body struct { + Success bool `json:"success"` + Data admin.Stats `json:"data"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + require.True(t, body.Success) + require.GreaterOrEqual(t, body.Data.TokensCount, int64(2)) + require.GreaterOrEqual(t, body.Data.EventsCount, int64(1)) + require.NotEmpty(t, body.Data.ByType) + require.NotEmpty(t, body.Data.ByAlertChannel) +} + +func TestIntegration_AdminListTokens_Authorized(t *testing.T) { + st := setupIntgStack(t) + + for i := range 4 { + seedIntgToken(t, st.tokenRepo, + "adminlist0"+string(rune('a'+i)), + token.TypeWebbug, token.ChannelWebhook) + } + + w := authedGet(t, st.router, "/api/admin/tokens?limit=2", intgOperatorToken) + require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String()) + + var body struct { + Data admin.TokenListResponse `json:"data"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&body)) + require.Len(t, body.Data.Tokens, 2) + require.GreaterOrEqual(t, body.Data.Total, int64(4)) + require.True(t, body.Data.Page.HasMore) + require.Equal(t, 2, body.Data.Page.NextOffset) + + require.Contains(t, body.Data.Tokens[0].TriggerURL, "/c/") + require.Contains(t, body.Data.Tokens[0].ManageURL, "/m/") +} + +func TestIntegration_AdminDisableToken_Authorized(t *testing.T) { + st := setupIntgStack(t) + ctx := context.Background() + + tok := seedIntgToken(t, st.tokenRepo, "admindis001a", token.TypeWebbug, token.ChannelWebhook) + require.True(t, tok.Enabled) + + w := authedPost(t, st.router, + "/api/admin/tokens/"+tok.ID+"/disable", + intgOperatorToken) + require.Equal(t, http.StatusNoContent, w.Code) + + got, err := st.tokenRepo.GetByID(ctx, tok.ID) + require.NoError(t, err) + require.False(t, got.Enabled, "disable must persist to DB") +} + +func TestIntegration_AdminDisableToken_NotFound(t *testing.T) { + st := setupIntgStack(t) + + w := authedPost(t, st.router, + "/api/admin/tokens/admindismiss/disable", + intgOperatorToken) + require.Equal(t, http.StatusNotFound, w.Code) + require.Contains(t, w.Body.String(), `"NOT_FOUND"`) +}