package server import ( "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/cardsvc" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/cmdbus" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/surfacesvc" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/pkg/extension" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" "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") } }