feat(canary): wire /api/admin mount + integration tests

- 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.
This commit is contained in:
CarterPerez-dev 2026-05-14 06:57:39 -04:00
parent 9e82f70841
commit 9adb3d2eb8
2 changed files with 236 additions and 1 deletions

View File

@ -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)
}

View File

@ -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"`)
}