feat(canary): admin handler (stats + tokens list + disable) + repo methods
- internal/admin/handler.go: replaces unused template stats handler
with the canonical Phase 12 endpoints from spec §8.1:
GET /stats — tokens_count, events_count, by_type,
by_alert_channel
GET /tokens — offset-based pagination, default 50,
cap 100; returns full token.Response
list with trigger/manage URLs via
injected URLBuilder; 400 BAD_PARAM on
negative/non-int offset
POST /tokens/{id}/disable — 204 on success; 404 NOT_FOUND envelope
when token.ErrNotFound; 500 envelope
on other repo errors
Handler takes TokenRepository + EventRepository + URLBuilder
interfaces (test seam); wire-up supplies *token.Repository,
*event.Repository, *token.Service (which already implements
TriggerURL/ManageURL).
- internal/admin/dto.go: Stats, TokenListPage, TokenListResponse.
- token.Repository.CountByType / CountByAlertChannel: GROUP BY type
and GROUP BY alert_channel; returned shapes typed as TypeCount /
ChannelCount with json+db tags for direct JSON emission.
- event.Repository.CountAll: global event count.
- Handler tests cover happy path + 500 propagation for each repo
dependency; pagination (default/limit-cap/offset paging); 400 on
bad offset; 404 on disable miss; 405 on GET to disable endpoint
(sanity check that POST-only routing is intentional).
This commit is contained in:
parent
426ed54594
commit
9e82f70841
|
|
@ -0,0 +1,26 @@
|
|||
// ©AngelaMos | 2026
|
||||
// dto.go
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
TokensCount int64 `json:"tokens_count"`
|
||||
EventsCount int64 `json:"events_count"`
|
||||
ByType []token.TypeCount `json:"by_type"`
|
||||
ByAlertChannel []token.ChannelCount `json:"by_alert_channel"`
|
||||
}
|
||||
|
||||
type TokenListPage struct {
|
||||
NextOffset int `json:"next_offset"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
type TokenListResponse struct {
|
||||
Tokens []token.Response `json:"tokens"`
|
||||
Total int64 `json:"total"`
|
||||
Page TokenListPage `json:"page"`
|
||||
}
|
||||
|
|
@ -5,191 +5,253 @@ package admin
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||
)
|
||||
|
||||
const (
|
||||
urlParamID = "id"
|
||||
|
||||
queryParamOffset = "offset"
|
||||
queryParamLimit = "limit"
|
||||
|
||||
defaultPageSize = 50
|
||||
maxPageSize = 100
|
||||
|
||||
headerContentType = "Content-Type"
|
||||
contentTypeJSON = "application/json"
|
||||
|
||||
errorCodeNotFound = "NOT_FOUND"
|
||||
errorCodeBadParam = "BAD_PARAM"
|
||||
errorCodeInternalError = "INTERNAL_ERROR"
|
||||
|
||||
respMessageNotFound = "not found"
|
||||
respMessageBadOffset = "invalid offset"
|
||||
respMessageInternalError = "internal server error"
|
||||
)
|
||||
|
||||
type TokenRepository interface {
|
||||
ListAll(ctx context.Context, opts token.ListOptions) ([]token.Token, error)
|
||||
CountAll(ctx context.Context) (int64, error)
|
||||
CountByType(ctx context.Context) ([]token.TypeCount, error)
|
||||
CountByAlertChannel(ctx context.Context) ([]token.ChannelCount, error)
|
||||
SetEnabled(ctx context.Context, id string, enabled bool) error
|
||||
}
|
||||
|
||||
type EventRepository interface {
|
||||
CountAll(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
type URLBuilder interface {
|
||||
TriggerURL(id string) string
|
||||
ManageURL(manageID string) string
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
dbStats func() sql.DBStats
|
||||
redisStats func() *redis.PoolStats
|
||||
redisPing func(ctx context.Context) error
|
||||
dbPing func(ctx context.Context) error
|
||||
tokens TokenRepository
|
||||
events EventRepository
|
||||
urls URLBuilder
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type HandlerConfig struct {
|
||||
DBStats func() sql.DBStats
|
||||
RedisStats func() *redis.PoolStats
|
||||
RedisPing func(ctx context.Context) error
|
||||
DBPing func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewHandler(cfg HandlerConfig) *Handler {
|
||||
func NewHandler(
|
||||
tokens TokenRepository,
|
||||
events EventRepository,
|
||||
urls URLBuilder,
|
||||
logger *slog.Logger,
|
||||
) *Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Handler{
|
||||
dbStats: cfg.DBStats,
|
||||
redisStats: cfg.RedisStats,
|
||||
redisPing: cfg.RedisPing,
|
||||
dbPing: cfg.DBPing,
|
||||
tokens: tokens,
|
||||
events: events,
|
||||
urls: urls,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Route("/admin", func(r chi.Router) {
|
||||
r.Get("/stats", h.GetSystemStats)
|
||||
r.Get("/stats/db", h.GetDatabaseStats)
|
||||
r.Get("/stats/redis", h.GetRedisStats)
|
||||
r.Get("/stats/runtime", h.GetRuntimeStats)
|
||||
})
|
||||
func (h *Handler) Register(r chi.Router) {
|
||||
r.Get("/stats", h.GetStats)
|
||||
r.Get("/tokens", h.ListTokens)
|
||||
r.Post("/tokens/{"+urlParamID+"}/disable", h.DisableToken)
|
||||
}
|
||||
|
||||
func (h *Handler) GetSystemStats(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
dbHealthy := true
|
||||
if h.dbPing != nil {
|
||||
if err := h.dbPing(ctx); err != nil {
|
||||
dbHealthy = false
|
||||
tokensCount, err := h.tokens.CountAll(ctx)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "admin: count tokens", "error", err)
|
||||
h.writeInternal(w)
|
||||
return
|
||||
}
|
||||
eventsCount, err := h.events.CountAll(ctx)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "admin: count events", "error", err)
|
||||
h.writeInternal(w)
|
||||
return
|
||||
}
|
||||
byType, err := h.tokens.CountByType(ctx)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "admin: count by type", "error", err)
|
||||
h.writeInternal(w)
|
||||
return
|
||||
}
|
||||
byChannel, err := h.tokens.CountByAlertChannel(ctx)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "admin: count by channel", "error", err)
|
||||
h.writeInternal(w)
|
||||
return
|
||||
}
|
||||
|
||||
stats := Stats{
|
||||
TokensCount: tokensCount,
|
||||
EventsCount: eventsCount,
|
||||
ByType: byType,
|
||||
ByAlertChannel: byChannel,
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, envelopeData(stats))
|
||||
}
|
||||
|
||||
func (h *Handler) ListTokens(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
offset, err := parseOffset(r.URL.Query().Get(queryParamOffset))
|
||||
if err != nil {
|
||||
h.writeJSON(w, http.StatusBadRequest, envelopeError(
|
||||
errorCodeBadParam, respMessageBadOffset,
|
||||
))
|
||||
return
|
||||
}
|
||||
limit := parseLimit(r.URL.Query().Get(queryParamLimit))
|
||||
|
||||
rows, err := h.tokens.ListAll(ctx, token.ListOptions{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "admin: list tokens", "error", err)
|
||||
h.writeInternal(w)
|
||||
return
|
||||
}
|
||||
|
||||
total, err := h.tokens.CountAll(ctx)
|
||||
if err != nil {
|
||||
h.logger.ErrorContext(ctx, "admin: count tokens", "error", err)
|
||||
h.writeInternal(w)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]token.Response, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, rows[i].ToResponse(
|
||||
h.urls.TriggerURL(rows[i].ID),
|
||||
h.urls.ManageURL(rows[i].ManageID),
|
||||
))
|
||||
}
|
||||
|
||||
next := offset + len(rows)
|
||||
hasMore := int64(next) < total
|
||||
|
||||
resp := TokenListResponse{
|
||||
Tokens: out,
|
||||
Total: total,
|
||||
Page: TokenListPage{
|
||||
NextOffset: next,
|
||||
HasMore: hasMore,
|
||||
},
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, envelopeData(resp))
|
||||
}
|
||||
|
||||
func (h *Handler) DisableToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, urlParamID)
|
||||
if id == "" {
|
||||
h.writeJSON(w, http.StatusNotFound, envelopeError(
|
||||
errorCodeNotFound, respMessageNotFound,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tokens.SetEnabled(r.Context(), id, false); err != nil {
|
||||
if errors.Is(err, token.ErrNotFound) {
|
||||
h.writeJSON(w, http.StatusNotFound, envelopeError(
|
||||
errorCodeNotFound, respMessageNotFound,
|
||||
))
|
||||
return
|
||||
}
|
||||
h.logger.ErrorContext(r.Context(), "admin: disable token",
|
||||
"error", err, "token_id", id)
|
||||
h.writeInternal(w)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
redisHealthy := true
|
||||
if h.redisPing != nil {
|
||||
if err := h.redisPing(ctx); err != nil {
|
||||
redisHealthy = false
|
||||
}
|
||||
func parseOffset(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return 0, errors.New("invalid offset")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
func parseLimit(raw string) int {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultPageSize
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return defaultPageSize
|
||||
}
|
||||
if v > maxPageSize {
|
||||
return maxPageSize
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
response := SystemStatsResponse{
|
||||
Database: DatabaseStatus{
|
||||
Healthy: dbHealthy,
|
||||
Stats: h.getDBStats(),
|
||||
},
|
||||
Redis: RedisStatus{
|
||||
Healthy: redisHealthy,
|
||||
Stats: h.getRedisStats(),
|
||||
},
|
||||
Runtime: RuntimeStats{
|
||||
GoVersion: runtime.Version(),
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
NumCPU: runtime.NumCPU(),
|
||||
MemAlloc: memStats.Alloc,
|
||||
MemSys: memStats.Sys,
|
||||
NumGC: memStats.NumGC,
|
||||
func (h *Handler) writeJSON(
|
||||
w http.ResponseWriter,
|
||||
status int,
|
||||
body any,
|
||||
) {
|
||||
w.Header().Set(headerContentType, contentTypeJSON)
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
h.logger.Warn("write json response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) writeInternal(w http.ResponseWriter) {
|
||||
h.writeJSON(w, http.StatusInternalServerError, envelopeError(
|
||||
errorCodeInternalError, respMessageInternalError,
|
||||
))
|
||||
}
|
||||
|
||||
func envelopeData(data any) map[string]any {
|
||||
return map[string]any{"success": true, "data": data}
|
||||
}
|
||||
|
||||
func envelopeError(code, message string) map[string]any {
|
||||
return map[string]any{
|
||||
"success": false,
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
|
||||
core.OK(w, response)
|
||||
}
|
||||
|
||||
func (h *Handler) GetDatabaseStats(w http.ResponseWriter, r *http.Request) {
|
||||
core.OK(w, h.getDBStats())
|
||||
}
|
||||
|
||||
func (h *Handler) GetRedisStats(w http.ResponseWriter, r *http.Request) {
|
||||
core.OK(w, h.getRedisStats())
|
||||
}
|
||||
|
||||
func (h *Handler) GetRuntimeStats(w http.ResponseWriter, r *http.Request) {
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
response := RuntimeStats{
|
||||
GoVersion: runtime.Version(),
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
NumCPU: runtime.NumCPU(),
|
||||
MemAlloc: memStats.Alloc,
|
||||
MemSys: memStats.Sys,
|
||||
NumGC: memStats.NumGC,
|
||||
}
|
||||
|
||||
core.OK(w, response)
|
||||
}
|
||||
|
||||
func (h *Handler) getDBStats() *DBPoolStats {
|
||||
if h.dbStats == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stats := h.dbStats()
|
||||
return &DBPoolStats{
|
||||
MaxOpenConnections: stats.MaxOpenConnections,
|
||||
OpenConnections: stats.OpenConnections,
|
||||
InUse: stats.InUse,
|
||||
Idle: stats.Idle,
|
||||
WaitCount: stats.WaitCount,
|
||||
WaitDuration: stats.WaitDuration.String(),
|
||||
MaxIdleClosed: stats.MaxIdleClosed,
|
||||
MaxIdleTimeClosed: stats.MaxIdleTimeClosed,
|
||||
MaxLifetimeClosed: stats.MaxLifetimeClosed,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) getRedisStats() *RedisPoolStats {
|
||||
if h.redisStats == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
stats := h.redisStats()
|
||||
return &RedisPoolStats{
|
||||
Hits: stats.Hits,
|
||||
Misses: stats.Misses,
|
||||
Timeouts: stats.Timeouts,
|
||||
TotalConns: stats.TotalConns,
|
||||
IdleConns: stats.IdleConns,
|
||||
StaleConns: stats.StaleConns,
|
||||
}
|
||||
}
|
||||
|
||||
type SystemStatsResponse struct {
|
||||
Database DatabaseStatus `json:"database"`
|
||||
Redis RedisStatus `json:"redis"`
|
||||
Runtime RuntimeStats `json:"runtime"`
|
||||
}
|
||||
|
||||
type DatabaseStatus struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Stats *DBPoolStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
type RedisStatus struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Stats *RedisPoolStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
type DBPoolStats struct {
|
||||
MaxOpenConnections int `json:"max_open_connections"`
|
||||
OpenConnections int `json:"open_connections"`
|
||||
InUse int `json:"in_use"`
|
||||
Idle int `json:"idle"`
|
||||
WaitCount int64 `json:"wait_count"`
|
||||
WaitDuration string `json:"wait_duration"`
|
||||
MaxIdleClosed int64 `json:"max_idle_closed"`
|
||||
MaxIdleTimeClosed int64 `json:"max_idle_time_closed"`
|
||||
MaxLifetimeClosed int64 `json:"max_lifetime_closed"`
|
||||
}
|
||||
|
||||
type RedisPoolStats struct {
|
||||
Hits uint32 `json:"hits"`
|
||||
Misses uint32 `json:"misses"`
|
||||
Timeouts uint32 `json:"timeouts"`
|
||||
TotalConns uint32 `json:"total_conns"`
|
||||
IdleConns uint32 `json:"idle_conns"`
|
||||
StaleConns uint32 `json:"stale_conns"`
|
||||
}
|
||||
|
||||
type RuntimeStats struct {
|
||||
GoVersion string `json:"go_version"`
|
||||
NumGoroutine int `json:"num_goroutine"`
|
||||
NumCPU int `json:"num_cpu"`
|
||||
MemAlloc uint64 `json:"mem_alloc_bytes"`
|
||||
MemSys uint64 `json:"mem_sys_bytes"`
|
||||
NumGC uint32 `json:"num_gc"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,518 @@
|
|||
// ©AngelaMos | 2026
|
||||
// handler_test.go
|
||||
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"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/token"
|
||||
)
|
||||
|
||||
const (
|
||||
testBaseURL = "https://canary.example.com"
|
||||
testManageURL = "https://canary.example.com"
|
||||
)
|
||||
|
||||
type fakeURLBuilder struct{}
|
||||
|
||||
func (fakeURLBuilder) TriggerURL(id string) string {
|
||||
return testBaseURL + "/c/" + id
|
||||
}
|
||||
|
||||
func (fakeURLBuilder) ManageURL(manageID string) string {
|
||||
return testManageURL + "/m/" + manageID
|
||||
}
|
||||
|
||||
type fakeTokenRepo struct {
|
||||
mu sync.Mutex
|
||||
tokens []token.Token
|
||||
disabledCalls []string
|
||||
setEnabledErr error
|
||||
listErr error
|
||||
countErr error
|
||||
countByTypeErr error
|
||||
countByChannelErr error
|
||||
}
|
||||
|
||||
func newFakeTokenRepo() *fakeTokenRepo {
|
||||
return &fakeTokenRepo{tokens: []token.Token{}}
|
||||
}
|
||||
|
||||
func (f *fakeTokenRepo) ListAll(
|
||||
_ context.Context,
|
||||
opts token.ListOptions,
|
||||
) ([]token.Token, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
start := opts.Offset
|
||||
if start > len(f.tokens) {
|
||||
start = len(f.tokens)
|
||||
}
|
||||
end := start + opts.Limit
|
||||
if end > len(f.tokens) {
|
||||
end = len(f.tokens)
|
||||
}
|
||||
out := make([]token.Token, end-start)
|
||||
copy(out, f.tokens[start:end])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeTokenRepo) CountAll(_ context.Context) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.countErr != nil {
|
||||
return 0, f.countErr
|
||||
}
|
||||
return int64(len(f.tokens)), nil
|
||||
}
|
||||
|
||||
func (f *fakeTokenRepo) CountByType(
|
||||
_ context.Context,
|
||||
) ([]token.TypeCount, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.countByTypeErr != nil {
|
||||
return nil, f.countByTypeErr
|
||||
}
|
||||
counts := map[token.Type]int64{}
|
||||
for _, t := range f.tokens {
|
||||
counts[t.Type]++
|
||||
}
|
||||
out := []token.TypeCount{}
|
||||
for typ, c := range counts {
|
||||
out = append(out, token.TypeCount{Type: typ, Count: c})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeTokenRepo) CountByAlertChannel(
|
||||
_ context.Context,
|
||||
) ([]token.ChannelCount, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.countByChannelErr != nil {
|
||||
return nil, f.countByChannelErr
|
||||
}
|
||||
counts := map[token.AlertChannel]int64{}
|
||||
for _, t := range f.tokens {
|
||||
counts[t.AlertChannel]++
|
||||
}
|
||||
out := []token.ChannelCount{}
|
||||
for ch, c := range counts {
|
||||
out = append(out, token.ChannelCount{Channel: ch, Count: c})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeTokenRepo) SetEnabled(
|
||||
_ context.Context,
|
||||
id string,
|
||||
enabled bool,
|
||||
) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.disabledCalls = append(f.disabledCalls, id)
|
||||
if f.setEnabledErr != nil {
|
||||
return f.setEnabledErr
|
||||
}
|
||||
for i := range f.tokens {
|
||||
if f.tokens[i].ID == id {
|
||||
f.tokens[i].Enabled = enabled
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return token.ErrNotFound
|
||||
}
|
||||
|
||||
type fakeEventRepo struct {
|
||||
count int64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEventRepo) CountAll(_ context.Context) (int64, error) {
|
||||
if f.err != nil {
|
||||
return 0, f.err
|
||||
}
|
||||
return f.count, nil
|
||||
}
|
||||
|
||||
func quietLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func newRouter(h *admin.Handler) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
h.Register(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func seedToken(id string, typ token.Type, ch token.AlertChannel) token.Token {
|
||||
return token.Token{
|
||||
ID: id,
|
||||
ManageID: "mng-" + id,
|
||||
Type: typ,
|
||||
AlertChannel: ch,
|
||||
Enabled: true,
|
||||
Metadata: json.RawMessage(`{}`),
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmin_GetStats_HappyPath(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
repo.tokens = []token.Token{
|
||||
seedToken("a01", token.TypeWebbug, token.ChannelTelegram),
|
||||
seedToken("a02", token.TypeWebbug, token.ChannelWebhook),
|
||||
seedToken("a03", token.TypeDocx, token.ChannelTelegram),
|
||||
}
|
||||
events := &fakeEventRepo{count: 17}
|
||||
h := admin.NewHandler(repo, events, fakeURLBuilder{}, quietLogger())
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/stats", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "application/json", w.Header().Get("Content-Type"))
|
||||
|
||||
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.Equal(t, int64(3), body.Data.TokensCount)
|
||||
require.Equal(t, int64(17), body.Data.EventsCount)
|
||||
require.NotEmpty(t, body.Data.ByType)
|
||||
require.NotEmpty(t, body.Data.ByAlertChannel)
|
||||
|
||||
byType := map[token.Type]int64{}
|
||||
for _, c := range body.Data.ByType {
|
||||
byType[c.Type] = c.Count
|
||||
}
|
||||
require.Equal(t, int64(2), byType[token.TypeWebbug])
|
||||
require.Equal(t, int64(1), byType[token.TypeDocx])
|
||||
|
||||
byChan := map[token.AlertChannel]int64{}
|
||||
for _, c := range body.Data.ByAlertChannel {
|
||||
byChan[c.Channel] = c.Count
|
||||
}
|
||||
require.Equal(t, int64(2), byChan[token.ChannelTelegram])
|
||||
require.Equal(t, int64(1), byChan[token.ChannelWebhook])
|
||||
}
|
||||
|
||||
func TestAdmin_GetStats_TokenCountFails_500(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
repo.countErr = errors.New("db down")
|
||||
events := &fakeEventRepo{}
|
||||
h := admin.NewHandler(repo, events, fakeURLBuilder{}, quietLogger())
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/stats", nil))
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"INTERNAL_ERROR"`)
|
||||
}
|
||||
|
||||
func TestAdmin_GetStats_EventCountFails_500(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
events := &fakeEventRepo{err: errors.New("redis down")}
|
||||
h := admin.NewHandler(repo, events, fakeURLBuilder{}, quietLogger())
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/stats", nil))
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
|
||||
func TestAdmin_ListTokens_DefaultPagination(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
for i := range 3 {
|
||||
repo.tokens = append(repo.tokens, seedToken(
|
||||
"t0"+string(rune('a'+i)),
|
||||
token.TypeWebbug,
|
||||
token.ChannelWebhook,
|
||||
))
|
||||
}
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/tokens", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data admin.TokenListResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
|
||||
require.True(t, body.Success)
|
||||
require.Len(t, body.Data.Tokens, 3)
|
||||
require.Equal(t, int64(3), body.Data.Total)
|
||||
require.False(t, body.Data.Page.HasMore)
|
||||
require.Equal(t, 3, body.Data.Page.NextOffset)
|
||||
|
||||
require.Equal(t, testBaseURL+"/c/t0a", body.Data.Tokens[0].TriggerURL)
|
||||
require.Equal(t, testManageURL+"/m/mng-t0a", body.Data.Tokens[0].ManageURL)
|
||||
}
|
||||
|
||||
func TestAdmin_ListTokens_HasMoreWhenBeyondPage(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
for i := range 5 {
|
||||
repo.tokens = append(repo.tokens, seedToken(
|
||||
"row"+string(rune('a'+i)),
|
||||
token.TypeWebbug,
|
||||
token.ChannelWebhook,
|
||||
))
|
||||
}
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/tokens?limit=2",
|
||||
nil,
|
||||
))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
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.Equal(t, int64(5), body.Data.Total)
|
||||
require.True(t, body.Data.Page.HasMore)
|
||||
require.Equal(t, 2, body.Data.Page.NextOffset)
|
||||
}
|
||||
|
||||
func TestAdmin_ListTokens_OffsetPagesThrough(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
for i := range 5 {
|
||||
repo.tokens = append(repo.tokens, seedToken(
|
||||
"pg"+string(rune('a'+i)),
|
||||
token.TypeWebbug,
|
||||
token.ChannelWebhook,
|
||||
))
|
||||
}
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/tokens?limit=2&offset=4",
|
||||
nil,
|
||||
))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data admin.TokenListResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
|
||||
require.Len(t, body.Data.Tokens, 1)
|
||||
require.False(t, body.Data.Page.HasMore)
|
||||
require.Equal(t, 5, body.Data.Page.NextOffset)
|
||||
}
|
||||
|
||||
func TestAdmin_ListTokens_InvalidOffset_400(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
for _, badOffset := range []string{"-1", "abc", "1.5"} {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(
|
||||
http.MethodGet, "/tokens?offset="+badOffset, nil,
|
||||
))
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "offset=%s", badOffset)
|
||||
require.Contains(t, w.Body.String(), `"BAD_PARAM"`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmin_ListTokens_LimitCappedAt100(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
for i := range 150 {
|
||||
repo.tokens = append(repo.tokens, seedToken(
|
||||
"lim"+strItoa(i),
|
||||
token.TypeWebbug,
|
||||
token.ChannelWebhook,
|
||||
))
|
||||
}
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/tokens?limit=500",
|
||||
nil,
|
||||
))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data admin.TokenListResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
|
||||
require.Len(t, body.Data.Tokens, 100,
|
||||
"limit=500 must be capped to maxPageSize=100")
|
||||
}
|
||||
|
||||
func TestAdmin_ListTokens_RepoError_500(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
repo.listErr = errors.New("db down")
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/tokens", nil))
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
|
||||
func TestAdmin_DisableToken_HappyPath(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
repo.tokens = []token.Token{
|
||||
seedToken("disok0001a", token.TypeWebbug, token.ChannelWebhook),
|
||||
}
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/tokens/disok0001a/disable",
|
||||
nil,
|
||||
))
|
||||
require.Equal(t, http.StatusNoContent, w.Code)
|
||||
require.False(t, repo.tokens[0].Enabled)
|
||||
require.Equal(t, []string{"disok0001a"}, repo.disabledCalls)
|
||||
}
|
||||
|
||||
func TestAdmin_DisableToken_NotFound_404(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/tokens/missing/disable",
|
||||
nil,
|
||||
))
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"NOT_FOUND"`)
|
||||
}
|
||||
|
||||
func TestAdmin_DisableToken_RepoError_500(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
repo.tokens = []token.Token{
|
||||
seedToken("repoerr01a", token.TypeWebbug, token.ChannelWebhook),
|
||||
}
|
||||
repo.setEnabledErr = errors.New("db error")
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/tokens/repoerr01a/disable",
|
||||
nil,
|
||||
))
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"INTERNAL_ERROR"`)
|
||||
}
|
||||
|
||||
func TestAdmin_DisableToken_GetIsNotRouted(t *testing.T) {
|
||||
repo := newFakeTokenRepo()
|
||||
repo.tokens = []token.Token{
|
||||
seedToken("methodtest1", token.TypeWebbug, token.ChannelWebhook),
|
||||
}
|
||||
h := admin.NewHandler(
|
||||
repo,
|
||||
&fakeEventRepo{},
|
||||
fakeURLBuilder{},
|
||||
quietLogger(),
|
||||
)
|
||||
r := newRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/tokens/methodtest1/disable",
|
||||
nil,
|
||||
))
|
||||
require.Equal(t, http.StatusMethodNotAllowed, w.Code)
|
||||
}
|
||||
|
||||
func strItoa(i int) string {
|
||||
const digits = "0123456789"
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var out []byte
|
||||
for i > 0 {
|
||||
out = append([]byte{digits[i%10]}, out...)
|
||||
i /= 10
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
|
@ -138,6 +138,18 @@ func (r *Repository) CountByToken(
|
|||
return n, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountAll(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := r.db.GetContext(
|
||||
ctx,
|
||||
&n,
|
||||
`SELECT COUNT(*) FROM events`,
|
||||
); err != nil {
|
||||
return 0, fmt.Errorf("count events: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AttachFingerprint(
|
||||
ctx context.Context,
|
||||
tokenID, sourceIP string,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,28 @@ func TestRepository_FKCascade(t *testing.T) {
|
|||
require.Equal(t, int64(0), count, "cascade delete should remove all events")
|
||||
}
|
||||
|
||||
func TestRepository_CountAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := seedToken(t, tokRepo, "evtcntallall")
|
||||
|
||||
base, err := evtRepo.CountAll(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
for range 4 {
|
||||
require.NoError(t, evtRepo.Insert(ctx, &event.Event{
|
||||
TokenID: tok.ID,
|
||||
SourceIP: "203.0.113.99",
|
||||
}))
|
||||
}
|
||||
|
||||
got, err := evtRepo.CountAll(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, base+4, got)
|
||||
}
|
||||
|
||||
func TestRepository_AttachFingerprint(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
|
|
|
|||
|
|
@ -183,3 +183,41 @@ func (r *Repository) CountAll(ctx context.Context) (int64, error) {
|
|||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type TypeCount struct {
|
||||
Type Type `db:"type" json:"type"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
type ChannelCount struct {
|
||||
Channel AlertChannel `db:"alert_channel" json:"alert_channel"`
|
||||
Count int64 `db:"count" json:"count"`
|
||||
}
|
||||
|
||||
func (r *Repository) CountByType(
|
||||
ctx context.Context,
|
||||
) ([]TypeCount, error) {
|
||||
rows := []TypeCount{}
|
||||
q := `SELECT type, COUNT(*) AS count
|
||||
FROM tokens
|
||||
GROUP BY type
|
||||
ORDER BY type`
|
||||
if err := r.db.SelectContext(ctx, &rows, q); err != nil {
|
||||
return nil, fmt.Errorf("count tokens by type: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountByAlertChannel(
|
||||
ctx context.Context,
|
||||
) ([]ChannelCount, error) {
|
||||
rows := []ChannelCount{}
|
||||
q := `SELECT alert_channel, COUNT(*) AS count
|
||||
FROM tokens
|
||||
GROUP BY alert_channel
|
||||
ORDER BY alert_channel`
|
||||
if err := r.db.SelectContext(ctx, &rows, q); err != nil {
|
||||
return nil, fmt.Errorf("count tokens by channel: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,6 +200,56 @@ func TestRepository_ListAll(t *testing.T) {
|
|||
require.Equal(t, int64(3), count)
|
||||
}
|
||||
|
||||
func TestRepository_CountByType(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
webbug := sampleWebhookToken("cntbytype01a")
|
||||
webbug.Type = token.TypeWebbug
|
||||
require.NoError(t, repo.Insert(ctx, webbug))
|
||||
|
||||
webbug2 := sampleWebhookToken("cntbytype01b")
|
||||
webbug2.Type = token.TypeWebbug
|
||||
require.NoError(t, repo.Insert(ctx, webbug2))
|
||||
|
||||
docx := sampleTelegramToken("cntbytype02a")
|
||||
docx.Type = token.TypeDocx
|
||||
require.NoError(t, repo.Insert(ctx, docx))
|
||||
|
||||
rows, err := repo.CountByType(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := map[token.Type]int64{}
|
||||
for _, r := range rows {
|
||||
got[r.Type] = r.Count
|
||||
}
|
||||
require.GreaterOrEqual(t, got[token.TypeWebbug], int64(2))
|
||||
require.GreaterOrEqual(t, got[token.TypeDocx], int64(1))
|
||||
}
|
||||
|
||||
func TestRepository_CountByAlertChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
wh := sampleWebhookToken("cntbychan01a")
|
||||
require.NoError(t, repo.Insert(ctx, wh))
|
||||
|
||||
tg := sampleTelegramToken("cntbychan02a")
|
||||
require.NoError(t, repo.Insert(ctx, tg))
|
||||
|
||||
rows, err := repo.CountByAlertChannel(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := map[token.AlertChannel]int64{}
|
||||
for _, r := range rows {
|
||||
got[r.Channel] = r.Count
|
||||
}
|
||||
require.GreaterOrEqual(t, got[token.ChannelWebhook], int64(1))
|
||||
require.GreaterOrEqual(t, got[token.ChannelTelegram], int64(1))
|
||||
}
|
||||
|
||||
func TestRepository_TypeAndChannelValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
|
|
|
|||
Loading…
Reference in New Issue