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:
parent
9e82f70841
commit
9adb3d2eb8
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`)
|
||||
}
|
||||
Loading…
Reference in New Issue