Files
Agent-System-Backend/internal/server/settings_completion_test.go
weichen f965c5877f Enforce settings ownership and versioned shared configuration
Why: Personal project rows and credentials could override system policy and split shared business configuration by actor.

Evidence: SELF receipts /tmp/project-settings-backend-full-20261014-973d52c1.json and /tmp/rdm-settings-backend-final-green-20261014-03.json preserve full commands, object snapshots and output. PostgreSQL and current-head CI remain NOT_RUN.

Impact: Add scope-aware APIs, a fail-closed optional project configuration authorizer, CAS updates and explicit atomic migration. Preserve legacy rows and private preferences; protected secrets and runtime model routes obey system ownership.

Refs: P1/P2/P8, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:28:41 +00:00

388 lines
17 KiB
Go

package server
import (
"agent-system-backend/internal/settings"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
func TestSharedProjectSettingsRealRequestsAndRevocation(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 business")
other, _ := st.CreateProject("alice", "Other")
revoked := false
s.SetProjectSettingsAuthorizer(func(_ context.Context, actor, id string, write bool) error {
if id != p.ID || revoked || actor == "mallory" {
return lifecycleStatusError(404)
}
if write && actor != "alice" {
return lifecycleStatusError(403)
}
return nil
})
write := func(key, body string) int {
return lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID, body).Code
}
if code := write(alice, `{"values":{"deliverables.style":"formal"},"expectedRevision":0}`); code != 200 {
t.Fatalf("save=%d", code)
}
w := lifecycleRequest(t, s, bob, "GET", "/api/settings?project="+p.ID, "")
var view map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &view)
if w.Code != 200 || view["projectRevision"] != float64(1) || view["projectCanConfigure"] != false || !strings.Contains(w.Body.String(), "formal") {
t.Fatalf("member shared read=%d %s", w.Code, w.Body.String())
}
if code := write(bob, `{"values":{"deliverables.style":"minimal"},"expectedRevision":1}`); code != 403 {
t.Fatalf("work/member treated as configuration authority=%d", code)
}
if code := write(alice, `{"values":{"deliverables.style":"minimal"},"expectedRevision":0}`); code != 409 {
t.Fatalf("stale save=%d", code)
}
for _, id := range []string{other.ID, "missing"} {
if w := lifecycleRequest(t, s, bob, "GET", "/api/settings?project="+id, ""); w.Code != 404 {
t.Fatalf("cross project read=%d", w.Code)
}
}
if w := lifecycleRequest(t, s, mallory, "GET", "/api/settings?project="+p.ID, ""); w.Code != 404 {
t.Fatalf("outsider read=%d", w.Code)
}
if w := lifecycleRequest(t, s, alice, "PUT", "/api/settings/project/"+p.ID+"/personal", `{"crossSession.read":true,"ui.theme":"light","context.userInstructions":"Alice only"}`); w.Code != 200 {
t.Fatalf("personal save=%d %s", w.Code, w.Body.String())
}
w = lifecycleRequest(t, s, bob, "GET", "/api/settings?project="+p.ID, "")
if strings.Contains(w.Body.String(), "Alice only") || s.set.Effective("bob", p.ID).GetBool("crossSession.read", false) {
t.Fatal("personal state shared with another member")
}
revoked = true
if w := lifecycleRequest(t, s, alice, "GET", "/api/settings?project="+p.ID, ""); w.Code != 404 {
t.Fatalf("creator bypassed revocation=%d", w.Code)
}
if code := write(alice, `{"values":{"deliverables.style":"minimal"},"expectedRevision":1}`); code != 404 {
t.Fatalf("revoked write=%d", code)
}
if got := s.set.Effective("alice", p.ID).GetString("deliverables.style", ""); got != "formal" {
t.Fatalf("denied save changed data: %s", got)
}
}
func TestSharedProjectSettingsConcurrentRevision(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Concurrent")
var wg sync.WaitGroup
codes := make(chan int, 2)
for _, style := range []string{"formal", "minimal"} {
wg.Add(1)
go func(style string) {
defer wg.Done()
body := fmt.Sprintf(`{"values":{"deliverables.style":%q},"expectedRevision":0}`, style)
codes <- lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID, body).Code
}(style)
}
wg.Wait()
close(codes)
counts := map[int]int{}
for code := range codes {
counts[code]++
}
if counts[200] != 1 || counts[409] != 1 {
t.Fatalf("concurrent save outcomes=%v", counts)
}
}
func TestProjectWorkCapabilityCannotConfigureSettings(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Capability")
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil)
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID, `{"values":{"deliverables.style":"formal"},"expectedRevision":0}`); w.Code != 404 {
t.Fatalf("missing configure capability allowed write: %d", w.Code)
}
}
func TestMigrationInventoryProtectedAndNonDestructive(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
if w := lifecycleRequest(t, s, key, "GET", "/api/settings/migration?dryRun=true", ""); w.Code != 403 {
t.Fatalf("nonadmin inventory=%d", w.Code)
}
s.Auth = nil
_ = st.MergeSettingsScope("alice", "project:p", map[string]any{"deliverables.style": "formal", "models.chat.apiKey": "sensitive-source-fixture"})
_ = st.MergeSettingsScope("bob", "project:p", map[string]any{"deliverables.style": "minimal"})
_, _ = st.AddSecret("alice", "models.chat", "Old", "sensitive-secret-fixture")
w := lifecycleRequest(t, s, "", "GET", "/api/settings/migration?dryRun=true", "")
if w.Code != 200 || !strings.Contains(w.Body.String(), `"conflict"`) || strings.Contains(w.Body.String(), "sensitive-") {
t.Fatalf("inventory invalid=%d %s", w.Code, w.Body.String())
}
raw, _ := st.GetSettingsScope("alice", "project:p")
encoded, _ := json.Marshal(raw)
if !strings.Contains(string(encoded), "sensitive-source-fixture") {
t.Fatal("dry run removed recovery value")
}
if s.set.Effective("bob", "p").GetString("deliverables.style", "") != "modern" {
t.Fatal("conflicting legacy values automatically promoted")
}
}
func TestPersonalSecretsRejectSystemManagedServices(t *testing.T) {
s, st := newAuthTestServer(t)
key, err := st.CreateAPIKey("alice", "test")
if err != nil {
t.Fatal(err)
}
for _, service := range []string{"models.chat", "models.lightweight", "models.embedding", "models.rerank", "tools.tavily"} {
body, _ := json.Marshal(map[string]string{"service": service, "value": "fixture-key"})
w := lifecycleRequest(t, s, key, "POST", "/api/secrets", string(body))
if w.Code != http.StatusBadRequest {
t.Fatalf("personal %s accepted: %d %s", service, w.Code, w.Body.String())
}
}
old, err := st.AddSecret("alice", "models.chat", "old", "old-fixture-key")
if err != nil {
t.Fatal(err)
}
for _, action := range []struct{ method, path, body string }{{"POST", "/api/secrets/" + old.ID + "/activate", ""}, {"PATCH", "/api/secrets/" + old.ID, `{"label":"new"}`}, {"DELETE", "/api/secrets/" + old.ID, ""}} {
if w := lifecycleRequest(t, s, key, action.method, action.path, action.body); w.Code != 400 {
t.Fatalf("old protected key mutable: %s %d", action.method, w.Code)
}
}
if w := lifecycleRequest(t, s, key, "GET", "/api/secrets", ""); w.Code != 200 || strings.Contains(w.Body.String(), old.ID) {
t.Fatal("retired protected key displayed as personal configuration")
}
if w := lifecycleRequest(t, s, key, "POST", "/api/secrets", `{"service":"custom.personal","value":"fixture"}`); w.Code != 200 {
t.Fatal("unrelated personal credentials blocked")
}
}
func TestSettingsReadProjectsOnlyRequestedScopes(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Settings project")
w := lifecycleRequest(t, s, key, "GET", "/api/settings?project="+p.ID, "")
if w.Code != http.StatusOK {
t.Fatalf("read: %d %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "models.chat") || strings.Contains(w.Body.String(), "主模型") {
t.Fatal("system schema exposed in project settings")
}
}
func TestSettingsRejectUnknownAndMalformedPaths(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
for _, body := range []string{`{"unknown":{"key":true}}`, `{"unknown":"••••••"}`, `{"ui":{"theme":{"nested":"light"}}}`, `{"ui":{}}`, `{"ui..theme":"light"}`, `{"ui":{"theme":"invalid"}}`, `{"ui.theme":"invalid","ui":{"theme":"light"}}`, `null`} {
w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("malformed accepted %s: %d", body, w.Code)
}
}
}
func TestProjectSettingsExplicitMigrationSourceAndRetry(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Migration")
if err := st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "formal", "ui.theme": "light", "context.userInstructions": "private-original"}); err != nil {
t.Fatal(err)
}
if err := st.MergeSettingsScope("bob", "project:"+p.ID, map[string]any{"deliverables.style": "minimal"}); err != nil {
t.Fatal(err)
}
preview := func() string {
w := lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/preview", `{"choices":[{"path":"deliverables.style","sourceOwner":"alice"}],"expectedRevision":0}`)
if w.Code != 200 {
t.Fatalf("preview=%d %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "private-original") || strings.Contains(w.Body.String(), "formal") {
t.Fatal("preview disclosed source values")
}
var response struct {
Observation string `json:"observation"`
}
_ = json.Unmarshal(w.Body.Bytes(), &response)
return response.Observation
}
apply := func(observation string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"observation": observation, "expectedRevision": 0})
return lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/apply", string(body))
}
old := preview()
_ = st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "modern"})
if w := apply(old); w.Code != 409 {
t.Fatalf("changed source accepted=%d", w.Code)
}
if got := s.set.Effective("alice", p.ID).GetString("deliverables.style", ""); got != "modern" {
t.Fatalf("stale apply changed target=%s", got)
}
_ = st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "formal"})
observation := preview()
if w := apply(observation); w.Code != 200 {
t.Fatalf("apply=%d %s", w.Code, w.Body.String())
}
if w := apply(observation); w.Code != 200 {
t.Fatalf("retry=%d %s", w.Code, w.Body.String())
}
if got := s.set.Effective("bob", p.ID).GetString("deliverables.style", ""); got != "formal" {
t.Fatalf("explicit selected source not applied=%s", got)
}
raw, _ := st.GetSettingsScope("alice", "project:"+p.ID)
encoded, _ := json.Marshal(raw)
if !strings.Contains(string(encoded), "private-original") || !strings.Contains(string(encoded), "formal") {
t.Fatal("migration changed original/private source")
}
}
func TestExplicitSystemMigrationCASAndCredentialRevocation(t *testing.T) {
s, st := newTestServer(t)
_, err := st.AddSecret(settings.OwnerSystemRow, "models.chat", "old", "old-system-fixture")
if err != nil {
t.Fatal(err)
}
code, view := doJSON(t, s.Handler(), "GET", "/api/settings/system", nil)
if code != 200 {
t.Fatal(code)
}
revision := int64(view["systemRevision"].(float64))
save := func(path, value string, expected int64) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"values": map[string]any{"models.chat.apiKey": value}, "expectedRevision": expected})
return lifecycleRequest(t, s, "", "POST", path, string(body))
}
if w := save("/api/settings/migration/system", "new-system-fixture", revision); w.Code != 200 {
t.Fatalf("explicit provided system values=%d %s", w.Code, w.Body.String())
}
if got, _ := s.set.LookupSecret("alice", "models.chat"); got != "new-system-fixture" {
t.Fatal("old active table credential shadowed explicit new value")
}
if w := save("/api/settings/migration/system", "wrong-stale-fixture", revision); w.Code != 409 {
t.Fatalf("stale system migration=%d", w.Code)
}
if w := save("/api/settings/migration/system", "", revision+1); w.Code != 200 {
t.Fatalf("revoke=%d %s", w.Code, w.Body.String())
}
if got, _ := s.set.LookupSecret("alice", "models.chat"); got != "" {
t.Fatal("revocation fell back to old credential")
}
if w := lifecycleRequest(t, s, "", "PUT", "/api/settings/system", `{"models.chat.model":"legacy-format"}`); w.Code != 400 {
t.Fatal("legacy unversioned write silently accepted")
}
}
func TestMigrationRejectsUntrustedSourcesTokensAndRevokedActor(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
bob, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Trusted")
other, _ := st.CreateProject("alice", "Other")
_ = st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "formal"})
_ = st.MergeSettingsScope("bob", "project:"+p.ID, map[string]any{"deliverables.style": "minimal"})
preview := func(source string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"choices": []map[string]string{{"path": "deliverables.style", "sourceOwner": source}}, "expectedRevision": 0})
return lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/preview", string(body))
}
if w := preview("bob"); w.Code != 404 {
t.Fatalf("forged scope granted source authority=%d", w.Code)
}
w := preview("alice")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var response struct {
Observation string `json:"observation"`
}
_ = json.Unmarshal(w.Body.Bytes(), &response)
body, _ := json.Marshal(map[string]any{"observation": response.Observation, "expectedRevision": 0})
if w := lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+other.ID+"/migration/apply", string(body)); w.Code != 404 {
t.Fatal("token reused for another project")
}
if w := lifecycleRequest(t, s, bob, "POST", "/api/settings/project/"+p.ID+"/migration/apply", string(body)); w.Code != 404 {
t.Fatal("token reused by another actor")
}
s.SetProjectSettingsAuthorizer(func(context.Context, string, string, bool) error { return lifecycleStatusError(403) })
if w := lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/apply", string(body)); w.Code != 403 {
t.Fatal("revoked actor migration applied")
}
}
func TestPersonalHTTPToolsCannotExportSystemCredential(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
for _, ref := range []string{"models.chat.apiKey", "tools.tavily.apiKeys", "env:MODEL_API_KEY"} {
body, _ := json.Marshal(map[string]any{"name": "private.export", "http": map[string]any{"method": "GET", "url": "https://user-controlled.invalid", "authRef": ref}})
if w := lifecycleRequest(t, s, key, "POST", "/api/tools", string(body)); w.Code != 400 {
t.Fatalf("personal tool can refer to protected credential namespace %s: %d", ref, w.Code)
}
}
}
func TestPublicProjectAndSystemUnsetEnvelope(t *testing.T) {
s, st := newTestServer(t)
p, _ := st.CreateProject("local", "Defaults")
w := lifecycleRequest(t, s, "", "PUT", "/api/settings/project/"+p.ID, `{"values":{"deliverables.style":"formal"},"expectedRevision":0}`)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
w = lifecycleRequest(t, s, "", "PUT", "/api/settings/project/"+p.ID, `{"values":{},"unset":["deliverables.style"],"expectedRevision":1}`)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
if s.set.Effective("local", p.ID).GetString("deliverables.style", "") != "modern" {
t.Fatal("project unset did not restore inherited default")
}
if w := lifecycleRequest(t, s, "", "PUT", "/api/settings/project/"+p.ID, `{"values":{"deliverables.style":"formal"},"unset":["deliverables.style"],"expectedRevision":2}`); w.Code != 400 {
t.Fatal("overlap request accepted")
}
revision, _ := s.set.SystemRevision()
body, _ := json.Marshal(map[string]any{"values": map[string]any{}, "unset": []string{"runtime.mock"}, "expectedRevision": revision})
if w := lifecycleRequest(t, s, "", "PUT", "/api/settings/system", string(body)); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
if s.set.Effective("local", "").GetBool("runtime.mock", true) {
t.Fatal("system unset did not restore default")
}
}
func TestPublicPersonalMergePatchDeletesOnlyKnownNonsecretLeaves(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Personal reset")
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", `{"ui.theme":"light","ui.cardflow":false,"retrieval.topK":12}`); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", `{"ui.theme":null,"ui.cardflow":null,"retrieval.topK":null}`); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
w := lifecycleRequest(t, s, key, "GET", "/api/settings", "")
var view struct {
User map[string]any `json:"user"`
}
_ = json.Unmarshal(w.Body.Bytes(), &view)
if w.Code != 200 || view.User["ui"] != nil || view.User["retrieval"] != nil {
t.Fatal("deleted preferences still read back as overrides")
}
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID+"/personal", `{"crossSession.read":true}`); w.Code != 200 {
t.Fatal(w.Code)
}
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID+"/personal", `{"crossSession.read":null}`); w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
if s.set.Effective("alice", p.ID).GetBool("crossSession.read", true) {
t.Fatal("personal project reset stayed active")
}
for _, body := range []string{`{"ui":null}`, `{"unknown":null}`, `{"models.chat.apiKey":null}`, `{"deliverables.style":null}`, `{"_policy.managedModels":null}`} {
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", body); w.Code != 400 {
t.Fatal("invalid personal reset accepted")
}
}
}