Files
Agent-System-Backend/internal/server/project_lifecycle_test.go
T
weichen d26f3cf2e8
ci / vet-and-test (pull_request) Successful in 49s
Protect private project event replay and live delivery
Why:
Legacy project streams replayed upload filenames and parser metadata to newly
authorized members even though the underlying cards remained owner-only.

Evidence:
SELF real HTTP red regressions exposed legacy replay, spoofed sharing claims,
private-history pagination, and live delivery after membership removal. The
affected packages, uncached full Backend suite, isolated PostgreSQL Store suite,
and agent-system build pass after applying the event boundary.

Impact:
Registered lifecycle streams resolve trusted resource ownership and require an
internal publication proof for cross-owner delivery. Event and proof commit in
one transaction; v30 adds an additive SQLite/PostgreSQL sidecar without granting
access to historical events. Seen and visible cursors remain separate, revoked
members leave live streams, and unregistered base behavior remains compatible.
The storage migration tool preserves the new proof rows.

Refs:
project-member-access-implementation-plan.md Task 1 / R5; authorized legacy SSE
privacy follow-up; project-member-composed-legacy-project-history.json
2026-10-13 11:12:47 +00:00

505 lines
23 KiB
Go

package server
import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
)
type lifecycleStatusError int
func (e lifecycleStatusError) Error() string { return "provider refusal" }
func (e lifecycleStatusError) StatusCode() int { return int(e) }
func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
r.Header.Set("Authorization", "Bearer "+key)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Original")
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
calls := []string{}
s.SetProjectLifecycle(extension.ProjectLifecycle{
List: func(context.Context, extension.Host) ([]core.Project, error) {
calls = append(calls, "list")
return []core.Project{{ID: p.ID, Owner: "alice", Name: "Decorated", Settings: map[string]any{"rdm.state": "ready"}}}, nil
},
Create: func(_ context.Context, _ extension.Host, name string) (core.Project, error) {
calls = append(calls, "create")
return core.Project{ID: "provider-created", Name: name}, nil
},
Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
calls = append(calls, "rename")
return core.Project{ID: id, Name: name}, nil
},
Archive: func(context.Context, extension.Host, string) error { calls = append(calls, "archive"); return nil },
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
}, nil)
for _, q := range []struct{ method, path, body string }{{"GET", "/api/projects", ""}, {"POST", "/api/projects", `{"name":" New "}`}, {"PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {"DELETE", "/api/projects/" + p.ID, ""}} {
w := lifecycleRequest(t, s, key, q.method, q.path, q.body)
if w.Code != 200 {
t.Fatalf("%s: %d %s", q.method, w.Code, w.Body.String())
}
}
if len(calls) != 4 || calls[0] != "list" || calls[1] != "create" || calls[2] != "rename" || calls[3] != "archive" {
t.Fatalf("calls=%v", calls)
}
if _, err := st.GetConversation("alice", c.ID); err != nil {
t.Fatalf("archive removed history: %v", err)
}
s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) {
return core.Project{}, fmtWrap(lifecycleStatusError(409))
}}, nil)
if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`); w.Code != 409 {
t.Fatalf("trusted status=%d %s", w.Code, w.Body.String())
}
}
func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) }
func TestProjectLifecycleMemberPrivateWork(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := st.CreateAPIKey("alice", "test")
bob, _ := st.CreateAPIKey("bob", "test")
mallory, _ := st.CreateAPIKey("mallory", "test")
p, _ := st.CreateProject("alice", "Shared")
ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
ready := true
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return ready && (actor == "alice" || actor == "bob") && id == core.ID(p.ID), nil
}}, nil)
w := lifecycleRequest(t, s, bob, "POST", "/api/projects/"+p.ID+"/conversations", `{"title":"Bob private"}`)
if w.Code != 200 {
t.Fatalf("member create=%d %s", w.Code, w.Body.String())
}
var bc core.Conversation
if err := json.Unmarshal(w.Body.Bytes(), &bc); err != nil {
t.Fatal(err)
}
w = lifecycleRequest(t, s, bob, "GET", "/api/projects/"+p.ID+"/conversations", "")
var cs []core.Conversation
_ = json.Unmarshal(w.Body.Bytes(), &cs)
if w.Code != 200 || len(cs) != 1 || cs[0].ID != bc.ID {
t.Fatalf("member list=%d %+v", w.Code, cs)
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+ac.ID, ""); w.Code != 404 {
t.Fatalf("private history=%d", w.Code)
}
if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
t.Fatalf("nonmember create=%d", w.Code)
}
if w := lifecycleRequest(t, s, mallory, "GET", "/api/projects/"+p.ID+"/conversations", ""); w.Code != 404 {
t.Fatalf("nonmember list=%d", w.Code)
}
s.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() })
s.WireBuiltinCommands()
w = lifecycleRequest(t, s, bob, "POST", "/api/conversations/"+bc.ID+"/messages", `{"text":"/new"}`)
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte(`"conversation"`)) {
t.Fatalf("/new=%d %s", w.Code, w.Body.String())
}
ready = false
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
if w.Code != 404 {
t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String())
}
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 {
t.Fatalf("owned revoked history=%d", w.Code)
}
}
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Shared")
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
if w := lifecycleRequest(t, s, key, http.MethodPost, "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
t.Fatalf("read-only write=%d %s", w.Code, w.Body.String())
}
if cs, err := st.ListConversations("bob", p.ID); err != nil || len(cs) != 0 {
t.Fatalf("denial wrote state: %v %v", cs, err)
}
}
func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Project")
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
card := core.HITLCard{ID: "hitl-revoked", ConversationID: conv.ID, RunID: "run-revoked", Kind: "confirm"}
if err := st.SaveHITLCard("alice", &card); err != nil {
t.Fatal(err)
}
for _, q := range []struct{ path, body string }{{"/api/conversations/" + conv.ID + "/regenerate", `{}`}, {"/api/hitl/" + card.ID + "/resolve", `{"choiceId":"yes"}`}} {
w := lifecycleRequest(t, s, key, "POST", q.path, q.body)
if w.Code != 404 {
t.Fatalf("resumption %s=%d %s", q.path, w.Code, w.Body.String())
}
}
}
func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Project")
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
svc := surfacesvc.New(st, nil)
s.Surfaces = svc
if _, err := svc.SetActivation(context.Background(), "alice", "generative-ui", true); err != nil {
t.Fatal(err)
}
snap, err := svc.Submit(context.Background(), "alice", "generative-ui", surfacesvc.SubmitRequest{ConversationID: conv.ID, Spec: json.RawMessage(`{"title":"Page","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"Submit"}]}]}`)})
if err != nil {
t.Fatal(err)
}
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
w := lifecycleRequest(t, s, key, "POST", "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", `{"actionId":"generative-ui.submit","revision":1,"requestId":"request"}`)
if w.Code != 404 {
t.Fatalf("revoked surface action=%d %s", w.Code, w.Body.String())
}
}
func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Project")
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
if err != nil {
return nil, err
}
if current.Settings == nil {
current.Settings = map[string]any{}
}
current.Settings["rdm.projectId"] = "canonical"
current.Settings["rdm.state"] = "ready"
return []core.Project{current}, nil
}}, nil)
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"rdm.projectId":"forged","example":true}}`)
var got core.Project
_ = json.Unmarshal(w.Body.Bytes(), &got)
if w.Code != 200 || got.Settings["rdm.projectId"] != "canonical" || got.Settings["example"] != true {
t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String())
}
}
func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := st.CreateAPIKey("alice", "test")
bob, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Shared")
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
t.Fatal(err)
}
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugin.private.credential", Secret: true}})
read := func() (core.Project, error) { return st.GetProject("alice", p.ID) }
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := read()
return []core.Project{current}, err
}, Create: func(context.Context, extension.Host, string) (core.Project, error) { return read() }, Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { return read() }}, nil)
for _, q := range []struct{ key, method, path, body string }{{bob, "GET", "/api/projects", ""}, {alice, "GET", "/api/projects", ""}, {alice, "POST", "/api/projects", `{"name":"New"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"settings":{"display":{"caption":"Updated"}}}`}} {
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
if w.Code != 200 {
t.Fatalf("public metadata %s=%d", q.method, w.Code)
}
for _, secret := range []string{"fixture-key", "fixture-token", "fixture-flat-token", "fixture-schema-token"} {
if bytes.Contains(w.Body.Bytes(), []byte(secret)) {
t.Fatalf("%s %s leaked a fixture credential", q.method, q.path)
}
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) || !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.state":"ready"`)) {
t.Fatal("service metadata lost")
}
}
owned, err := read()
if err != nil {
t.Fatal(err)
}
if owned.Settings["models.chat.apiKey"] != "fixture-key" || owned.Settings["custom"].(map[string]any)["token"] != "fixture-token" {
t.Fatal("public metadata masking changed stored credentials")
}
}
func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) {
for _, registered := range []bool{false, true} {
t.Run(fmt.Sprint(registered), func(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Original")
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
if registered {
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
return []core.Project{current}, err
}, Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
if err := st.RenameProject("alice", id, name); err != nil {
return core.Project{}, err
}
return st.GetProject("alice", id)
}}, nil)
}
for _, body := range []string{`{"name":"Changed","settings":{"models":{"chat":{"apiKey":"fixture-key"}},"display":{"caption":"Forbidden partial write"}}}`, `{"settings":{"models.chat.apiKey":"fixture-key"}}`, `{"settings":{"models":"smash"}}`, `{"settings":{"plugins.private.managed":"override"}}`} {
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body)
if w.Code != 400 {
t.Fatalf("managed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil {
t.Fatal(err)
}
if current.Name != "Original" || len(current.Settings) != 0 {
t.Fatal("rejected managed patch applied a partial mutation")
}
}
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"custom.caption":"Allowed"}}`)
if w.Code != 200 {
t.Fatalf("allowed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil || current.Settings["custom.caption"] != "Allowed" {
t.Fatal("allowed metadata shape changed")
}
})
}
}
func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) {
for _, registered := range []bool{false, true} {
t.Run(fmt.Sprint(registered), func(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Project")
if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil {
t.Fatal(err)
}
if registered {
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
return []core.Project{current}, err
}}, nil)
}
for _, tc := range []struct{ body, caption string }{
{`{"settings":{"models":{"chat":{"apiKey":"••••••"}},"display":{"caption":"Nested updated"}}}`, "Nested updated"},
{`{"settings":{"models.chat":{"apiKey":"••••••"},"display":{"caption":"Mixed updated"}}}`, "Mixed updated"},
{`{"settings":{"models":{"chat.apiKey":"••••••"},"display":{"caption":"Mixed leaf updated"}}}`, "Mixed leaf updated"},
} {
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, tc.body)
if w.Code != 200 {
t.Fatalf("masked writeback rejected legal edit: %d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil {
t.Fatal(err)
}
if current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] != "fixture-persisted-key" || current.Settings["display"].(map[string]any)["caption"] != tc.caption {
t.Fatalf("masked writeback secret preserved=%v caption=%q expected=%q", current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] == "fixture-persisted-key", current.Settings["display"].(map[string]any)["caption"], tc.caption)
}
}
for _, body := range []string{`{"settings":{"models":{}}}`, `{"settings":{"models":{"chat":{}}}}`, `{"settings":{"models.chat":{}}}`, `{"settings":{"models":{"chat":{"apiKey":"••••••","model":"Overwrite"}}}}`} {
if w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body); w.Code != 400 {
t.Fatalf("explicit empty/real managed write accepted: %d", w.Code)
}
}
})
}
}
func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
file, err := writer.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte(content)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", &body)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}
func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := st.CreateAPIKey("alice", "test")
bob, _ := st.CreateAPIKey("bob", "test")
mallory, _ := st.CreateAPIKey("mallory", "test")
p, _ := st.CreateProject("alice", "Shared")
ready := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return ready && id == p.ID && (actor == "alice" || actor == "bob"), nil
}}, nil)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
if q.ResourceType == "artifact" {
return q.Actor == q.Owner, nil
}
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
})
s.DisableUploadIndexing = true
w := lifecycleUpload(t, s, bob, p.ID, "member-private.txt", "QA_MEMBER_PRIVATE_ORIGINAL")
if w.Code != 200 {
t.Fatalf("member upload=%d", w.Code)
}
var info core.UploadInfo
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
artifact, err := st.GetArtifact("bob", info.ArtifactID)
if err != nil || artifact.ProjectID != p.ID || artifact.Owner != "bob" {
t.Fatalf("upload ownership/target incorrect: %+v %v", artifact, err)
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/artifacts/"+info.ArtifactID+"/content", ""); w.Code != 200 || w.Body.String() != "QA_MEMBER_PRIVATE_ORIGINAL" {
t.Fatalf("owner original read=%d", w.Code)
}
for _, key := range []string{alice, mallory} {
for _, suffix := range []string{"", "/content"} {
if w := lifecycleRequest(t, s, key, "GET", "/api/artifacts/"+info.ArtifactID+suffix, ""); w.Code != 404 {
t.Fatalf("other actor read private original=%d", w.Code)
}
}
}
if w := lifecycleUpload(t, s, mallory, p.ID, "forbidden.txt", "DENIED"); w.Code != 404 {
t.Fatalf("nonmember upload=%d", w.Code)
}
ready = false
if w := lifecycleUpload(t, s, bob, p.ID, "revoked.txt", "DENIED"); w.Code != 404 {
t.Fatalf("revoked upload=%d", w.Code)
}
artifacts, err := st.ListProjectArtifactsAll(p.ID)
if err != nil || len(artifacts) != 1 {
t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err)
}
}
func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := st.CreateAPIKey("alice", "test")
bob, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Shared")
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return id == p.ID && (actor == "alice" || actor == "bob"), nil
}}, nil)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
})
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
s.DisableUploadIndexing = true
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil {
t.Fatal(err)
}
// The owner path already admitted upload before the member authorization fix.
if w := lifecycleUpload(t, s, alice, p.ID, "owner-private-filename.txt", "PRIVATE ORIGINAL"); w.Code != 200 {
t.Fatalf("owner upload=%d", w.Code)
}
if w := lifecycleUpload(t, s, bob, p.ID, "member-private-filename.txt", "MEMBER PRIVATE ORIGINAL"); w.Code != 200 {
t.Fatalf("member upload=%d", w.Code)
}
for _, owner := range []string{"alice", "bob"} {
cards, err := st.ListProjectCards(owner, p.ID)
if err != nil {
t.Fatal(err)
}
expected := 0
if owner == "alice" {
expected = 1
}
if len(cards) != expected {
t.Fatal("private upload added project card history")
}
for _, card := range cards {
if card.Title != "Shared structure" {
t.Fatal("private filename/parser card entered project history")
}
}
}
events, err := st.EventsAfter(core.ProjectChannelKey(p.ID), 0, 100)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 {
t.Fatal("private upload added project events")
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte("owner-private-filename.txt")) || bytes.Contains(event.Payload, []byte("member-private-filename.txt")) {
t.Fatal("private upload metadata entered persisted project stream")
}
}
request := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+p.ID, nil)
request.Header.Set("Authorization", "Bearer "+bob)
ctx, cancel := context.WithTimeout(request.Context(), 20*time.Millisecond)
defer cancel()
request = request.WithContext(ctx)
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != 200 || !bytes.Contains(response.Body.Bytes(), []byte("Shared structure")) {
t.Fatalf("member structural stream did not replay: %d", response.Code)
}
if bytes.Contains(response.Body.Bytes(), []byte("owner-private-filename.txt")) || bytes.Contains(response.Body.Bytes(), []byte("member-private-filename.txt")) {
t.Fatal("another member's SSE exposed private upload metadata")
}
}
func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Private")
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.DisableUploadIndexing = true
if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 {
t.Fatalf("unregistered owner upload=%d", w.Code)
}
cards, err := st.ListProjectCards("alice", p.ID)
if err != nil || len(cards) != 1 || cards[0].Title != "legacy-upload.txt" {
t.Fatalf("unregistered ingestion card changed: count=%d error=%v", len(cards), err)
}
if w := lifecycleRequest(t, s, key, "POST", "/api/projects/missing/uploads", "not multipart"); w.Code != 400 {
t.Fatalf("legacy missing-file validation changed: %d", w.Code)
}
}
func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Shared")
ready := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
allowed := ready
ready = false
return allowed, nil
}}, nil)
if w := lifecycleUpload(t, s, key, p.ID, "revoked-during-upload.txt", "PRIVATE"); w.Code != 404 {
t.Fatalf("upload persisted after authorization changed: %d", w.Code)
}
artifacts, err := st.ListProjectArtifactsAll(p.ID)
if err != nil || len(artifacts) != 0 {
t.Fatal("revoked upload created an artifact")
}
}