Files
silo-server/internal/diagnostics/contract/contract_test.go
Quick104andClaude Fable 5 a6348b3dc5 fix(diagnostics): address PR #445 review findings
- bundle: reject tar entry names that differ from their trimmed form instead
  of normalizing padded names into the allowlist
- repo: reserve expected bytes on receiving rows and count receiving+ready in
  the per-user byte quota so concurrent/multi-node uploads can't overshoot
- contract: require the crash object for event report types and keep it absent
  for manual; add contract tests
- settings/service: seed diagnostics.server_instance_id atomically via
  insert-if-absent and adopt the winning value across nodes
- bundle/service: capture the embedded manifest.json during ValidateBundle and
  reject reports whose embedded manifest disagrees with the part-1 manifest
  (minus archive); add tests
- admin: delete the DB row before the blob on DeleteReport; log bucket/key when
  the blob delete fails instead of leaving a visible report with a missing bundle
- bundle: reject PAX/GNU tar formats and extension records that smuggle bytes
  past validation; add a PAX-archive rejection test
- migration: add CHECK constraints for state, report_type, and platform
- docs: add text/jsonc language identifiers to the two unfenced code blocks
- cleanup: log-and-continue per report and aggregate errors so one poisoned
  report no longer blocks the whole run; update tests
- tasks: give diagnostics its own cleanup interval key instead of reusing the
  opslog key, and bound the startup settings lookup

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

324 lines
11 KiB
Go

package contract
import (
"bufio"
"bytes"
"encoding/json"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
)
const contractSchemaRoot = "../../../docs/design/schemas/client-diagnostics"
func TestValidFixtures(t *testing.T) {
fixtures := mustGlob(t, "v1/fixtures/valid/*")
if len(fixtures) == 0 {
t.Fatal("valid fixtures missing")
}
for _, fixture := range fixtures {
fixture := fixture
t.Run(filepath.Base(fixture), func(t *testing.T) {
data := mustReadFixture(t, fixture)
switch {
case strings.HasSuffix(fixture, "device.json"):
if _, err := ValidateDevice(data); err != nil {
t.Fatalf("ValidateDevice() error = %v", err)
}
case strings.HasSuffix(fixture, ".jsonl"):
validateLogLinesFixture(t, data)
case strings.HasSuffix(fixture, ".json"):
if _, err := ValidateManifest(data); err != nil {
t.Fatalf("ValidateManifest() error = %v", err)
}
default:
t.Fatalf("unexpected fixture extension: %s", fixture)
}
})
}
}
func TestInvalidManifestFixtures(t *testing.T) {
fixtures := mustGlob(t, "v1/fixtures/invalid/*.json")
if len(fixtures) < 6 {
t.Fatalf("invalid fixtures = %d, want at least 6", len(fixtures))
}
seenErrors := map[string]string{}
for _, fixture := range fixtures {
fixture := fixture
t.Run(filepath.Base(fixture), func(t *testing.T) {
_, err := ValidateManifest(mustReadFixture(t, fixture))
if err == nil {
t.Fatal("ValidateManifest() error = nil, want failure")
}
msg := err.Error()
if prior := seenErrors[msg]; prior != "" {
t.Fatalf("error %q also used by %s", msg, prior)
}
seenErrors[msg] = filepath.Base(fixture)
})
}
}
func TestValidateManifestRequiresCrashForEventReports(t *testing.T) {
// A non-manual report with the crash object stripped must be rejected so
// event reports always carry crash summary/source/occurred_at.
var m map[string]json.RawMessage
if err := json.Unmarshal(mustReadFixture(t, "v1/fixtures/valid/android-tv-crash-ueh.json"), &m); err != nil {
t.Fatalf("parse crash fixture: %v", err)
}
delete(m, "crash")
stripped, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal stripped manifest: %v", err)
}
_, err = ValidateManifest(stripped)
if err == nil || !strings.Contains(err.Error(), "crash") {
t.Fatalf("ValidateManifest() error = %v, want crash required for event report", err)
}
}
func TestValidateManifestRejectsCrashOnManualReports(t *testing.T) {
var m map[string]json.RawMessage
if err := json.Unmarshal(mustReadFixture(t, "v1/fixtures/valid/android-tv-crash-ueh.json"), &m); err != nil {
t.Fatalf("parse crash fixture: %v", err)
}
var report map[string]json.RawMessage
if err := json.Unmarshal(m["report"], &report); err != nil {
t.Fatalf("parse report: %v", err)
}
report["type"] = json.RawMessage(`"manual"`)
reportJSON, err := json.Marshal(report)
if err != nil {
t.Fatalf("marshal report: %v", err)
}
m["report"] = reportJSON
withCrash, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
_, err = ValidateManifest(withCrash)
if err == nil || !strings.Contains(err.Error(), "crash") {
t.Fatalf("ValidateManifest() error = %v, want crash rejected for manual report", err)
}
}
func TestSchemaEnumsAndRequiredFieldsStayInSync(t *testing.T) {
manifest := mustReadObject(t, "v1/manifest.schema.json")
assertStringsEqual(t, "manifest.required", schemaStrings(t, manifest, "required"), manifestRequiredFields)
assertConstInt(t, "manifest.schema_version.const", schemaValue(t, manifest, "properties", "schema_version", "const"), SchemaVersion)
assertStringsEqual(t, "manifest.report.required", schemaStrings(t, manifest, "properties", "report", "required"), manifestReportRequiredFields)
assertStringsEqual(t, "manifest.destination.required", schemaStrings(t, manifest, "properties", "destination", "required"), manifestDestinationRequiredFields)
assertStringsEqual(t, "manifest.consent.required", schemaStrings(t, manifest, "properties", "consent", "required"), manifestConsentRequiredFields)
assertStringsEqual(t, "manifest.crash.required", schemaStrings(t, manifest, "properties", "crash", "required"), manifestCrashRequiredFields)
assertStringsEqual(t, "manifest.device_summary.required", schemaStrings(t, manifest, "properties", "device_summary", "required"), manifestDeviceSummaryFields)
assertStringsEqual(t, "manifest.log_summary.required", schemaStrings(t, manifest, "properties", "log_summary", "required"), manifestLogSummaryRequiredFields)
assertStringsEqual(t, "manifest.archive.required", schemaStrings(t, manifest, "properties", "archive", "required"), manifestArchiveRequiredFields)
assertStringsEqual(t, "manifest.report.type.enum", schemaStrings(t, manifest, "properties", "report", "properties", "type", "enum"), reportTypes)
assertStringsEqual(t, "manifest.report.platform.enum", schemaStrings(t, manifest, "properties", "report", "properties", "platform", "enum"), platforms)
assertStringsEqual(t, "manifest.consent.mode.enum", schemaStrings(t, manifest, "properties", "consent", "properties", "mode", "enum"), consentModes)
assertStringsEqual(t, "manifest.crash.source.enum", schemaStrings(t, manifest, "properties", "crash", "properties", "source", "enum"), crashSources)
assertStringsEqual(t, "manifest.crash.provenance.enum", schemaStrings(t, manifest, "properties", "crash", "properties", "provenance", "enum"), crashProvenances)
assertStringsEqual(t, "manifest.log_summary.categories.enum", schemaStrings(t, manifest, "properties", "log_summary", "properties", "categories", "items", "enum"), logCategories)
assertStringsEqual(t, "manifest.archive.entries.enum", schemaStrings(t, manifest, "properties", "archive", "properties", "entries", "items", "enum"), ArchiveEntryAllowlist)
logline := mustReadObject(t, "v1/logline.schema.json")
assertStringsEqual(t, "logline.required", schemaStrings(t, logline, "required"), []string{"ts", "run", "lvl", "cat", "tag", "msg"})
assertStringsEqual(t, "logline.lvl.enum", schemaStrings(t, logline, "properties", "lvl", "enum"), logLevels)
assertStringsEqual(t, "logline.cat.enum", schemaStrings(t, logline, "properties", "cat", "enum"), logCategories)
device := mustReadObject(t, "v1/device.schema.json")
assertStringsEqual(t, "device.required", schemaStrings(t, device, "required"), deviceRequiredFields)
assertStringsEqual(t, "device.provenance.enum", schemaStrings(t, device, "properties", "provenance", "enum"), deviceProvenances)
}
func TestAttrRegistryStaysInSync(t *testing.T) {
var registry struct {
Categories map[string]map[string]struct {
Type string `json:"type"`
} `json:"categories"`
}
if err := json.Unmarshal(mustReadFixture(t, "v1/attr-registry.json"), &registry); err != nil {
t.Fatalf("parse attr-registry.json: %v", err)
}
got := make(map[string]map[string]string, len(registry.Categories))
for category, keys := range registry.Categories {
got[category] = make(map[string]string, len(keys))
for key, spec := range keys {
got[category][key] = spec.Type
}
}
want := make(map[string]map[string]string, len(attrRegistry))
for category, keys := range attrRegistry {
want[category] = make(map[string]string, len(keys))
for key, valueType := range keys {
want[category][key] = string(valueType)
}
}
if !mapsEqual(got, want) {
t.Fatalf("attr registry mismatch\ngot: %#v\nwant: %#v", got, want)
}
}
func TestValidateLogLineDropsUnregisteredAttrs(t *testing.T) {
line := []byte(`{"ts":"2026-07-19T18:22:29Z","run":"run_1","lvl":"I","cat":"playback","tag":"Player","msg":"started","attrs":{"sink":"HDMI","unregistered":"drop-me"}}`)
got, err := ValidateLogLine(line)
if err != nil {
t.Fatalf("ValidateLogLine() error = %v", err)
}
if _, ok := got.Attrs["sink"]; !ok {
t.Fatalf("registered attr missing: %#v", got.Attrs)
}
if _, ok := got.Attrs["unregistered"]; ok {
t.Fatalf("unregistered attr was not dropped: %#v", got.Attrs)
}
}
func TestValidateLogLineRejectsRegisteredAttrTypeMismatch(t *testing.T) {
line := []byte(`{"ts":"2026-07-19T18:22:29Z","run":"run_1","lvl":"I","cat":"network","tag":"HTTP","msg":"done","attrs":{"status":"200"}}`)
if _, err := ValidateLogLine(line); err == nil {
t.Fatal("ValidateLogLine() error = nil, want type mismatch")
}
}
func validateLogLinesFixture(t *testing.T, data []byte) {
t.Helper()
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 0, 1024), 1024*1024)
lineCount := 0
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
lineCount++
if _, err := ValidateLogLine(line); err != nil {
t.Fatalf("line %d: ValidateLogLine() error = %v", lineCount, err)
}
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan loglines fixture: %v", err)
}
if lineCount == 0 {
t.Fatal("loglines fixture has no log lines")
}
}
func mustGlob(t *testing.T, pattern string) []string {
t.Helper()
matches, err := filepath.Glob(filepath.Join(contractSchemaRoot, filepath.FromSlash(pattern)))
if err != nil {
t.Fatalf("glob %s: %v", pattern, err)
}
for i, match := range matches {
rel, err := filepath.Rel(contractSchemaRoot, match)
if err != nil {
t.Fatalf("rel %s: %v", match, err)
}
matches[i] = filepath.ToSlash(rel)
}
sort.Strings(matches)
return matches
}
func mustReadFixture(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join(contractSchemaRoot, filepath.FromSlash(path)))
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return data
}
func mustReadObject(t *testing.T, path string) map[string]any {
t.Helper()
var obj map[string]any
if err := json.Unmarshal(mustReadFixture(t, path), &obj); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return obj
}
func schemaStrings(t *testing.T, root any, path ...string) []string {
t.Helper()
value := schemaValue(t, root, path...)
items, ok := value.([]any)
if !ok {
t.Fatalf("%s is %T, want array", strings.Join(path, "."), value)
}
out := make([]string, 0, len(items))
for _, item := range items {
s, ok := item.(string)
if !ok {
t.Fatalf("%s item is %T, want string", strings.Join(path, "."), item)
}
out = append(out, s)
}
return out
}
func schemaValue(t *testing.T, root any, path ...string) any {
t.Helper()
current := root
for _, key := range path {
obj, ok := current.(map[string]any)
if !ok {
t.Fatalf("%s parent is %T, want object", key, current)
}
next, ok := obj[key]
if !ok {
t.Fatalf("missing schema path %s", strings.Join(path, "."))
}
current = next
}
return current
}
func assertConstInt(t *testing.T, label string, got any, want int) {
t.Helper()
gotFloat, ok := got.(float64)
if !ok {
t.Fatalf("%s = %T, want JSON number", label, got)
}
if int(gotFloat) != want || gotFloat != float64(want) {
t.Fatalf("%s = %v, want %d", label, got, want)
}
}
func assertStringsEqual(t *testing.T, label string, got, want []string) {
t.Helper()
got = append([]string(nil), got...)
want = append([]string(nil), want...)
sort.Strings(got)
sort.Strings(want)
if !slices.Equal(got, want) {
t.Fatalf("%s mismatch\ngot: %v\nwant: %v", label, got, want)
}
}
func mapsEqual(got, want map[string]map[string]string) bool {
if len(got) != len(want) {
return false
}
for category, gotKeys := range got {
wantKeys, ok := want[category]
if !ok || len(gotKeys) != len(wantKeys) {
return false
}
for key, gotType := range gotKeys {
if wantKeys[key] != gotType {
return false
}
}
}
return true
}