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:
CarterPerez-dev 2026-05-14 06:57:28 -04:00
parent 426ed54594
commit 9e82f70841
7 changed files with 890 additions and 162 deletions

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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