Files
silo-server/internal/diagnostics/settings_test.go
Quick104andClaude Fable 5 2d5d4980de fix(diagnostics): address round-2 review findings on PR #445
- settings.go: cap the parsed cleanup interval at 7 days before converting to
  time.Duration so a huge configured value can't overflow int64 nanoseconds and
  wrap into a tiny/negative interval; add boundary tests.
- settings.go: propagate genuine settings read failures from LoadSettings
  (missing/empty -> default, error -> fail) so a transient DB error surfaces
  retryably instead of silently reporting uploads disabled or wrong quotas.
- bundle.go: validate non-manifest bundle entries while streaming with bounded
  memory -- device.json and crash/*.json must be a single JSON object,
  logs.jsonl/breadcrumbs.jsonl must be newline-delimited JSON objects with a
  per-line byte cap (new contract.MaxLogLineBytes); binary members stay opaque.
- diagnostics upload handler: extend the read deadline per-route via
  http.ResponseController.SetReadDeadline (10m) so slow mobile uploads of large
  bundles aren't cut off by the shared 30s server ReadTimeout.
- web admin download: request the ?proxy=1 streaming path directly so downloads
  work when S3Private is only server-reachable and errors can surface in-page.

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

235 lines
8.0 KiB
Go

package diagnostics
import (
"context"
"errors"
"strconv"
"testing"
"time"
)
func TestSeedDefaultsPreservesExistingServerInstanceID(t *testing.T) {
store := newMemorySettingsStore(map[string]string{
KeyServerInstanceID: "existing-instance",
})
if err := SeedDefaults(context.Background(), store); err != nil {
t.Fatalf("SeedDefaults: %v", err)
}
if got := store.values[KeyServerInstanceID]; got != "existing-instance" {
t.Fatalf("server instance id = %q, want existing-instance", got)
}
if got := store.values[KeyUploadsEnabled]; got != defaultUploadsEnabledStr {
t.Fatalf("uploads enabled default = %q, want %q", got, defaultUploadsEnabledStr)
}
}
func TestSeedDefaultsGeneratesDedicatedServerInstanceIDWhenMissing(t *testing.T) {
const jellyfinServerIDKey = "jellyfin_compat.server_id"
store := newMemorySettingsStore(map[string]string{
jellyfinServerIDKey: "constant-jellyfin-id",
})
if err := SeedDefaults(context.Background(), store); err != nil {
t.Fatalf("SeedDefaults: %v", err)
}
got := store.values[KeyServerInstanceID]
if len(got) != 36 {
t.Fatalf("server instance id length = %d, want UUID string", len(got))
}
if got == store.values[jellyfinServerIDKey] {
t.Fatal("diagnostics server instance id reused jellyfin_compat.server_id")
}
if store.values[jellyfinServerIDKey] != "constant-jellyfin-id" {
t.Fatalf("jellyfin server id changed to %q", store.values[jellyfinServerIDKey])
}
}
func TestSeedDefaultsAdoptsConcurrentInstanceIDWinner(t *testing.T) {
// A store that supports insert-if-absent seeds the instance ID atomically:
// when another node wins the race, this node adopts the winning value
// instead of overwriting it.
store := &racingSettingsStore{
memorySettingsStore: memorySettingsStore{values: map[string]string{}},
concurrentWinner: "winner-instance",
}
if err := SeedDefaults(context.Background(), store); err != nil {
t.Fatalf("SeedDefaults: %v", err)
}
if got := store.values[KeyServerInstanceID]; got != "winner-instance" {
t.Fatalf("server instance id = %q, want winner-instance", got)
}
if store.setIfAbsentCalls != 1 {
t.Fatalf("SetIfAbsent calls = %d, want 1", store.setIfAbsentCalls)
}
}
func TestLoadCleanupIntervalUsesDiagnosticsKey(t *testing.T) {
store := newMemorySettingsStore(map[string]string{
KeyCleanupIntervalMinutes: "45",
})
if got := LoadCleanupInterval(context.Background(), store); got != 45*time.Minute {
t.Fatalf("LoadCleanupInterval = %v, want 45m", got)
}
if got := LoadCleanupInterval(context.Background(), newMemorySettingsStore(nil)); got != DefaultCleanupIntervalMinutes*time.Minute {
t.Fatalf("LoadCleanupInterval default = %v, want %d minutes", got, DefaultCleanupIntervalMinutes)
}
}
func TestLoadCleanupIntervalCapsHugeValues(t *testing.T) {
// The boundary: the largest value that still fits under the cap is accepted
// verbatim, while anything larger (including values that would overflow
// int64 nanoseconds when multiplied by time.Minute) clamps to the cap
// instead of wrapping into a tiny or negative duration.
atCap := newMemorySettingsStore(map[string]string{
KeyCleanupIntervalMinutes: strconv.Itoa(maxCleanupIntervalMinutes),
})
if got := LoadCleanupInterval(context.Background(), atCap); got != maxCleanupIntervalMinutes*time.Minute {
t.Fatalf("LoadCleanupInterval at cap = %v, want %v", got, maxCleanupIntervalMinutes*time.Minute)
}
for _, raw := range []string{
strconv.Itoa(maxCleanupIntervalMinutes + 1),
"153722868", // wraps int64 nanoseconds without the cap
"9223372036854775807", // math.MaxInt64 minutes
} {
store := newMemorySettingsStore(map[string]string{KeyCleanupIntervalMinutes: raw})
got := LoadCleanupInterval(context.Background(), store)
if got != maxCleanupIntervalMinutes*time.Minute {
t.Fatalf("LoadCleanupInterval(%s) = %v, want capped %v", raw, got, maxCleanupIntervalMinutes*time.Minute)
}
if got <= 0 {
t.Fatalf("LoadCleanupInterval(%s) = %v, want a positive duration", raw, got)
}
}
}
func TestLoadSettingsPropagatesReadErrors(t *testing.T) {
// A genuine read failure must surface as an error so callers can return a
// retryable failure instead of silently reporting defaults (e.g. uploads
// disabled) to clients.
store := &failingSettingsStore{
memorySettingsStore: memorySettingsStore{values: map[string]string{KeyUploadsEnabled: "true"}},
failKey: KeyUploadsEnabled,
}
if _, err := LoadSettings(context.Background(), store); err == nil {
t.Fatal("LoadSettings did not propagate a store read error")
}
// A later key failing must also propagate, not be swallowed as a default.
store = &failingSettingsStore{
memorySettingsStore: memorySettingsStore{values: map[string]string{KeyUploadsEnabled: "true"}},
failKey: KeyMaxBytesPerUser,
}
if _, err := LoadSettings(context.Background(), store); err == nil {
t.Fatal("LoadSettings did not propagate a late store read error")
}
}
func TestLoadSettingsParsesTypedValues(t *testing.T) {
store := newMemorySettingsStore(map[string]string{
KeyUploadsEnabled: "true",
KeyMaxBundleBytes: "123",
KeyMaxUncompressedBytes: "456",
KeyMaxReportsPerUserDay: "7",
KeyRetentionDays: "8",
KeyMaxBytesPerUser: "900",
KeyConsentNoticeVersion: "2",
KeyServerInstanceID: "server-1",
})
got, err := LoadSettings(context.Background(), store)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
if !got.UploadsEnabled ||
got.MaxBundleBytes != 123 ||
got.MaxUncompressedBytes != 456 ||
got.MaxReportsPerUserDay != 7 ||
got.RetentionDays != 8 ||
got.MaxBytesPerUser != 900 ||
got.ConsentNoticeVersion != 2 ||
got.ServerInstanceID != "server-1" {
t.Fatalf("LoadSettings parsed unexpected values: %+v", got)
}
}
func TestLoadSettingsFallsBackForInvalidNumbers(t *testing.T) {
store := newMemorySettingsStore(map[string]string{
KeyMaxBundleBytes: "-1",
KeyMaxUncompressedBytes: "not-a-number",
KeyMaxReportsPerUserDay: "0",
KeyRetentionDays: "0",
KeyMaxBytesPerUser: "-2",
KeyConsentNoticeVersion: "0",
})
got, err := LoadSettings(context.Background(), store)
if err != nil {
t.Fatalf("LoadSettings: %v", err)
}
want := DefaultSettings()
if got.MaxBundleBytes != want.MaxBundleBytes ||
got.MaxUncompressedBytes != want.MaxUncompressedBytes ||
got.MaxReportsPerUserDay != want.MaxReportsPerUserDay ||
got.RetentionDays != want.RetentionDays ||
got.MaxBytesPerUser != want.MaxBytesPerUser ||
got.ConsentNoticeVersion != want.ConsentNoticeVersion {
t.Fatalf("LoadSettings = %+v, want defaults %+v", got, want)
}
}
type memorySettingsStore struct {
values map[string]string
}
func newMemorySettingsStore(values map[string]string) *memorySettingsStore {
store := &memorySettingsStore{values: map[string]string{}}
for k, v := range values {
store.values[k] = v
}
return store
}
func (s *memorySettingsStore) Get(_ context.Context, key string) (string, error) {
return s.values[key], nil
}
func (s *memorySettingsStore) Set(_ context.Context, key, value string) error {
s.values[key] = value
return nil
}
// failingSettingsStore returns an error when Get is called for failKey,
// simulating a transient database read failure for one setting.
type failingSettingsStore struct {
memorySettingsStore
failKey string
}
func (s *failingSettingsStore) Get(ctx context.Context, key string) (string, error) {
if key == s.failKey {
return "", errors.New("transient read failure")
}
return s.memorySettingsStore.Get(ctx, key)
}
// racingSettingsStore adds insert-if-absent semantics and simulates another node
// winning the instance-ID seed race via concurrentWinner.
type racingSettingsStore struct {
memorySettingsStore
concurrentWinner string
setIfAbsentCalls int
}
func (s *racingSettingsStore) SetIfAbsent(_ context.Context, key, value string) (bool, error) {
s.setIfAbsentCalls++
if existing := s.values[key]; existing != "" {
return false, nil
}
if s.concurrentWinner != "" {
s.values[key] = s.concurrentWinner
return false, nil
}
s.values[key] = value
return true, nil
}