package server import ( "agent-system-backend/internal/cardsvc" "agent-system-backend/pkg/extension" core "agent-system-core" "bufio" "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "sync/atomic" "testing" "time" ) func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+project+"&since=0", nil) r.Header.Set("Authorization", "Bearer "+key) // Replay can pass more than a thousand private events before the first // visible frame; account checks on CI and under -race need headroom. ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() w := httptest.NewRecorder() s.Handler().ServeHTTP(w, r.WithContext(ctx)) return w } func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) { t.Helper() s, st := newAuthTestServer(t) alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") p, _ := st.CreateProject("alice", "Project") s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked) s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent) member := &atomic.Bool{} member.Store(true) s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || (q.Actor == "bob" && member.Load())), nil }) return s, alice, bob, p.ID, member } func TestProjectEventsLegacyReplayIsPrivate(t *testing.T) { s, alice, bob, p, _ := projectEventFixture(t) s.DisableUploadIndexing = true if w := lifecycleUpload(t, s, alice, p, "legacy-owner-private.txt", "PRIVATE"); w.Code != 200 { t.Fatal(w.Code) } s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil) member := projectStreamReplay(t, s, bob, p) if member.Code != 200 || bytes.Contains(member.Body.Bytes(), []byte("legacy-owner-private.txt")) || bytes.Contains(member.Body.Bytes(), []byte("builtin:txt")) { t.Fatal("legacy private upload leaked to member") } owner := projectStreamReplay(t, s, alice, p) if owner.Code != 200 || !bytes.Contains(owner.Body.Bytes(), []byte("legacy-owner-private.txt")) { t.Fatal("owner lost private history") } } func TestProjectEventsSharedProofAndPrivatePagination(t *testing.T) { s, _, bob, p, _ := projectEventFixture(t) s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) card, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "PRIVATE HISTORY"}, "") if err != nil { t.Fatal(err) } payload, _ := json.Marshal(map[string]any{"card": card, "owner": "bob", "shared": true, "producer": "plugin:rdm:structure"}) private := make([]core.StreamEvent, 1001) for i := range private { private[i] = core.StreamEvent{Type: core.EvCardUpdated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1} } if _, err := s.st.AppendEvents(core.ProjectChannelKey(p), private); err != nil { t.Fatal(err) } if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "EXPLICIT SHARED"}, ""); err != nil { t.Fatal(err) } w := projectStreamReplay(t, s, bob, p) if w.Code != 200 || bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")) || !bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")) { t.Fatalf("private pagination/spoof proof failed: code=%d private=%t shared=%t bytes=%d", w.Code, bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")), bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")), w.Body.Len()) } } func TestProjectEventsLivePrivacyAndRevocation(t *testing.T) { s, _, bob, p, member := projectEventFixture(t) s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) httpServer := httptest.NewServer(s.Handler()) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() req, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil) req.Header.Set("Authorization", "Bearer "+bob) response, err := httpServer.Client().Do(req) if err != nil { t.Fatal(err) } defer response.Body.Close() if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LIVE PRIVATE"}, ""); err != nil { t.Fatal(err) } if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "LIVE SHARED"}, ""); err != nil { t.Fatal(err) } reader := bufio.NewReader(response.Body) var before bytes.Buffer for { line, err := reader.ReadString('\n') if err != nil { t.Fatalf("live shared event did not arrive: %v", err) } before.WriteString(line) if bytes.Contains([]byte(line), []byte("LIVE SHARED")) { break } } if bytes.Contains(before.Bytes(), []byte("LIVE PRIVATE")) { t.Fatal("live private frame leaked before revocation") } member.Store(false) if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "AFTER REMOVAL"}, ""); err != nil { t.Fatal(err) } body, err := io.ReadAll(reader) if err != nil { t.Fatalf("revoked stream did not stop: %v", err) } if bytes.Contains(body, []byte("LIVE PRIVATE")) || bytes.Contains(body, []byte("AFTER REMOVAL")) { t.Fatal("live private/revoked event leaked") } } func TestUnregisteredProjectEventsRetainLegacyReads(t *testing.T) { s, _, bob, p, _ := projectEventFixture(t) if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LEGACY BASE"}, ""); err != nil { t.Fatal(err) } w := projectStreamReplay(t, s, bob, p) if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte("LEGACY BASE")) { t.Fatal("unregistered project stream changed") } } func TestProjectEventsIdleStreamStopsAfterMembershipRemoval(t *testing.T) { previous := sseAuthRecheck sseAuthRecheck = 20 * time.Millisecond defer func() { sseAuthRecheck = previous }() s, _, bob, p, member := projectEventFixture(t) s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) httpServer := httptest.NewServer(s.Handler()) defer httpServer.Close() ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() request, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil) request.Header.Set("Authorization", "Bearer "+bob) response, err := httpServer.Client().Do(request) if err != nil { t.Fatal(err) } defer response.Body.Close() member.Store(false) body, err := io.ReadAll(response.Body) if err != nil || !bytes.Contains(body, []byte("project-access-revoked")) { t.Fatal("idle project stream kept revoked member connected") } }