package app import ( "encoding/json" "strings" "testing" "time" "gorm.io/gorm" "infinite-canvas/backend/internal/model" ) func cloudAgentProfileFixture(t *testing.T) (*Service, string, string) { s, _, projectID, canvasID := cloudAgentProfileFixtureDB(t) return s, projectID, canvasID } func cloudAgentProfileFixtureDB(t *testing.T) (*Service, *gorm.DB, string, string) { t.Helper() s, db, _, _ := creationTestService(t) now := time.Now() for _, item := range []any{ &model.Project{ID: "project-1", UserID: "user", Name: "Agent 项目", Status: model.ProjectStatusActive, CreatedAt: now, UpdatedAt: now}, &model.Project{ID: "project-2", UserID: "other", Name: "其他用户项目", Status: model.ProjectStatusActive, CreatedAt: now, UpdatedAt: now}, &model.CanvasProject{ID: "agent-canvas", UserID: "user", ProjectID: "project-1", Title: "Agent 画布", PayloadJSON: `{"nodes":[],"connections":[]}`, CreatedAt: now, UpdatedAt: now}, &model.CanvasProject{ID: "foreign-canvas", UserID: "other", ProjectID: "project-2", Title: "其他用户画布", PayloadJSON: `{"nodes":[],"connections":[]}`, CreatedAt: now, UpdatedAt: now}, } { if err := db.Create(item).Error; err != nil { t.Fatal(err) } } return s, db, "project-1", "agent-canvas" } func saveAgentProfileForTest(t *testing.T, s *Service, userID string, req AgentProfileRequest) AgentProfileView { t.Helper() view, err := s.UpdateCloudAgentProfile(userID, req) if err != nil { t.Fatal(err) } return view } func TestCloudAgentProfileMergesUserProjectCanvasInOrder(t *testing.T) { s, projectID, canvasID := cloudAgentProfileFixture(t) saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "用户偏好", Revision: 0}) saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeProject, ProjectID: projectID, Content: "项目偏好", Revision: 0}) view := saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeCanvas, CanvasID: canvasID, Content: "画布偏好", Revision: 0}) if len(view.Layers) != 3 { t.Fatalf("expected three profile layers, got %#v", view.Layers) } for index, scope := range []string{model.AgentProfileScopeUser, model.AgentProfileScopeProject, model.AgentProfileScopeCanvas} { if view.Layers[index].Scope != scope { t.Fatalf("profile precedence changed: %#v", view.Layers) } } if view.Layers[1].ProjectID != projectID || view.Layers[2].ProjectID != projectID || view.Layers[2].CanvasID != canvasID { t.Fatalf("profile scope identity was not normalized: %#v", view.Layers) } text := agentProfileText(view.Layers) if strings.Index(text, "用户偏好") > strings.Index(text, "项目偏好") || strings.Index(text, "项目偏好") > strings.Index(text, "画布偏好") { t.Fatalf("profile text precedence changed: %q", text) } if view.Revision == "" || view.Hash != agentProfileHash(text) { t.Fatal("effective profile identifiers do not match the merged document") } } func TestCloudAgentProfileOwnershipAndScopeValidation(t *testing.T) { s, projectID, canvasID := cloudAgentProfileFixture(t) tests := []struct { name string userID string req AgentProfileRequest }{ {name: "foreign project update", userID: "user", req: AgentProfileRequest{Scope: model.AgentProfileScopeProject, ProjectID: "project-2", Content: "x"}}, {name: "foreign canvas update", userID: "user", req: AgentProfileRequest{Scope: model.AgentProfileScopeCanvas, CanvasID: "foreign-canvas", Content: "x"}}, {name: "canvas project mismatch", userID: "user", req: AgentProfileRequest{Scope: model.AgentProfileScopeCanvas, ProjectID: "project-mismatch", CanvasID: canvasID, Content: "x"}}, {name: "user scope carries project", userID: "user", req: AgentProfileRequest{Scope: model.AgentProfileScopeUser, ProjectID: projectID, Content: "x"}}, {name: "project scope missing id", userID: "user", req: AgentProfileRequest{Scope: model.AgentProfileScopeProject, Content: "x"}}, {name: "canvas scope missing id", userID: "user", req: AgentProfileRequest{Scope: model.AgentProfileScopeCanvas, Content: "x"}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if _, err := s.UpdateCloudAgentProfile(test.userID, test.req); err == nil { t.Fatal("invalid or foreign profile update was accepted") } }) } if _, err := s.CloudAgentProfileForScope("user", "project-2", ""); err == nil { t.Fatal("foreign project profile was readable") } if _, err := s.CloudAgentProfileForScope("user", "", "foreign-canvas"); err == nil { t.Fatal("foreign canvas profile was readable") } if _, err := s.CloudAgentProfileForScope("user", "project-mismatch", canvasID); err == nil { t.Fatal("canvas was readable through a mismatched project") } } func TestCloudAgentProfileRevisionCASAndNormalization(t *testing.T) { s, _, _ := cloudAgentProfileFixture(t) if _, err := s.UpdateCloudAgentProfile("user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "first", Revision: 1}); err == nil { t.Fatal("first write accepted a non-zero revision") } first := saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: " first \n", Revision: 0}) layer := first.Layers[0] if layer.Revision != 1 || layer.Content != "first" || layer.Hash != agentProfileHash("first") { t.Fatalf("first revision was not normalized: %#v", layer) } if _, err := s.UpdateCloudAgentProfile("user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "stale", Revision: 0}); err == nil { t.Fatal("stale profile revision was accepted") } second := saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "second", Revision: layer.Revision}) if second.Layers[0].Revision != 2 || second.Layers[0].Hash != agentProfileHash("second") { t.Fatalf("profile revision did not advance: %#v", second.Layers[0]) } blank := saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: " \n\t ", Revision: second.Layers[0].Revision}) if blank.Layers[0].Content != "" || agentProfileText(blank.Layers) != "" { t.Fatalf("blank profile still injects behavior text: %#v", blank) } } func TestCloudAgentProfileRejectsUnsafeContent(t *testing.T) { s, _, _ := cloudAgentProfileFixture(t) if _, err := s.UpdateCloudAgentProfile("", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "unowned"}); err == nil { t.Fatal("profile update accepted an unauthenticated owner") } tests := []struct { name string content string }{ {name: "too long", content: strings.Repeat("界", cloudAgentProfileMaxRunes+1)}, {name: "control character", content: "safe\x00unsafe"}, {name: "invalid utf8", content: string([]byte{0xff, 0xfe})}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if _, err := s.UpdateCloudAgentProfile("user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: test.content}); err == nil { t.Fatal("unsafe profile content was accepted") } }) } } func TestCloudAgentProfileEmptyViewAndRunSnapshotAreImmutable(t *testing.T) { s, _, canvasID := cloudAgentProfileFixture(t) empty, err := s.CloudAgentProfile("user", canvasID) if err != nil { t.Fatal(err) } if len(empty.Layers) != 0 || agentProfileText(empty.Layers) != "" || empty.Hash != agentProfileHash("") { t.Fatalf("unexpected empty profile behavior: %#v", empty) } profile := saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "固定偏好", Revision: 0}) req := agentTestRequest() req.IdempotencyKey = "profile-snapshot-run" req.ProfileRevision = profile.Revision run, err := s.CreateCloudAgentRun("user", req, "") if err != nil { t.Fatal(err) } saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "后来修改", Revision: profile.Layers[0].Revision}) task, err := s.repo.TaskForUser("user", run.ID) if err != nil { t.Fatal(err) } var input struct { CloudAgent cloudAgentState `json:"cloudAgent"` Config struct { SystemPrompt string `json:"systemPrompt"` } `json:"config"` } if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { t.Fatal(err) } if input.CloudAgent.Policy.ProfileRevision != profile.Revision || input.CloudAgent.Policy.ProfileHash != profile.Hash { t.Fatalf("run profile snapshot changed: %#v", input.CloudAgent.Policy) } if strings.Contains(input.Config.SystemPrompt, "固定偏好") || strings.Contains(input.Config.SystemPrompt, "后来修改") { t.Fatal("profile body leaked into the system prompt") } if len(input.CloudAgent.Profile.Layers) != 1 || input.CloudAgent.Profile.Layers[0].Content != "固定偏好" { t.Fatal("run did not preserve the admitted profile body outside the system prompt") } state := cloudAgentRuntime{Profile: input.CloudAgent.Profile} call := cloudAgentCall{} call.Function.Name, call.Function.Arguments = "agent_profile_read", `{"scope":"user"}` result, err := cloudAgentReadTool(nil, "user", &state, call) if err != nil || result.(map[string]any)["content"] != "固定偏好" { t.Fatalf("fixed profile could not be read on demand: %v, %v", result, err) } if _, err := cloudAgentReadTool(nil, "user", &state, call); err == nil { t.Fatal("profile layer could be read repeatedly") } stale := agentTestRequest() stale.IdempotencyKey = "profile-stale-run" stale.ProfileRevision = profile.Revision if _, err := s.CreateCloudAgentRun("user", stale, ""); err == nil { t.Fatal("run admission accepted a stale profile revision") } } func TestCloudAgentContinuationReadsLatestProfileWithoutChangingParent(t *testing.T) { s, db, _, canvasID := cloudAgentProfileFixtureDB(t) firstProfile := saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "第一版专属口吻", Revision: 0}) first := agentTestRequest() first.IdempotencyKey = "profile-parent-key" first.ProfileRevision = firstProfile.Revision parent, err := s.CreateCloudAgentRun("user", first, "") if err != nil { t.Fatal(err) } if err := db.Model(&model.Task{}).Where("id = ?", parent.ID).Updates(map[string]any{"status": model.TaskStatusSucceeded, "result_json": `{"text":"已完成"}`}).Error; err != nil { t.Fatal(err) } secondProfile := saveAgentProfileForTest(t, s, "user", AgentProfileRequest{Scope: model.AgentProfileScopeUser, Content: "第二版专属口吻", Revision: firstProfile.Layers[0].Revision}) childRequest := agentTestRequest() childRequest.CanvasID = canvasID childRequest.IdempotencyKey = "profile-child-key" childRequest.ProfileRevision = secondProfile.Revision child, err := s.CreateCloudAgentRun("user", childRequest, parent.ID) if err != nil { t.Fatal(err) } for _, item := range []struct { id, wantRevision, wantText, unwantedText string }{ {parent.ID, firstProfile.Revision, "第一版专属口吻", "第二版专属口吻"}, {child.ID, secondProfile.Revision, "第二版专属口吻", "第一版专属口吻"}, } { task, err := s.repo.TaskForUser("user", item.id) if err != nil { t.Fatal(err) } var input struct { CloudAgent cloudAgentState `json:"cloudAgent"` Config struct { SystemPrompt string `json:"systemPrompt"` } `json:"config"` } if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { t.Fatal(err) } if input.CloudAgent.Policy.ProfileRevision != item.wantRevision || strings.Contains(input.Config.SystemPrompt, item.wantText) || strings.Contains(input.Config.SystemPrompt, item.unwantedText) || len(input.CloudAgent.Profile.Layers) != 1 || input.CloudAgent.Profile.Layers[0].Content != item.wantText { t.Fatalf("run %s did not preserve its profile snapshot", item.id) } } }