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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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 {
|
type Handler struct {
|
||||||
dbStats func() sql.DBStats
|
tokens TokenRepository
|
||||||
redisStats func() *redis.PoolStats
|
events EventRepository
|
||||||
redisPing func(ctx context.Context) error
|
urls URLBuilder
|
||||||
dbPing func(ctx context.Context) error
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type HandlerConfig struct {
|
func NewHandler(
|
||||||
DBStats func() sql.DBStats
|
tokens TokenRepository,
|
||||||
RedisStats func() *redis.PoolStats
|
events EventRepository,
|
||||||
RedisPing func(ctx context.Context) error
|
urls URLBuilder,
|
||||||
DBPing func(ctx context.Context) error
|
logger *slog.Logger,
|
||||||
}
|
) *Handler {
|
||||||
|
if logger == nil {
|
||||||
func NewHandler(cfg HandlerConfig) *Handler {
|
logger = slog.Default()
|
||||||
|
}
|
||||||
return &Handler{
|
return &Handler{
|
||||||
dbStats: cfg.DBStats,
|
tokens: tokens,
|
||||||
redisStats: cfg.RedisStats,
|
events: events,
|
||||||
redisPing: cfg.RedisPing,
|
urls: urls,
|
||||||
dbPing: cfg.DBPing,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) Register(r chi.Router) {
|
||||||
r.Route("/admin", func(r chi.Router) {
|
r.Get("/stats", h.GetStats)
|
||||||
r.Get("/stats", h.GetSystemStats)
|
r.Get("/tokens", h.ListTokens)
|
||||||
r.Get("/stats/db", h.GetDatabaseStats)
|
r.Post("/tokens/{"+urlParamID+"}/disable", h.DisableToken)
|
||||||
r.Get("/stats/redis", h.GetRedisStats)
|
|
||||||
r.Get("/stats/runtime", h.GetRuntimeStats)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetSystemStats(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
dbHealthy := true
|
tokensCount, err := h.tokens.CountAll(ctx)
|
||||||
if h.dbPing != nil {
|
if err != nil {
|
||||||
if err := h.dbPing(ctx); err != nil {
|
h.logger.ErrorContext(ctx, "admin: count tokens", "error", err)
|
||||||
dbHealthy = false
|
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
|
func parseOffset(raw string) (int, error) {
|
||||||
if h.redisPing != nil {
|
raw = strings.TrimSpace(raw)
|
||||||
if err := h.redisPing(ctx); err != nil {
|
if raw == "" {
|
||||||
redisHealthy = false
|
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
|
func parseLimit(raw string) int {
|
||||||
runtime.ReadMemStats(&memStats)
|
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{
|
func (h *Handler) writeJSON(
|
||||||
Database: DatabaseStatus{
|
w http.ResponseWriter,
|
||||||
Healthy: dbHealthy,
|
status int,
|
||||||
Stats: h.getDBStats(),
|
body any,
|
||||||
},
|
) {
|
||||||
Redis: RedisStatus{
|
w.Header().Set(headerContentType, contentTypeJSON)
|
||||||
Healthy: redisHealthy,
|
w.WriteHeader(status)
|
||||||
Stats: h.getRedisStats(),
|
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||||
},
|
h.logger.Warn("write json response", "error", err)
|
||||||
Runtime: RuntimeStats{
|
}
|
||||||
GoVersion: runtime.Version(),
|
}
|
||||||
NumGoroutine: runtime.NumGoroutine(),
|
|
||||||
NumCPU: runtime.NumCPU(),
|
func (h *Handler) writeInternal(w http.ResponseWriter) {
|
||||||
MemAlloc: memStats.Alloc,
|
h.writeJSON(w, http.StatusInternalServerError, envelopeError(
|
||||||
MemSys: memStats.Sys,
|
errorCodeInternalError, respMessageInternalError,
|
||||||
NumGC: memStats.NumGC,
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
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(
|
func (r *Repository) AttachFingerprint(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tokenID, sourceIP string,
|
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")
|
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) {
|
func TestRepository_AttachFingerprint(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
_, tokRepo, evtRepo := newRepos(t)
|
_, tokRepo, evtRepo := newRepos(t)
|
||||||
|
|
|
||||||
|
|
@ -183,3 +183,41 @@ func (r *Repository) CountAll(ctx context.Context) (int64, error) {
|
||||||
}
|
}
|
||||||
return n, nil
|
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)
|
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) {
|
func TestRepository_TypeAndChannelValidation(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
repo := newRepo(t)
|
repo := newRepo(t)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue