diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/dto.go b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/dto.go new file mode 100644 index 00000000..20f6ffa3 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/dto.go @@ -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"` +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go index 7f5b67ff..2aa7c7c0 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler.go @@ -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"` } diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler_test.go new file mode 100644 index 00000000..43d138b5 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/admin/handler_test.go @@ -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) +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go index 3f9e4952..b6405346 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository.go @@ -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, diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go index 9d56390c..3ec40bd5 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/repository_test.go @@ -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) diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go index 378c2f47..7303653b 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository.go @@ -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 +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go index 3358a437..b0f9707e 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/repository_test.go @@ -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)