* feat(diagnostics): chunked report upload fallback for proxy body caps
Diagnostics bundles can be up to max_bundle_bytes (10 MiB default), but a
reverse proxy in front of Silo commonly caps request bodies at nginx's
default client_max_body_size of 1 MiB. Such a proxy answers the single-shot
multipart upload with its own 413 before Silo ever sees the request, so any
report over the cap could never be delivered.
Add a chunked upload fallback under /api/v1/diagnostics/reports/uploads:
- POST / {manifest, bundle_bytes} opens a session
- PUT /{id}/chunks/{index} streams one ≤768 KiB chunk (proxy-safe)
- POST /{id}/complete ingests the assembled bundle
- DELETE /{id} best-effort abandon
The assembled bundle goes through the exact same Ingest path as the
single-shot endpoint, so every content check (manifest contract, archive
sha/bytes/entries, quotas, profile attribution) applies identically.
Sessions reuse internal/uploads (the plugin chunked-upload spool manager)
plus a small owner map for per-user isolation; they spool to disk, expire
after 15 minutes, cap at one per user / 16 global, and complete shares the
existing per-user + global in-flight ingest limiter.
/diagnostics/status now advertises upload_chunk_bytes so clients can detect
support; older servers omit the field and clients treat that as
unsupported. The demo guard's diagnostics prefix gains PUT to cover the
chunk route.
Verified end to end against an OpenResty proxy with a 1m body cap: the
single-shot upload 413s, the same 1.6 MiB bundle uploads in three chunks
and lands as an accepted report; also exercised from the tvOS client's
fallback path in the simulator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(diagnostics): harden chunked upload sessions per review
- Reserve the per-user slot and global cap atomically in init (a
reservation map counted with live sessions), so concurrent inits by one
account can no longer fan out past one session or transiently exceed the
cap. Creation failures roll the reservation back.
- Move chunk body I/O outside the uploads.Manager mutex: a slow client
streaming one chunk no longer serializes every other session's chunk
writes, completes, and cancels. A per-chunk in-flight flag rejects
duplicate concurrent writes to the same offset (ErrChunkBusy → 409), and
cancel/expiry defer spool-directory removal to the last finishing
writer.
- Chunk arrivals refresh the session expiry, making the TTL an idle
timeout instead of an absolute deadline so a slow-but-progressing upload
cannot expire mid-transfer.
- Extend the request read deadline on chunk PUTs and both deadlines on
complete, matching the single-shot handler's slow-uplink handling.
- Keep the session when complete's availability re-check fails
transiently (status load error → 500): only definitive
disabled/storage-unavailable answers discard the spool, so a retried
complete succeeds without re-uploading every chunk.
- Reclaim orphaned spool directories at startup (a restart previously
stranded the old process's partial uploads forever) and sweep expired
sessions on a timer instead of only from later init traffic.
- Document that session state is process-local and what that means for
multi-replica deployments.
Adds concurrency/race tests (go test -race) for atomic admission,
same-chunk write exclusion, expiry refresh, transient-status retry, and
startup reclaim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(diagnostics): count detached chunk writers and lift chunk PUT write deadline
Second review round:
- A canceled session whose slow chunk writer was still draining held a
connection and spool disk but vanished from every count, so a
cancel-and-reinit loop could stack unbounded live writers behind the
16-session cap. The uploads manager now parks such sessions in a
detached set (exposed as DetachedWriterSessions) until their last
writer returns, and diagnostics init counts them in its admission gate.
- Chunk PUTs now extend the write deadline as well as the read deadline:
on an uplink slow enough to eat the server's 120s WriteTimeout, the
stored chunk's JSON acknowledgement would otherwise be lost and the
client would retry an already-accepted chunk.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
425 lines
16 KiB
Go
425 lines
16 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
var errFakeStatusUnavailable = errors.New("status store temporarily unavailable")
|
|
|
|
// newChunkedTestHandler returns a handler whose chunk spool lives in the
|
|
// test's temp dir so parallel test runs never share state.
|
|
func newChunkedTestHandler(t *testing.T, service DiagnosticsService) *DiagnosticsHandler {
|
|
t.Helper()
|
|
handler := NewDiagnosticsHandler(service)
|
|
handler.chunkSessions = newDiagnosticsChunkSessions(t.TempDir())
|
|
return handler
|
|
}
|
|
|
|
func chunkedRouter(handler *DiagnosticsHandler) chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Post("/uploads", handler.HandleChunkedUploadInit)
|
|
r.Put("/uploads/{upload_id}/chunks/{chunk_index}", handler.HandleChunkedUploadChunk)
|
|
r.Post("/uploads/{upload_id}/complete", handler.HandleChunkedUploadComplete)
|
|
r.Delete("/uploads/{upload_id}", handler.HandleChunkedUploadAbort)
|
|
return r
|
|
}
|
|
|
|
func doChunked(t *testing.T, router chi.Router, method, path string, body []byte, claims *auth.Claims) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer token")
|
|
if method == http.MethodPost && strings.HasSuffix(path, "/uploads") {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if claims != nil {
|
|
req = req.WithContext(apimw.SetClaims(req.Context(), claims))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func initChunkedUpload(t *testing.T, router chi.Router, bundleBytes int, claims *auth.Claims) diagnosticsChunkInitResponse {
|
|
t.Helper()
|
|
body := []byte(fmt.Sprintf(`{"manifest":{"ok":true},"bundle_bytes":%d}`, bundleBytes))
|
|
rec := doChunked(t, router, http.MethodPost, "/uploads", body, claims)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("init status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String())
|
|
}
|
|
var resp diagnosticsChunkInitResponse
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode init response: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func TestDiagnosticsChunkedUploadHappyPath(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
bundle := bytes.Repeat([]byte("b"), int(diagnostics.UploadChunkBytes)+512)
|
|
session := initChunkedUpload(t, router, len(bundle), accessClaims())
|
|
if session.ChunkBytes != diagnostics.UploadChunkBytes {
|
|
t.Fatalf("chunk_bytes = %d, want %d", session.ChunkBytes, diagnostics.UploadChunkBytes)
|
|
}
|
|
if session.TotalChunks != 2 {
|
|
t.Fatalf("total_chunks = %d, want 2", session.TotalChunks)
|
|
}
|
|
|
|
for index := 0; index < session.TotalChunks; index++ {
|
|
start := int64(index) * session.ChunkBytes
|
|
end := start + session.ChunkBytes
|
|
if end > int64(len(bundle)) {
|
|
end = int64(len(bundle))
|
|
}
|
|
rec := doChunked(t, router, http.MethodPut,
|
|
fmt.Sprintf("/uploads/%s/chunks/%d", session.UploadID, index),
|
|
bundle[start:end], accessClaims())
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("chunk %d status = %d; body=%s", index, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
rec := doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("complete status = %d; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp diagnostics.IngestResult
|
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode complete response: %v", err)
|
|
}
|
|
if resp.ShortID != "SILO-ABCDEF123456" {
|
|
t.Fatalf("short_id = %q", resp.ShortID)
|
|
}
|
|
if service.ingestCalls != 1 {
|
|
t.Fatalf("ingest calls = %d, want 1", service.ingestCalls)
|
|
}
|
|
if got := string(service.lastManifest); got != `{"ok":true}` {
|
|
t.Fatalf("manifest passed to ingest = %q", got)
|
|
}
|
|
if int(service.lastBundleBytes) != len(bundle) {
|
|
t.Fatalf("bundle bytes passed to ingest = %d, want %d", service.lastBundleBytes, len(bundle))
|
|
}
|
|
|
|
// The ingest slot must be free again after completion.
|
|
release, acquired := handler.inflight.acquire(42)
|
|
if !acquired {
|
|
t.Fatal("in-flight slot still held after complete")
|
|
}
|
|
release()
|
|
}
|
|
|
|
func TestDiagnosticsChunkedUploadInitOverBundleLimit(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
service.status.MaxBundleBytes = 1024
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
rec := doChunked(t, router, http.MethodPost, "/uploads",
|
|
[]byte(`{"manifest":{"ok":true},"bundle_bytes":2048}`), accessClaims())
|
|
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
|
|
|
|
// Rejected init must not leave the limiter slot held.
|
|
release, acquired := handler.inflight.acquire(42)
|
|
if !acquired {
|
|
t.Fatal("in-flight slot held after rejected init")
|
|
}
|
|
release()
|
|
}
|
|
|
|
func TestDiagnosticsChunkedUploadInitDisabled(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
service.status.Status = diagnostics.StatusDisabled
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
rec := doChunked(t, router, http.MethodPost, "/uploads",
|
|
[]byte(`{"manifest":{"ok":true},"bundle_bytes":10}`), accessClaims())
|
|
assertDiagnosticsError(t, rec, http.StatusForbidden, "disabled")
|
|
}
|
|
|
|
func TestDiagnosticsChunkedUploadForeignSessionRejected(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
session := initChunkedUpload(t, router, 10, accessClaims())
|
|
|
|
other := &auth.Claims{UserID: 7, TokenType: auth.TokenTypeAccess}
|
|
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
|
|
[]byte("0123456789"), other)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("foreign chunk status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, other)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("foreign complete status = %d, want 404", rec.Code)
|
|
}
|
|
// A foreign abort reports success but must not destroy the session.
|
|
rec = doChunked(t, router, http.MethodDelete, "/uploads/"+session.UploadID, nil, other)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("foreign abort status = %d, want 204", rec.Code)
|
|
}
|
|
rec = doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
|
|
[]byte("0123456789"), accessClaims())
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("owner chunk after foreign abort = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsChunkedUploadIncompleteComplete(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
bundle := bytes.Repeat([]byte("b"), int(diagnostics.UploadChunkBytes)+512)
|
|
session := initChunkedUpload(t, router, len(bundle), accessClaims())
|
|
|
|
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
|
|
bundle[:diagnostics.UploadChunkBytes], accessClaims())
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("chunk status = %d", rec.Code)
|
|
}
|
|
|
|
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("incomplete complete status = %d, want 409; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if service.ingestCalls != 0 {
|
|
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsChunkedUploadReinitReplacesPreviousSession(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
first := initChunkedUpload(t, router, 10, accessClaims())
|
|
second := initChunkedUpload(t, router, 10, accessClaims())
|
|
if first.UploadID == second.UploadID {
|
|
t.Fatal("re-init returned the same session id")
|
|
}
|
|
|
|
// The replaced session is gone; the new one accepts chunks.
|
|
rec := doChunked(t, router, http.MethodPut, "/uploads/"+first.UploadID+"/chunks/0",
|
|
[]byte("0123456789"), accessClaims())
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("replaced-session chunk status = %d, want 404", rec.Code)
|
|
}
|
|
rec = doChunked(t, router, http.MethodPut, "/uploads/"+second.UploadID+"/chunks/0",
|
|
[]byte("0123456789"), accessClaims())
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("new-session chunk status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
rec = doChunked(t, router, http.MethodDelete, "/uploads/"+second.UploadID, nil, accessClaims())
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("abort status = %d, want 204", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsChunkedCompleteBusyKeepsSessionRetryable(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
session := initChunkedUpload(t, router, 10, accessClaims())
|
|
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
|
|
[]byte("0123456789"), accessClaims())
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("chunk status = %d", rec.Code)
|
|
}
|
|
|
|
// Occupy the user's ingest slot: complete must answer busy without
|
|
// consuming the spooled session.
|
|
release, acquired := handler.inflight.acquire(42)
|
|
if !acquired {
|
|
t.Fatal("could not occupy in-flight slot")
|
|
}
|
|
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
|
|
assertDiagnosticsError(t, rec, http.StatusServiceUnavailable, "busy")
|
|
release()
|
|
|
|
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("retried complete status = %d; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if service.ingestCalls != 1 {
|
|
t.Fatalf("ingest calls = %d, want 1", service.ingestCalls)
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsChunkedUploadOversizedChunkRejected(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
bundle := bytes.Repeat([]byte("b"), int(diagnostics.UploadChunkBytes)*2)
|
|
session := initChunkedUpload(t, router, len(bundle), accessClaims())
|
|
|
|
oversized := bytes.Repeat([]byte("x"), int(diagnostics.UploadChunkBytes)+1)
|
|
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
|
|
oversized, accessClaims())
|
|
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("oversized chunk status = %d, want 400 or 413; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if service.ingestCalls != 0 {
|
|
t.Fatalf("ingest calls = %d, want 0", service.ingestCalls)
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsChunkedUploadManifestTooLarge(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
manifest := `{"pad":"` + strings.Repeat("x", int(diagnostics.MaxManifestBytes)) + `"}`
|
|
rec := doChunked(t, router, http.MethodPost, "/uploads",
|
|
[]byte(`{"manifest":`+manifest+`,"bundle_bytes":10}`), accessClaims())
|
|
assertDiagnosticsError(t, rec, http.StatusRequestEntityTooLarge, "too_large")
|
|
}
|
|
|
|
func TestDiagnosticsChunkedCompleteIngestErrorMapsLikeSingleShot(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
service.ingestErr = diagnostics.ErrStaleConsent
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
session := initChunkedUpload(t, router, 10, accessClaims())
|
|
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
|
|
[]byte("0123456789"), accessClaims())
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("chunk status = %d", rec.Code)
|
|
}
|
|
|
|
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
|
|
assertDiagnosticsError(t, rec, http.StatusBadRequest, "stale_consent")
|
|
|
|
// The failed session is consumed; a retry needs a fresh init and the slot
|
|
// must be free for it.
|
|
initChunkedUpload(t, router, 10, accessClaims())
|
|
}
|
|
|
|
func TestDiagnosticsChunkedUploadConcurrentInitsRespectSlotAtomically(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
// Fire concurrent inits for the same user. The reservation must admit at
|
|
// most one at a time — the losers get busy — so a single account can
|
|
// never fan out to multiple sessions and eat the global cap.
|
|
const attempts = 8
|
|
var wg sync.WaitGroup
|
|
codes := make([]int, attempts)
|
|
for i := 0; i < attempts; i++ {
|
|
wg.Add(1)
|
|
go func(slot int) {
|
|
defer wg.Done()
|
|
rec := doChunked(t, router, http.MethodPost, "/uploads",
|
|
[]byte(`{"manifest":{"ok":true},"bundle_bytes":10}`), accessClaims())
|
|
codes[slot] = rec.Code
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
created := 0
|
|
for _, code := range codes {
|
|
switch code {
|
|
case http.StatusCreated:
|
|
created++
|
|
case http.StatusServiceUnavailable:
|
|
default:
|
|
t.Fatalf("unexpected init status %d", code)
|
|
}
|
|
}
|
|
if created < 1 {
|
|
t.Fatal("no init succeeded")
|
|
}
|
|
// However the race resolved, the user must end up with at most one live
|
|
// session and no leaked cap slots: after aborting it, a fresh init and
|
|
// full upload must succeed.
|
|
handler.chunkSessions.mu.Lock()
|
|
owned := len(handler.chunkSessions.owners)
|
|
reserved := len(handler.chunkSessions.reserved)
|
|
handler.chunkSessions.mu.Unlock()
|
|
if owned > 1 || reserved != 0 {
|
|
t.Fatalf("owners = %d (want ≤1), reserved = %d (want 0)", owned, reserved)
|
|
}
|
|
|
|
session := initChunkedUpload(t, router, 10, accessClaims())
|
|
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
|
|
[]byte("0123456789"), accessClaims())
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("post-race chunk status = %d; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("post-race complete status = %d; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsChunkedCompleteTransientStatusErrorKeepsSession(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
handler := newChunkedTestHandler(t, service)
|
|
router := chunkedRouter(handler)
|
|
|
|
session := initChunkedUpload(t, router, 10, accessClaims())
|
|
rec := doChunked(t, router, http.MethodPut, "/uploads/"+session.UploadID+"/chunks/0",
|
|
[]byte("0123456789"), accessClaims())
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("chunk status = %d", rec.Code)
|
|
}
|
|
|
|
// A transient status failure must answer 500 WITHOUT destroying the fully
|
|
// uploaded spool; the retried complete succeeds with no re-upload.
|
|
service.setStatusErr(errFakeStatusUnavailable)
|
|
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
|
|
assertDiagnosticsError(t, rec, http.StatusInternalServerError, "internal_error")
|
|
|
|
service.setStatusErr(nil)
|
|
rec = doChunked(t, router, http.MethodPost, "/uploads/"+session.UploadID+"/complete", nil, accessClaims())
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("retried complete status = %d; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticsStatusAdvertisesUploadChunkBytes(t *testing.T) {
|
|
service := newFakeDiagnosticsService()
|
|
service.status.UploadChunkBytes = diagnostics.UploadChunkBytes
|
|
handler := newChunkedTestHandler(t, service)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/status", nil)
|
|
req.Header.Set("Authorization", "Bearer token")
|
|
req = req.WithContext(apimw.SetClaims(req.Context(), accessClaims()))
|
|
rec := httptest.NewRecorder()
|
|
handler.HandleStatus(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode status: %v", err)
|
|
}
|
|
got, ok := payload["upload_chunk_bytes"].(float64)
|
|
if !ok || int64(got) != diagnostics.UploadChunkBytes {
|
|
t.Fatalf("upload_chunk_bytes = %v, want %d", payload["upload_chunk_bytes"], diagnostics.UploadChunkBytes)
|
|
}
|
|
}
|