ci / vet-and-test (pull_request) Successful in 49s
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
505 lines
23 KiB
Go
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")
|
|
}
|
|
}
|