Files
Agent-System-Backend/internal/server/project_lifecycle_test.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

507 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, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
mallory, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
mallory, _ := createLegacyAPIKeyFixture(t, st, "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, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
bob, _ := createLegacyAPIKeyFixture(t, st, "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.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
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, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
p, _ := st.CreateProject("alice", "Private")
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked)
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, _ := createLegacyAPIKeyFixture(t, st, "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")
}
}