- 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
877 lines
30 KiB
Go
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
|
|
}
|