package host import ( "bytes" "context" "log" "net/http" "net/http/httptest" "path/filepath" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/analytics" "github.com/carlosframework/platform/internal/apperr" "github.com/carlosframework/platform/internal/applog" "github.com/carlosframework/platform/internal/edge" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/usage" ) // keyLog records every key a shipper touches, so a test can see a key that // a directory-backed store would have quietly normalised ("a//b" is "a/b" // on disk). type keyLog struct { release.Store mu sync.Mutex touched []string } func (k *keyLog) note(key string) { k.mu.Lock() k.touched = append(k.touched, key) k.mu.Unlock() } func (k *keyLog) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { k.note("put " + key) return k.Store.Put(ctx, key, body, ifMatch) } func (k *keyLog) Get(ctx context.Context, key string) ([]byte, string, error) { k.note("get " + key) return k.Store.Get(ctx, key) } func (k *keyLog) List(ctx context.Context, prefix string) ([]string, error) { k.note("list " + prefix) return k.Store.List(ctx, prefix) } func (k *keyLog) ListDirs(ctx context.Context, prefix string) ([]string, error) { k.note("listdirs " + prefix) return k.Store.ListDirs(ctx, prefix) } func (k *keyLog) Delete(ctx context.Context, key string) error { k.note("delete " + key) return k.Store.Delete(ctx, key) } func (k *keyLog) all() []string { k.mu.Lock() defer k.mu.Unlock() return append([]string(nil), k.touched...) } // captureShipLog swaps the standard logger's output for the test and forgets // which apps unshippable has reported, so its once-only line is observable. func captureShipLog(t *testing.T) *bytes.Buffer { t.Helper() unshippableSeen.Range(func(k, _ any) bool { unshippableSeen.Delete(k); return true }) var buf bytes.Buffer prev, flags := log.Writer(), log.Flags() log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) }) return &buf } // TestErrshipAppLessRouteWritesNothing is the dev-bucket regression end to // end. The route is the shape found on leo-climbing: an instance row with // an account, no app, and a TCP upstream nobody listens on. Every request // is a refused 502 that the edge files under (bab, ""). The errors shipper // must write nothing for it (it once wrote apps/bab//errors/...), prune // nothing, keep shipping the app beside it, and say so once. func TestErrshipAppLessRouteWritesNothing(t *testing.T) { dead := httptest.NewServer(http.NotFoundHandler()) deadAddr := dead.Listener.Addr().String() dead.Close() // nothing listens here now: every dial is refused reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } for _, rt := range []registry.Route{ {Host: "proof.example.com", Kind: "instance", Account: "bab", App: "", Addr: deadAddr, Status: "ready", Channel: "stable"}, {Host: "web.example.com", Kind: "service", Account: "bab", App: "web", Addr: deadAddr, Status: "ready", Version: "v1"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } e, err := edge.New(reg, nil) if err != nil { t.Fatal(err) } sink := apperr.NewSink("leo-climbing", time.Now) e.EnableErrors(sink) srv := httptest.NewServer(e) t.Cleanup(srv.Close) get := func(host string) { t.Helper() req, _ := http.NewRequest("GET", srv.URL+"/", nil) req.Host = host resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusBadGateway { t.Fatalf("%s answered %d, want a refused 502", host, resp.StatusCode) } } logs := captureShipLog(t) ctx := context.Background() s := &keyLog{Store: release.NewDirStore(t.TempDir())} pending := map[applog.AppKey][]apperr.Record{} for round := 0; round < 2; round++ { get("proof.example.com") get("web.example.com") errshipGather(sink, pending) if _, ok := pending[applog.AppKey{Acct: "bab", App: ""}]; !ok { t.Fatalf("round %d: the edge did not file the app-less route under (bab, \"\"); pending = %v", round, pending) } errshipFlush(ctx, s, pending) if len(pending) != 0 { t.Fatalf("round %d: pending after flush = %v; a refused key must drop, not hold forever", round, pending) } } for _, k := range errshipKnownApps(pending, sink) { chunkPrune(ctx, s, applog.FamilyErrors, "leo-climbing", k, time.Now()) } var webPuts int for _, op := range s.all() { if strings.Contains(op, "//") { t.Fatalf("touched a key with an empty segment: %s", op) } if strings.HasPrefix(op, "put apps/bab/web/errors/leo-climbing/") { webPuts++ } } if webPuts != 2 { t.Fatalf("web chunks written = %d, want 2 (one per round): %v", webPuts, s.all()) } if n := strings.Count(logs.String(), `errors: not shipping for account "bab" app ""`); n != 1 { t.Fatalf("refusal logged %d times, want once:\n%s", n, logs.String()) } } // TestLogshipAndAnalyticsDropAppLessRecords: the logs and analytics // shippers take the same refusal: drop, never re-queue, write nothing. func TestLogshipAndAnalyticsDropAppLessRecords(t *testing.T) { logs := captureShipLog(t) ctx := context.Background() s := &keyLog{Store: release.NewDirStore(t.TempDir())} bad := applog.AppKey{Acct: "bab", App: ""} pending := map[applog.AppKey][]applog.Record{bad: {{TS: time.Now(), Box: "box1", Seq: 1, Msg: "x"}}} logshipFlush(ctx, s, pending) if len(pending) != 0 { t.Fatalf("logship pending = %v", pending) } chunkPrune(ctx, s, applog.FamilyLogs, "box1", bad, time.Now()) now := time.Date(2026, 10, 16, 6, 46, 0, 0, time.UTC) am := analytics.NewMeter(t.TempDir(), "box1", func() time.Time { return now }) am.Observe(analytics.Obs{Acct: "bab", App: "", Host: "proof.example.com", Method: "GET", Status: 502, Path: "/", When: now}) am.Observe(analytics.Obs{Acct: "bab", App: "web", Host: "web.example.com", Method: "GET", Status: 200, ContentType: "text/html", Path: "/", UA: "Mozilla/5.0 Safari", ClientIP: "1.2.3.4", Bytes: 10, When: now}) none := func() map[string]usage.EdgeRow { return nil } for i := 0; i < 2; i++ { // a second flush proves the bad delta was not re-queued if err := usageFlush(ctx, s, s, "box1", none, am, now); err != nil { t.Fatal(err) } } var webDay bool for _, op := range s.all() { if strings.Contains(op, "//") { t.Fatalf("touched a key with an empty segment: %s", op) } if op == "put apps/bab/web/analytics/box1/days/2026-10-16.json" { webDay = true } } if !webDay { t.Fatalf("the valid app's day file was not written: %v", s.all()) } out := logs.String() if strings.Count(out, `logs: not shipping for account "bab" app ""`) != 1 || strings.Count(out, `analytics: not shipping for account "bab" app ""`) != 1 { t.Fatalf("want one refusal line per family:\n%s", out) } if strings.Contains(out, "re-queued") { t.Fatalf("a refused analytics delta was re-queued:\n%s", out) } }