Files
echolot/server/internal/runs/runs_test.go
T
mrambossekandClaude Fable 5 ce6d0c2f64
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 35s
server-release / release (push) Successful in 34s
oidc: the server becomes a relying party, and devices can carry an account
Echolot delegates identity to whatever IdP the operator already runs and stores
no passwords - no hashing, no reset flow, no lockout policy, and no credential
database to lose. For a tool people self-host next to other services, that is
the difference between one more service and one more thing that can leak
someone's password.

Verification is stdlib-only, matching the server's no-dependency rule. Longer
than jwt.Parse, and auditable in one sitting. The part that matters is the
algorithm allow-list: taking `alg` from the token is the classic forgery, so it
is fixed in code. Tests cover the real attacks against a genuine signer - a
self-contained IdP with real keys, because a mock that returns success proves
nothing about a verifier:

  alg=none, HS256/RS256 confusion, a payload swapped under a valid signature,
  a token addressed to another client, a token from another issuer, expired
  and future-dated tokens, and discovery that renames the issuer (which would
  otherwise have us fetch a stranger's keys believing they were the provider's).

With no admin group configured nobody is an admin. An operator who has not said
who may administer the server has not thereby said "anyone who can log in".

Device and account stay separate concepts: enrollment admits a device (operator's
token), signing in attributes it to a person (POST /v1/account/link, device
credential plus ID token - both required, neither substitutes). uploads=account
now means what it says instead of refusing everyone, and signing in does not
override uploads=off.

The profile advertises the sign-in configuration so the app can offer the button
only when there is something behind it, and drive PKCE without anyone typing an
issuer URL. A discovery failure is reported rather than hidden, so "configured
but the provider is not answering" is distinguishable from "not configured".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:52:56 +02:00

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":{"verdict":"warn"}}`, 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 != "warn" || 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)
}
}
}