Files
silo-server/internal/diagnostics/bundle_test.go
T
Quick104andClaude Fable 5 4fa84a661a feat(diagnostics): client diagnostics server foundation
Implements slice 1 of docs/design/2026-07-19-client-diagnostics.md: the
versioned contract (schemas, fixtures, Go validator), storage-validated
diagnostics.uploads_enabled gate, account-scoped status endpoint, hardened
streaming multipart ingest with quota reservation and a receiving/ready/
failed report state machine, S3 streaming puts, acting-admin report API
(list/detail/download/delete with audit events), and the retention +
orphan-reconciliation cleanup task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XppCCycoaskCsW7ja1fZct
2026-07-20 11:13:52 -04:00

261 lines
6.2 KiB
Go

package diagnostics
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"errors"
"strings"
"testing"
)
type testArchiveEntry struct {
name string
body []byte
typeflag byte
}
func TestValidateBundle(t *testing.T) {
validEntries := []testArchiveEntry{
{name: "manifest.json", body: []byte(`{"schema_version":1}`)},
{name: "logs.jsonl", body: []byte("{}\n")},
}
tests := []struct {
name string
entries []testArchiveEntry
limits BundleLimits
mutate func([]byte) []byte
wantErr error
}{
{
name: "happy path",
entries: validEntries,
},
{
name: "wrong first entry",
entries: []testArchiveEntry{
{name: "device.json", body: []byte(`{}`)},
{name: "manifest.json", body: []byte(`{}`)},
},
wantErr: ErrInvalidBundle,
},
{
name: "disallowed name",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "extra.txt", body: []byte("nope")},
},
wantErr: ErrInvalidBundle,
},
{
name: "traversal name",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "../logs.jsonl", body: []byte("nope")},
},
wantErr: ErrInvalidBundle,
},
{
name: "ratio bomb",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "logs.jsonl", body: []byte(strings.Repeat("a", 1024*1024))},
},
limits: BundleLimits{
MaxUncompressedBytes: 2 * 1024 * 1024,
},
wantErr: ErrCompressionRatio,
},
{
name: "over count",
entries: append([]testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
}, repeatedEntries("logs.jsonl", 16)...),
wantErr: ErrTooManyEntries,
},
{
name: "oversize",
entries: validEntries,
limits: BundleLimits{MaxCompressedBytes: 64},
wantErr: ErrCompressedTooLarge,
},
{
name: "truncated gzip",
entries: validEntries,
mutate: func(raw []byte) []byte {
return raw[:len(raw)-8]
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects trailing tar data inside gzip stream",
entries: validEntries,
mutate: func([]byte) []byte {
return buildTestArchiveWithPostTarPayload(t, validEntries, []byte("hidden"))
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects concatenated gzip member",
entries: validEntries,
mutate: func(raw []byte) []byte {
return append(raw, buildTestGzipMember(t, bytes.Repeat([]byte{0}, 1024))...)
},
wantErr: ErrInvalidBundle,
},
{
name: "rejects symlinks",
entries: []testArchiveEntry{
{name: "manifest.json", body: []byte(`{}`)},
{name: "logs.jsonl", typeflag: tar.TypeSymlink},
},
wantErr: ErrInvalidBundle,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := buildTestArchive(t, tt.entries)
if tt.mutate != nil {
raw = tt.mutate(raw)
}
info, err := ValidateBundle(bytes.NewReader(raw), tt.limits)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ValidateBundle error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("ValidateBundle error = %v", err)
}
if info.CompressedBytes != int64(len(raw)) {
t.Fatalf("compressed bytes = %d, want %d", info.CompressedBytes, len(raw))
}
sum := sha256.Sum256(raw)
if info.SHA256 != fmtHex(sum[:]) {
t.Fatalf("sha256 = %s, want %s", info.SHA256, fmtHex(sum[:]))
}
if got, want := strings.Join(info.Entries, ","), "manifest.json,logs.jsonl"; got != want {
t.Fatalf("entries = %s, want %s", got, want)
}
if info.UncompressedBytes != int64(len(validEntries[0].body)+len(validEntries[1].body)) {
t.Fatalf("uncompressed bytes = %d", info.UncompressedBytes)
}
})
}
}
func repeatedEntries(name string, count int) []testArchiveEntry {
entries := make([]testArchiveEntry, 0, count)
for i := 0; i < count; i++ {
entries = append(entries, testArchiveEntry{name: name, body: []byte("{}\n")})
}
return entries
}
func buildTestArchive(t *testing.T, entries []testArchiveEntry) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, entry := range entries {
typeflag := entry.typeflag
if typeflag == 0 {
typeflag = tar.TypeReg
}
hdr := &tar.Header{
Name: entry.name,
Mode: 0o600,
Size: int64(len(entry.body)),
Typeflag: typeflag,
}
if typeflag != tar.TypeReg && typeflag != tar.TypeRegA {
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write header: %v", err)
}
if hdr.Size > 0 {
if _, err := tw.Write(entry.body); err != nil {
t.Fatalf("write body: %v", err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func buildTestArchiveWithPostTarPayload(t *testing.T, entries []testArchiveEntry, trailing []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, entry := range entries {
typeflag := entry.typeflag
if typeflag == 0 {
typeflag = tar.TypeReg
}
hdr := &tar.Header{
Name: entry.name,
Mode: 0o600,
Size: int64(len(entry.body)),
Typeflag: typeflag,
}
if typeflag != tar.TypeReg && typeflag != tar.TypeRegA {
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("write header: %v", err)
}
if hdr.Size > 0 {
if _, err := tw.Write(entry.body); err != nil {
t.Fatalf("write body: %v", err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if _, err := gz.Write(trailing); err != nil {
t.Fatalf("write trailing gzip payload: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func buildTestGzipMember(t *testing.T, payload []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(payload); err != nil {
t.Fatalf("write gzip payload: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
func fmtHex(raw []byte) string {
const table = "0123456789abcdef"
out := make([]byte, len(raw)*2)
for i, b := range raw {
out[i*2] = table[b>>4]
out[i*2+1] = table[b&0x0f]
}
return string(out)
}