Every uploaded run showed "not recorded" in the web UI because the meta extractor read summary.verdict. Schema §7.3 calls that field summary.overall; "verdict" is the per-category field one level down. So the verdict was never stored, and the UI faithfully reported a gap that was this parser's doing rather than the document's. The test encoded the same mistake — its fixture posted summary.verdict:"warn" — so it passed throughout against a parser that read a field nothing writes. Corrected to summary.overall, and to a verdict that exists: §7.3 defines green|yellow|red|inconclusive, and "warn" was never one of them. The eleven runs already stored had their meta backfilled from the documents, which are kept byte-for-byte and still carry the real value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
6.3 KiB
Go
212 lines
6.3 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package runs
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func doc(id, anon string) []byte {
|
|
return []byte(fmt.Sprintf(
|
|
`{"run":{"id":%q,"started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":%q}},`+
|
|
`"findings":[{"id":"f1"},{"id":"f2"}],"summary":{"overall":"yellow"}}`, id, anon))
|
|
}
|
|
|
|
func open(t *testing.T, p Policy) (*Store, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
s, err := Open(dir, p)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
return s, dir
|
|
}
|
|
|
|
func TestModeOffRefusesEverything(t *testing.T) {
|
|
p := DefaultPolicy()
|
|
p.Mode = ModeOff
|
|
s, _ := open(t, p)
|
|
if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrDisabled) {
|
|
t.Fatalf("want ErrDisabled, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ModeAccount turns on whether the *caller* has signed in, and nothing else. A device that has
|
|
// not is refused with a reason it can act on; one that has is treated exactly like anonymous mode.
|
|
func TestModeAccountTurnsOnWhetherTheCallerSignedIn(t *testing.T) {
|
|
p := DefaultPolicy()
|
|
p.Mode = ModeAccount
|
|
s, _ := open(t, p)
|
|
|
|
if _, err := s.Put("dev1", doc("run-1", AnonFull), false); !errors.Is(err, ErrNeedAccount) {
|
|
t.Fatalf("an un-signed-in device was not refused: %v", err)
|
|
}
|
|
if _, err := s.Put("dev1", doc("run-2", AnonFull), true); err != nil {
|
|
t.Fatalf("a signed-in device was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// Signing in must not open a door that the operator closed outright: mode=off means off.
|
|
func TestSigningInDoesNotOverrideModeOff(t *testing.T) {
|
|
p := DefaultPolicy()
|
|
p.Mode = ModeOff
|
|
s, _ := open(t, p)
|
|
if _, err := s.Put("dev1", doc("run-1", AnonFull), true); !errors.Is(err, ErrDisabled) {
|
|
t.Fatalf("a signed-in device uploaded to a server with uploads off: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMinAnonymizationEnforced(t *testing.T) {
|
|
p := DefaultPolicy()
|
|
p.MinAnonymization = AnonBalanced
|
|
s, _ := open(t, p)
|
|
|
|
if _, err := s.Put("dev1", doc("run-full", AnonFull), false); !errors.Is(err, ErrNotAnonEnough) {
|
|
t.Fatalf("full should be refused when balanced is required, got %v", err)
|
|
}
|
|
// An undeclared level means nothing was stripped, so it must be treated as "full".
|
|
if _, err := s.Put("dev1", []byte(`{"run":{"id":"run-bare"},"summary":{}}`), false); !errors.Is(err, ErrNotAnonEnough) {
|
|
t.Fatalf("undeclared level should be treated as full, got %v", err)
|
|
}
|
|
for _, lvl := range []string{AnonBalanced, AnonStrict} {
|
|
if _, err := s.Put("dev1", doc("run-"+lvl, lvl), false); err != nil {
|
|
t.Fatalf("%s should be accepted: %v", lvl, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSizeLimit(t *testing.T) {
|
|
p := DefaultPolicy()
|
|
p.MaxBytes = 200
|
|
s, _ := open(t, p)
|
|
big := append(doc("run-1", AnonFull), make([]byte, 400)...)
|
|
if _, err := s.Put("dev1", big, false); !errors.Is(err, ErrTooLarge) {
|
|
t.Fatalf("want ErrTooLarge, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRetentionByCountKeepsNewest(t *testing.T) {
|
|
p := DefaultPolicy()
|
|
p.MaxRunsPerDevice = 3
|
|
s, _ := open(t, p)
|
|
for i := 0; i < 6; i++ {
|
|
if _, err := s.Put("dev1", doc(fmt.Sprintf("run-%d", i), AnonFull), false); err != nil {
|
|
t.Fatalf("put %d: %v", i, err)
|
|
}
|
|
time.Sleep(2 * time.Millisecond) // distinct UploadedAt so "newest" is well defined
|
|
}
|
|
got := s.List("dev1")
|
|
if len(got) != 3 {
|
|
t.Fatalf("kept %d runs, want 3", len(got))
|
|
}
|
|
for i, want := range []string{"run-5", "run-4", "run-3"} {
|
|
if got[i].ID != want {
|
|
t.Fatalf("kept[%d] = %s, want %s (newest first)", i, got[i].ID, want)
|
|
}
|
|
}
|
|
// The documents themselves must be gone too, not just their index entries.
|
|
if _, err := s.Get("dev1", "run-0"); err == nil {
|
|
t.Fatal("purged run is still readable")
|
|
}
|
|
}
|
|
|
|
func TestRetentionByAge(t *testing.T) {
|
|
p := DefaultPolicy()
|
|
p.RetentionDays = 7
|
|
p.MaxRunsPerDevice = 0
|
|
s, dir := open(t, p)
|
|
if _, err := s.Put("dev1", doc("run-old", AnonFull), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Backdate the index entry past the retention window.
|
|
metaPath := filepath.Join(dir, "runs", "dev1", "run-old.meta.json")
|
|
b, _ := os.ReadFile(metaPath)
|
|
var m Meta
|
|
_ = json.Unmarshal(b, &m)
|
|
m.UploadedAt = time.Now().Add(-30 * 24 * time.Hour)
|
|
nb, _ := json.Marshal(m)
|
|
if err := os.WriteFile(metaPath, nb, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := s.Put("dev1", doc("run-new", AnonFull), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := s.List("dev1")
|
|
if len(got) != 1 || got[0].ID != "run-new" {
|
|
t.Fatalf("age retention did not drop the old run: %+v", got)
|
|
}
|
|
}
|
|
|
|
// Run and device ids reach the filesystem, so a hostile one must not be able to climb out of the
|
|
// store directory or overwrite another device's data.
|
|
func TestIDsCannotEscapeTheStoreDirectory(t *testing.T) {
|
|
s, dir := open(t, DefaultPolicy())
|
|
if _, err := s.Put("../../etc", doc("../../../passwd", AnonFull), false); err != nil {
|
|
t.Fatalf("put: %v", err)
|
|
}
|
|
var found []string
|
|
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
|
if err == nil && !info.IsDir() {
|
|
rel, _ := filepath.Rel(dir, p)
|
|
found = append(found, filepath.ToSlash(rel))
|
|
}
|
|
return nil
|
|
})
|
|
for _, f := range found {
|
|
if strings.Contains(f, "..") {
|
|
t.Fatalf("path escaped the store: %s", f)
|
|
}
|
|
}
|
|
if len(found) == 0 {
|
|
t.Fatal("nothing written at all")
|
|
}
|
|
}
|
|
|
|
func TestListIsPerDevice(t *testing.T) {
|
|
s, _ := open(t, DefaultPolicy())
|
|
if _, err := s.Put("devA", doc("run-a", AnonFull), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Put("devB", doc("run-b", AnonFull), false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := s.List("devA"); len(got) != 1 || got[0].ID != "run-a" {
|
|
t.Fatalf("devA sees %+v", got)
|
|
}
|
|
if _, err := s.Get("devA", "run-b"); err == nil {
|
|
t.Fatal("devA could read devB's run")
|
|
}
|
|
}
|
|
|
|
func TestMetaSummarisesTheDocument(t *testing.T) {
|
|
s, _ := open(t, DefaultPolicy())
|
|
m, err := s.Put("dev1", doc("run-1", AnonBalanced), false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m.FindingCount != 2 || m.Verdict != "yellow" || m.Anonymization != AnonBalanced {
|
|
t.Fatalf("meta not extracted: %+v", m)
|
|
}
|
|
if m.StartedAt != "2026-08-01T10:00:00Z" {
|
|
t.Fatalf("started_at = %q", m.StartedAt)
|
|
}
|
|
}
|
|
|
|
func TestMalformedRejected(t *testing.T) {
|
|
s, _ := open(t, DefaultPolicy())
|
|
for _, body := range [][]byte{[]byte("not json"), []byte(`{"run":{}}`), []byte(`{}`)} {
|
|
if _, err := s.Put("dev1", body, false); !errors.Is(err, ErrMalformed) {
|
|
t.Fatalf("body %q: want ErrMalformed, got %v", body, err)
|
|
}
|
|
}
|
|
}
|