Files
Agent-System-Backend/internal/app/project_lifecycle_test.go
T
weichen f1302ed5b3 Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:47:16 +00:00

164 lines
6.5 KiB
Go

package app
import (
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/server"
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/settings"
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store"
"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"
"net/http/httptest"
"testing"
)
func completeLifecycle() extension.ProjectLifecycle {
return extension.ProjectLifecycle{
List: func(context.Context, extension.Host) ([]core.Project, error) { return nil, nil },
Create: func(context.Context, extension.Host, string) (core.Project, error) { return core.Project{}, nil },
Rename: func(context.Context, extension.Host, string, string) (core.Project, error) {
return core.Project{}, nil
},
Archive: func(context.Context, extension.Host, string) error { return nil },
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
}
}
func TestProjectLifecycleOptionalRegistration(t *testing.T) {
slot := &projectLifecycleSlot{}
reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}}
if err := reg.RegisterProjectLifecycle(extension.ProjectLifecycle{}); err == nil {
t.Fatal("incomplete registration accepted")
}
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err == nil {
t.Fatal("duplicate registration accepted")
}
if err := (compiledRegistry{}).RegisterProjectLifecycle(completeLifecycle()); err == nil {
t.Fatal("declarative callback accepted")
}
rr := recordRegistry{Registry: pluginRegistry{pluginID: "other", caps: hostcaps{projects: &projectLifecycleSlot{}}}}
optional, ok := any(rr).(extension.ProjectLifecycleRegistry)
if !ok {
t.Fatal("record registry hides optional registration")
}
if err := optional.RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
}
func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
alice := inprocHost{owner: "alice", caps: hostcaps{st: st}}
p, err := alice.EnsureProject("stable", "Shared")
if err != nil {
t.Fatal(err)
}
auth := &projectAuthz{}
auth.set("rdm", func(owner string, id core.ID) (bool, error) { return owner == "bob" && id == core.ID(p.ID), nil })
bob := inprocHost{owner: "bob", caps: hostcaps{st: st, authz: auth}}
if _, err := bob.ProjectMetadata(p.ID); err != nil {
t.Fatal(err)
}
if _, err := bob.OwnedProject(p.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("owned lookup=%v", err)
}
if _, err := bob.RenameOwnedProject(p.ID, "Other"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("shared rename=%v", err)
}
if _, err := bob.EnsureProject(p.ID, p.Name); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("shared ensure=%v", err)
}
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil })
if _, err := bob.ProjectMetadata(p.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("V2 denial ignored=%v", err)
}
}
func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
p, err := st.CreateProject("alice", "Shared")
if err != nil {
t.Fatal(err)
}
raw := map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-nested-secret"}}, "models.embedding.apiKey": "fixture-flat-secret", "legacy.token": "fixture-legacy-secret", "plugins.private": map[string]any{"credential": "fixture-schema-secret"}, "display": map[string]any{"caption": "Shared configuration"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
t.Fatal(err)
}
svc := settings.NewService(st)
svc.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
slot := &projectLifecycleSlot{}
auth := &projectAuthz{}
caps := hostcaps{st: st, set: svc, projects: slot, authz: auth}
provider := completeLifecycle()
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
project, err := h.(extension.ProjectHost).ProjectMetadata(p.ID)
return []core.Project{project}, err
}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
t.Fatal(err)
}
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
return q.Actor == "bob" && q.ResourceType == "project" && q.ResourceID == p.ID, nil
})
for _, actor := range []string{"bob", "alice"} {
h := inprocHost{owner: actor, caps: caps}
view, err := h.ProjectMetadata(p.ID)
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(view.Settings)
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
if bytes.Contains(encoded, []byte(value)) {
t.Fatalf("%s metadata exposed a fixture credential", actor)
}
}
if view.Settings["rdm.projectId"] != "canonical" || view.Settings["rdm.state"] != "ready" || view.Settings["display"].(map[string]any)["caption"] != "Shared configuration" {
t.Fatal("allowed service/configuration metadata changed")
}
}
// Exercise the real registry -> actor-scoped Host -> public HTTP list chain.
srv := server.New(st, nil, nil, nil, svc, nil)
srv.Auth = server.NewAuthenticator(st, true)
slot.wire(srv, caps)
key, err := st.CreateAPIKey("bob", "test")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest("GET", "/api/projects", nil)
request.Header.Set("Authorization", "Bearer "+key)
response := httptest.NewRecorder()
srv.Handler().ServeHTTP(response, request)
if response.Code != 200 {
t.Fatalf("member list=%d", response.Code)
}
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
if bytes.Contains(response.Body.Bytes(), []byte(value)) {
t.Fatal("actor-scoped lifecycle list exposed a fixture credential")
}
}
if !bytes.Contains(response.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) {
t.Fatal("actor-scoped list lost canonical metadata")
}
owned, err := (inprocHost{owner: "alice", caps: caps}).OwnedProject(p.ID)
if err != nil {
t.Fatal(err)
}
before, _ := json.Marshal(raw)
after, _ := json.Marshal(owned.Settings)
if !bytes.Equal(before, after) {
t.Fatal("metadata read mutated persisted owner settings")
}
}