ci / vet-and-test (pull_request) Successful in 56s
Why: The SSE test canceled after 200 ms while filtering more than a thousand private events; CI and race builds stopped before the shared frame. Evidence: The failure reproduced under go test -race with HTTP 200, no private leak, and no shared frame; after a two-second deadline the race case passed three times and the Server package passed. Impact: The privacy assertion is unchanged and now reports which condition failed, while slower replay still reaches the event under test. Refs: IAM U3 Stage 7; Backend PR #198; CI run 3170
168 lines
6.7 KiB
Go
168 lines
6.7 KiB
Go
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")
|
|
}
|
|
}
|