Files
silo-server/internal/api/handlers/diagnostics_test.go
T
Quick104andClaude Fable 5 9fad08a6fa fix(diagnostics): address round-6 review findings on PR #445
- AdminDiagnostics list: fix regression where rows dereferenced the
  now-omitted manifest for app_build. Project app_build server-side out
  of manifest JSONB into both list and detail responses (cheap
  COALESCE(manifest->'report'->>'app_build','')), split the TS type into
  DiagnosticReportSummary (list, no manifest) and DiagnosticReport
  (detail, with manifest), and read report.app_build in the row/detail.
- embeddedManifestMatches: decode with json.Decoder + UseNumber so large
  integers above 2^53 (e.g. log_summary.lines) can't collapse to the same
  float and falsely match; re-assert no-trailing-data strictness.
- Quota reservation (SKIP): reserving the client-claimed archive.bytes is
  sound because archiveMatches requires claimed==actual before MarkReady,
  so no stored report exceeds its reservation; documented in a code comment.
- Multipart parts: reject a wrong-name/wrong-content-type part without
  calling part.Close(), which would drain up to the bundle limit while
  holding the in-flight slot; abandon it so malformed uploads fail promptly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e3QjbPo96ed9Mn2qRiUkh
2026-07-21 14:23:59 -04:00

877 lines
30 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/diagnostics"
)
func TestDiagnosticsUploadHappyPath(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, []diagnosticsPart{
{name: "manifest", contentType: "application/json", body: []byte(`{"ok":true}`)},
{name: "bundle", contentType: "application/gzip", body: []byte("bundle")},
}, accessClaims()))
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String())
}
var resp diagnostics.IngestResult
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ShortID != "SILO-ABCDEF123456" {
t.Fatalf("short_id = %q, want SILO-ABCDEF123456", resp.ShortID)
}
if service.ingestCalls != 1 {
t.Fatalf("ingest calls = %d, want 1", service.ingestCalls)
}
}
func TestDiagnosticsUploadManifestTooBig(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, []diagnosticsPart{
{name: "manifest", contentType: "application/json", body: bytes.Repeat([]byte("x"), int(diagnostics.MaxManifestBytes)+1)},
{name: "bundle", contentType: "application/gzip", body: []byte("bundle")},
}, accessClaims()))
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
assertDiagnosticsRejectionLog(t, logs, "too_large", 42)
}
func TestDiagnosticsUploadWrongPartOrder(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, []diagnosticsPart{
{name: "bundle", contentType: "application/gzip", body: []byte("bundle")},
{name: "manifest", contentType: "application/json", body: []byte(`{"ok":true}`)},
}, accessClaims()))
assertDiagnosticsError(t, rec, http.StatusBadRequest, "invalid_bundle")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
assertDiagnosticsRejectionLog(t, logs, "invalid_bundle", 42)
}
func TestDiagnosticsUploadWrongFirstPartNotDrained(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
// A large, wrongly-named first part must be rejected without draining its
// body: an invalid upload must not be allowed to stream its whole payload
// (holding the in-flight slot) before it gets a 400.
var body bytes.Buffer
writer := multipart.NewWriter(&body)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="bundle"`)
header.Set("Content-Type", "application/gzip")
w, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("create multipart part: %v", err)
}
if _, err := w.Write(bytes.Repeat([]byte("x"), 1<<20)); err != nil {
t.Fatalf("write multipart part: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
counter := &countingReader{r: &body}
req := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/reports", counter)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer token")
req = req.WithContext(apimw.SetClaims(req.Context(), accessClaims()))
rec := httptest.NewRecorder()
handler.HandleUpload(rec, req)
assertDiagnosticsError(t, rec, http.StatusBadRequest, "invalid_bundle")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
// Only the small multipart framing/headers should have been read; the 1 MiB
// wrong-first-part body must not have been drained.
if counter.n > 128*1024 {
t.Fatalf("read %d bytes from request body, want the wrong first part rejected without draining (< 128 KiB)", counter.n)
}
}
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func TestDiagnosticsUploadBodyTooLargeLogsRejection(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.MaxBundleBytes = 1
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
body := strings.NewReader(strings.Repeat("x\n", int(diagnosticsMultipartOverheadBytes/2)+2))
req := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/reports", body)
req.Header.Set("Content-Type", "multipart/form-data; boundary=diagnostics-boundary")
req.Header.Set("Authorization", "Bearer token")
req = req.WithContext(apimw.SetClaims(req.Context(), accessClaims()))
rec := httptest.NewRecorder()
handler.HandleUpload(rec, req)
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
assertDiagnosticsRejectionLog(t, logs, "too_large", 42)
}
func TestDiagnosticsUploadMissingPart(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, []diagnosticsPart{
{name: "manifest", contentType: "application/json", body: []byte(`{"ok":true}`)},
}, accessClaims()))
assertDiagnosticsError(t, rec, http.StatusBadRequest, "invalid_bundle")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsUploadDisabled(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.Status = diagnostics.StatusDisabled
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims()))
assertDiagnosticsError(t, rec, http.StatusForbidden, "disabled")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsUploadStorageUnavailable(t *testing.T) {
service := newFakeDiagnosticsService()
service.status.Status = diagnostics.StatusStorageUnavailable
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims()))
assertDiagnosticsError(t, rec, http.StatusServiceUnavailable, "storage_unavailable")
if service.ingestCalls != 0 {
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
}
}
func TestDiagnosticsUploadQuotaExceededSetsRetryAfter(t *testing.T) {
service := newFakeDiagnosticsService()
service.ingestErr = &diagnostics.QuotaError{Kind: diagnostics.QuotaKindReportsPerDay, Limit: 20}
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims()))
assertDiagnosticsError(t, rec, http.StatusTooManyRequests, "quota_exceeded")
if got := rec.Header().Get("Retry-After"); got != diagnosticsQuotaRetryAfter {
t.Fatalf("Retry-After = %q, want %q", got, diagnosticsQuotaRetryAfter)
}
}
func TestDiagnosticsUploadRejectsAPIKey(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
rec := httptest.NewRecorder()
req := newDiagnosticsUploadRequest(t, validDiagnosticsParts(), &auth.Claims{
UserID: 42,
TokenType: auth.TokenTypeAPIKey,
})
req.Header.Set("Authorization", "Bearer sa_test")
handler.HandleUpload(rec, req)
assertDiagnosticsError(t, rec, http.StatusForbidden, "api_key_not_allowed")
if service.statusCalls != 0 {
t.Fatalf("status calls = %d, want 0", service.statusCalls)
}
assertDiagnosticsRejectionLog(t, logs, "api_key_not_allowed", 42)
}
func TestDiagnosticsUploadBusy(t *testing.T) {
service := newFakeDiagnosticsService()
service.started = make(chan struct{})
service.block = make(chan struct{})
handler := NewDiagnosticsHandler(service)
logs := captureDiagnosticsLogs(handler)
firstReq := newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims())
firstDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
rec := httptest.NewRecorder()
handler.HandleUpload(rec, firstReq)
firstDone <- rec
}()
<-service.started
rec := httptest.NewRecorder()
handler.HandleUpload(rec, newDiagnosticsUploadRequest(t, validDiagnosticsParts(), accessClaims()))
assertDiagnosticsError(t, rec, http.StatusServiceUnavailable, "busy")
if got := rec.Header().Get("Retry-After"); got != diagnosticsBusyRetryAfter {
t.Fatalf("Retry-After = %q, want %q", got, diagnosticsBusyRetryAfter)
}
assertDiagnosticsRejectionLog(t, logs, "busy", 42)
close(service.block)
first := <-firstDone
if first.Code != http.StatusCreated {
t.Fatalf("first status = %d, want %d; body=%s", first.Code, http.StatusCreated, first.Body.String())
}
}
func TestDiagnosticsAdminListReportsParsesFilters(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
req := httptest.NewRequest(
http.MethodGet,
"/api/v1/admin/diagnostics/reports?user_id=42&platform=ios&report_type=crash&from=2026-07-19T10:00:00Z&to=2026-07-20T10:00:00Z&short_id=abcdef123456&limit=25&cursor=next",
nil,
)
rec := httptest.NewRecorder()
handler.HandleAdminListReports(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if service.listCalls != 1 {
t.Fatalf("list calls = %d, want 1", service.listCalls)
}
filters := service.listFilters
if filters.UserID == nil || *filters.UserID != 42 {
t.Fatalf("UserID = %v, want 42", filters.UserID)
}
if filters.Platform != "ios" || filters.ReportType != "crash" {
t.Fatalf("filters = %#v, want platform ios report_type crash", filters)
}
if filters.From == nil || !filters.From.Equal(time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)) {
t.Fatalf("From = %v, want expected timestamp", filters.From)
}
if filters.To == nil || !filters.To.Equal(time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)) {
t.Fatalf("To = %v, want expected timestamp", filters.To)
}
if filters.ShortID != "SILO-ABCDEF123456" || filters.Limit != 25 || filters.Cursor != "next" {
t.Fatalf("filters = %#v, want normalized short id, limit, cursor", filters)
}
}
func TestDiagnosticsAdminDownloadReturnsPresignedURLWhenAvailable(t *testing.T) {
service := newFakeDiagnosticsService()
service.getReport = readyDiagnosticsReport()
service.presignURL = "https://storage.example.test/report"
service.effectiveTTL = 5 * time.Minute
handler := NewDiagnosticsHandler(service)
before := time.Now().UTC()
rec := httptest.NewRecorder()
adminDiagnosticsRouter(handler).ServeHTTP(rec, adminDiagnosticsRequest(http.MethodGet, "/diagnostics/reports/report-1/download", adminClaims()))
after := time.Now().UTC()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp diagnosticsDownloadURLResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.DownloadURL != service.presignURL {
t.Fatalf("download_url = %q, want %q", resp.DownloadURL, service.presignURL)
}
if service.presignExpiry != service.effectiveTTL {
t.Fatalf("presign expiry = %s, want %s", service.presignExpiry, service.effectiveTTL)
}
if resp.ExpiresAt.Before(before.Add(service.effectiveTTL)) || resp.ExpiresAt.After(after.Add(service.effectiveTTL)) {
t.Fatalf("expires_at = %s, want within effective TTL window", resp.ExpiresAt)
}
if service.openCalls != 0 {
t.Fatalf("open calls = %d, want 0", service.openCalls)
}
}
func TestDiagnosticsAdminDownloadStreamsWhenProxyRequested(t *testing.T) {
service := newFakeDiagnosticsService()
service.getReport = readyDiagnosticsReport()
service.presignURL = "https://storage.example.test/report"
service.openData = []byte("gzipped report")
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
adminDiagnosticsRouter(handler).ServeHTTP(rec, adminDiagnosticsRequest(http.MethodGet, "/diagnostics/reports/report-1/download?proxy=1", adminClaims()))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if service.presignCalls != 0 {
t.Fatalf("presign calls = %d, want 0", service.presignCalls)
}
if service.openCalls != 1 {
t.Fatalf("open calls = %d, want 1", service.openCalls)
}
if rec.Body.String() != "gzipped report" {
t.Fatalf("body = %q, want streamed data", rec.Body.String())
}
}
func TestDiagnosticsAdminDownloadStreamsWhenPresignUnavailable(t *testing.T) {
service := newFakeDiagnosticsService()
service.getReport = readyDiagnosticsReport()
service.presignErr = errors.New("presign unavailable")
service.openData = []byte("gzipped report")
handler := NewDiagnosticsHandler(service)
rec := httptest.NewRecorder()
adminDiagnosticsRouter(handler).ServeHTTP(rec, adminDiagnosticsRequest(http.MethodGet, "/diagnostics/reports/report-1/download", adminClaims()))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != diagnostics.ReportDownloadContentType {
t.Fatalf("Content-Type = %q, want %q", got, diagnostics.ReportDownloadContentType)
}
if !strings.Contains(rec.Header().Get("Content-Disposition"), "silo-diagnostics-SILO-ABCDEF123456.tar.gz") {
t.Fatalf("Content-Disposition = %q, want diagnostic filename", rec.Header().Get("Content-Disposition"))
}
if rec.Body.String() != "gzipped report" {
t.Fatalf("body = %q, want streamed data", rec.Body.String())
}
}
func TestDiagnosticsAdminListReportsRejectsMalformedQuery(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/diagnostics/reports?user_id=abc", nil)
rec := httptest.NewRecorder()
handler.HandleAdminListReports(rec, req)
assertDiagnosticsError(t, rec, http.StatusBadRequest, "bad_request")
if service.listCalls != 0 {
t.Fatalf("list calls = %d, want 0", service.listCalls)
}
}
func TestDiagnosticsAdminListReportsRejectsMalformedCursor(t *testing.T) {
service := newFakeDiagnosticsService()
service.listErr = diagnostics.ErrInvalidCursor
handler := NewDiagnosticsHandler(service)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/diagnostics/reports?cursor=bad", nil)
rec := httptest.NewRecorder()
handler.HandleAdminListReports(rec, req)
assertDiagnosticsError(t, rec, http.StatusBadRequest, "bad_request")
}
func TestDiagnosticsAdminDeleteEmitsAuditEvent(t *testing.T) {
service := newFakeDiagnosticsService()
service.deleteReport = readyDiagnosticsReport()
var logs bytes.Buffer
handler := NewDiagnosticsHandler(service)
handler.logger = slog.New(slog.NewJSONHandler(&logs, nil))
rec := httptest.NewRecorder()
adminDiagnosticsRouter(handler).ServeHTTP(rec, adminDiagnosticsRequest(http.MethodDelete, "/diagnostics/reports/report-1", adminClaims()))
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
}
logLine := logs.String()
if !strings.Contains(logLine, `"msg":"diagnostic report deleted"`) ||
!strings.Contains(logLine, `"admin_user_id":7`) ||
!strings.Contains(logLine, `"report_id":"report-1"`) {
t.Fatalf("audit log = %s", logLine)
}
}
func TestDiagnosticsAdminRoutesRejectNonActingAdmin(t *testing.T) {
service := newFakeDiagnosticsService()
handler := NewDiagnosticsHandler(service)
router := chi.NewRouter()
router.Use(apimw.RequireActingAdmin(nil))
RegisterAdminDiagnosticsRoutes(router, handler)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, adminDiagnosticsRequest(http.MethodGet, "/diagnostics/reports", &auth.Claims{
UserID: 8,
Role: "user",
TokenType: auth.TokenTypeAccess,
}))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
if service.listCalls != 0 {
t.Fatalf("list calls = %d, want 0", service.listCalls)
}
}
func TestDiagnosticsUploadsEnablementRequiresStorageProbe(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{}}
handler := &AdminHandler{SettingsRepo: settings}
router := chi.NewRouter()
router.Put("/admin/settings/{key}", handler.HandleUpdateSetting)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+diagnostics.KeyUploadsEnabled,
strings.NewReader(`{"value":"true"}`),
))
assertDiagnosticsError(t, rec, http.StatusBadRequest, "storage_unavailable")
if settings.values[diagnostics.KeyUploadsEnabled] != "" {
t.Fatalf("setting persisted = %q, want empty", settings.values[diagnostics.KeyUploadsEnabled])
}
store := &fakeDiagnosticsEnablementStore{bucket: "private"}
handler.DiagnosticsStore = store
rec = httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+diagnostics.KeyUploadsEnabled,
strings.NewReader(`{"value":"true"}`),
))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if settings.values[diagnostics.KeyUploadsEnabled] != "true" {
t.Fatalf("setting persisted = %q, want true", settings.values[diagnostics.KeyUploadsEnabled])
}
if !sameStringSlice(store.ops, []string{"put:diagnostics/.probe", "delete:diagnostics/.probe"}) {
t.Fatalf("probe ops = %v, want put/delete probe", store.ops)
}
}
func TestDiagnosticsNumericSettingsAcceptBoundaries(t *testing.T) {
cases := []struct {
name string
key string
value string
initial map[string]string
}{
{name: "bundle minimum", key: diagnostics.KeyMaxBundleBytes, value: "1048576", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "1073741824"}},
{name: "bundle maximum", key: diagnostics.KeyMaxBundleBytes, value: "268435456", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "1073741824", diagnostics.KeyMaxBytesPerUser: "10737418240"}},
{name: "uncompressed bundle floor", key: diagnostics.KeyMaxUncompressedBytes, value: "1048576", initial: map[string]string{diagnostics.KeyMaxBundleBytes: "1048576"}},
{name: "uncompressed maximum", key: diagnostics.KeyMaxUncompressedBytes, value: "1073741824", initial: map[string]string{diagnostics.KeyMaxBundleBytes: "1048576"}},
{name: "reports minimum", key: diagnostics.KeyMaxReportsPerUserDay, value: "1"},
{name: "reports maximum", key: diagnostics.KeyMaxReportsPerUserDay, value: "1000"},
{name: "retention minimum", key: diagnostics.KeyRetentionDays, value: "1"},
{name: "retention maximum", key: diagnostics.KeyRetentionDays, value: "365"},
{name: "user bytes minimum", key: diagnostics.KeyMaxBytesPerUser, value: "10485760"},
{name: "user bytes maximum", key: diagnostics.KeyMaxBytesPerUser, value: "10737418240"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
settings := &fakeServerSettingsStore{values: tc.initial}
rec := updateDiagnosticsSetting(t, &AdminHandler{SettingsRepo: settings}, tc.key, tc.value)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if settings.values[tc.key] != tc.value {
t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.value)
}
})
}
}
func TestDiagnosticsNumericSettingsRejectOutOfRangeValues(t *testing.T) {
cases := []struct {
name string
key string
value string
initial map[string]string
}{
{name: "bundle below minimum", key: diagnostics.KeyMaxBundleBytes, value: "1048575"},
{name: "bundle above maximum", key: diagnostics.KeyMaxBundleBytes, value: "268435457", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "1073741824"}},
{name: "bundle above uncompressed", key: diagnostics.KeyMaxBundleBytes, value: "67108865", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "67108864"}},
{name: "uncompressed below bundle", key: diagnostics.KeyMaxUncompressedBytes, value: "10485759", initial: map[string]string{diagnostics.KeyMaxBundleBytes: "10485760"}},
{name: "bundle above per-user cap", key: diagnostics.KeyMaxBundleBytes, value: "104857600", initial: map[string]string{diagnostics.KeyMaxUncompressedBytes: "1073741824", diagnostics.KeyMaxBytesPerUser: "10485760"}},
{name: "per-user below bundle", key: diagnostics.KeyMaxBytesPerUser, value: "10485760", initial: map[string]string{diagnostics.KeyMaxBundleBytes: "104857600", diagnostics.KeyMaxUncompressedBytes: "1073741824"}},
{name: "uncompressed above maximum", key: diagnostics.KeyMaxUncompressedBytes, value: "1073741825"},
{name: "reports below minimum", key: diagnostics.KeyMaxReportsPerUserDay, value: "0"},
{name: "reports above maximum", key: diagnostics.KeyMaxReportsPerUserDay, value: "1001"},
{name: "retention below minimum", key: diagnostics.KeyRetentionDays, value: "0"},
{name: "retention above maximum", key: diagnostics.KeyRetentionDays, value: "366"},
{name: "user bytes below minimum", key: diagnostics.KeyMaxBytesPerUser, value: "10485759"},
{name: "user bytes above maximum", key: diagnostics.KeyMaxBytesPerUser, value: "10737418241"},
{name: "not an integer", key: diagnostics.KeyRetentionDays, value: "thirty"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
settings := &fakeServerSettingsStore{values: tc.initial}
rec := updateDiagnosticsSetting(t, &AdminHandler{SettingsRepo: settings}, tc.key, tc.value)
body := rec.Body.String()
assertDiagnosticsError(t, rec, http.StatusBadRequest, "bad_request")
if _, stored := settings.values[tc.key]; stored {
t.Fatalf("invalid value was stored: %#v", settings.values)
}
if !strings.Contains(body, tc.key) {
t.Fatalf("error body = %s, want clear error naming %s", body, tc.key)
}
})
}
}
type diagnosticsPart struct {
name string
contentType string
body []byte
}
func validDiagnosticsParts() []diagnosticsPart {
return []diagnosticsPart{
{name: "manifest", contentType: "application/json", body: []byte(`{"ok":true}`)},
{name: "bundle", contentType: "application/gzip", body: []byte("bundle")},
}
}
func newDiagnosticsUploadRequest(t *testing.T, parts []diagnosticsPart, claims *auth.Claims) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for _, part := range parts {
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="`+part.name+`"`)
header.Set("Content-Type", part.contentType)
w, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("create multipart part: %v", err)
}
if _, err := w.Write(part.body); err != nil {
t.Fatalf("write multipart part: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/reports", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer token")
if claims != nil {
req = req.WithContext(apimw.SetClaims(req.Context(), claims))
}
return req
}
func accessClaims() *auth.Claims {
return &auth.Claims{
UserID: 42,
TokenType: auth.TokenTypeAccess,
}
}
func assertDiagnosticsError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
t.Helper()
if rec.Code != status {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, status, rec.Body.String())
}
var resp struct {
Error string `json:"error"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode error response: %v", err)
}
if resp.Error != code {
t.Fatalf("error = %q, want %q", resp.Error, code)
}
}
func captureDiagnosticsLogs(handler *DiagnosticsHandler) *bytes.Buffer {
var logs bytes.Buffer
handler.logger = slog.New(slog.NewJSONHandler(&logs, nil))
return &logs
}
func assertDiagnosticsRejectionLog(t *testing.T, logs *bytes.Buffer, reason string, userID int) {
t.Helper()
line := logs.String()
for _, want := range []string{
`"msg":"diagnostic report rejected"`,
`"component":"diagnostics"`,
`"result":"rejected"`,
`"reason":"` + reason + `"`,
`"user_id":` + strconv.Itoa(userID),
} {
if !strings.Contains(line, want) {
t.Fatalf("rejection log = %s, want %s", line, want)
}
}
}
func updateDiagnosticsSetting(t *testing.T, handler *AdminHandler, key, value string) *httptest.ResponseRecorder {
t.Helper()
router := chi.NewRouter()
router.Put("/admin/settings/{key}", handler.HandleUpdateSetting)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+key,
strings.NewReader(`{"value":"`+value+`"}`),
))
return rec
}
func readyDiagnosticsReport() *diagnostics.Report {
blobBucket := "private"
blobKey := "diagnostics/7/report-1.tar.gz"
blobBytes := int64(13)
return &diagnostics.Report{
ID: "report-1",
ShortID: "SILO-ABCDEF123456",
UserID: 7,
State: diagnostics.StateReady,
ReportType: "crash",
Platform: "android-tv",
BlobBucket: &blobBucket,
BlobKey: &blobKey,
BlobBytes: &blobBytes,
}
}
func adminDiagnosticsRouter(handler *DiagnosticsHandler) chi.Router {
router := chi.NewRouter()
RegisterAdminDiagnosticsRoutes(router, handler)
return router
}
func adminDiagnosticsRequest(method, target string, claims *auth.Claims) *http.Request {
req := httptest.NewRequest(method, target, nil)
if claims != nil {
req = req.WithContext(apimw.SetClaims(req.Context(), claims))
}
return req
}
func adminClaims() *auth.Claims {
return &auth.Claims{
UserID: 7,
Role: "admin",
TokenType: auth.TokenTypeAccess,
}
}
type fakeDiagnosticsService struct {
mu sync.Mutex
status diagnostics.Status
statusCalls int
ingestCalls int
ingestErr error
started chan struct{}
block chan struct{}
listCalls int
listFilters diagnostics.ListFilters
listResult diagnostics.ListResult
listErr error
getReport *diagnostics.Report
getErr error
presignURL string
presignErr error
presignCalls int
presignExpiry time.Duration
effectiveTTL time.Duration
openCalls int
openData []byte
openErr error
deleteReport *diagnostics.Report
deleteErr error
}
func newFakeDiagnosticsService() *fakeDiagnosticsService {
return &fakeDiagnosticsService{
status: diagnostics.Status{
Status: diagnostics.StatusAvailable,
ServerInstanceID: "server-1",
AcceptedSchemaVersions: []int{1},
MaxBundleBytes: diagnostics.DefaultMaxBundleBytes,
MaxManifestBytes: diagnostics.MaxManifestBytes,
RetentionDays: diagnostics.DefaultRetentionDays,
ConsentNoticeVersion: diagnostics.DefaultConsentNoticeVer,
},
}
}
func (f *fakeDiagnosticsService) Status(context.Context, int) (diagnostics.Status, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.statusCalls++
return f.status, nil
}
func (f *fakeDiagnosticsService) Ingest(_ context.Context, _ int, _ *string, _ []byte, bundle io.Reader) (diagnostics.IngestResult, error) {
f.mu.Lock()
f.ingestCalls++
started := f.started
block := f.block
err := f.ingestErr
f.mu.Unlock()
if started != nil {
close(started)
}
if block != nil {
<-block
}
if err != nil {
return diagnostics.IngestResult{}, err
}
if _, err := io.ReadAll(bundle); err != nil {
return diagnostics.IngestResult{}, err
}
return diagnostics.IngestResult{
ReportID: "11111111-1111-1111-1111-111111111111",
ShortID: "SILO-ABCDEF123456",
}, nil
}
func (f *fakeDiagnosticsService) ListForAdmin(_ context.Context, filters diagnostics.ListFilters) (diagnostics.ListResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.listCalls++
f.listFilters = filters
if f.listErr != nil {
return diagnostics.ListResult{}, f.listErr
}
return f.listResult, nil
}
func (f *fakeDiagnosticsService) GetReport(context.Context, string) (*diagnostics.Report, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.getErr != nil {
return nil, f.getErr
}
if f.getReport != nil {
return f.getReport, nil
}
return readyDiagnosticsReport(), nil
}
func (f *fakeDiagnosticsService) PresignReportDownload(_ context.Context, _ *diagnostics.Report, expiry time.Duration) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.presignCalls++
f.presignExpiry = expiry
if f.presignErr != nil {
return "", f.presignErr
}
return f.presignURL, nil
}
func (f *fakeDiagnosticsService) EffectiveReportDownloadTTL(requested time.Duration) time.Duration {
f.mu.Lock()
defer f.mu.Unlock()
if f.effectiveTTL > 0 {
return f.effectiveTTL
}
return requested
}
func (f *fakeDiagnosticsService) OpenReportDownload(context.Context, *diagnostics.Report) (io.ReadCloser, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.openCalls++
if f.openErr != nil {
return nil, f.openErr
}
return io.NopCloser(bytes.NewReader(f.openData)), nil
}
func (f *fakeDiagnosticsService) DeleteReport(context.Context, string) (*diagnostics.Report, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.deleteErr != nil {
return nil, f.deleteErr
}
if f.deleteReport != nil {
return f.deleteReport, nil
}
return readyDiagnosticsReport(), nil
}
type fakeDiagnosticsEnablementStore struct {
bucket string
ops []string
}
func (f *fakeDiagnosticsEnablementStore) PutStream(_ context.Context, _ string, key string, r io.Reader, _ string) error {
if _, err := io.ReadAll(r); err != nil {
return err
}
f.ops = append(f.ops, "put:"+key)
return nil
}
func (f *fakeDiagnosticsEnablementStore) DeleteObject(_ context.Context, _ string, key string) error {
f.ops = append(f.ops, "delete:"+key)
return nil
}
func (f *fakeDiagnosticsEnablementStore) Bucket() string {
return f.bucket
}
func sameStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}