Files
knowledge-governance/internal/libraryagent/agent_test.go
T
姚章浩 910c0fcd76
build / build (push) Failing after 16s
feat(library): 页面发起封存的最小权限主机侧执行边界
- libraryagent:0700 目录内 0600 unix socket + SO_PEERCRED 同 uid,
  固定 binary/归档/日志/项目/写入者声明,请求只能选范围与作业号
- API:POST/GET /api/v1/library/captures(入队 library.capture + 读队列状态)
- worker:library.capture handler 走 socket;启动先 recover;不挂 Docker socket、
  不持有来源凭据
- web:整库版本页显示真实作业状态并可发起封存(未接线/无权限给出原因)
- scripts/library-agent-install.sh:构建、安装 systemd user 服务并读回权限
2026-10-28 12:11:08 +08:00

172 lines
4.9 KiB
Go

package libraryagent
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func testAgent(t *testing.T) (*Agent, string, func() []string) {
t.Helper()
root, err := os.MkdirTemp(os.TempDir(), "kgag-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
if err = os.Chmod(root, 0700); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var calls []string
config := Config{
Socket: filepath.Join(root, "agent.sock"),
Binary: "/bin/echo",
ArchiveRoot: filepath.Join(root, "library"),
JournalRoot: filepath.Join(root, "freeze"),
SourceApp: "WKNFull-app",
SourcePG: "WKNFull-postgres",
SourceES: "WKNFull-elasticsearch",
GovernancePG: "knowledge-governance-p1-postgres-1",
Project: "weknora071full",
Writers: "app",
NonWriters: "frontend=UI only; no knowledge writes",
MaxDuration: time.Minute,
Run: func(_ context.Context, name string, args ...string) ([]byte, error) {
mu.Lock()
calls = append(calls, name+" "+strings.Join(args, " "))
mu.Unlock()
return []byte(`{"snapshot_id":"abc","state":"succeeded"}`), nil
},
}
agent, err := New(config)
if err != nil {
t.Fatal(err)
}
return agent, config.Socket, func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), calls...)
}
}
func roundTrip(t *testing.T, socket string, payload string) response {
t.Helper()
conn, err := net.Dial("unix", socket)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if _, err = conn.Write([]byte(payload + "\n")); err != nil {
t.Fatal(err)
}
var out response
if err = json.NewDecoder(conn).Decode(&out); err != nil {
t.Fatal(err)
}
return out
}
// The socket is the whole security boundary: 0600 in a 0700 directory, and the
// declaration (archive, journal, project, writers) comes from the agent's own
// configuration, never from the request.
func TestAgentSocketIsPrivateAndPinsTheDeclaration(t *testing.T) {
agent, socket, calls := testAgent(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = agent.Serve(ctx) }()
deadline := time.Now().Add(2 * time.Second)
for {
if _, err := os.Stat(socket); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("agent socket never appeared")
}
time.Sleep(10 * time.Millisecond)
}
info, err := os.Stat(socket)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("socket mode %v", info.Mode().Perm())
}
dir, err := os.Stat(filepath.Dir(socket))
if err != nil {
t.Fatal(err)
}
if dir.Mode().Perm() != 0700 {
t.Fatalf("socket directory mode %v", dir.Mode().Perm())
}
out := roundTrip(t, socket, `{"action":"capture","scope":"internal","job":"lib-internal-test"}`)
if !out.OK || out.Job != "lib-internal-test" {
t.Fatalf("capture rejected: %+v", out)
}
recorded := calls()
if len(recorded) != 1 {
t.Fatalf("expected one execution, got %v", recorded)
}
for _, want := range []string{"capture -scope internal -job lib-internal-test",
"-archive " + filepath.Join(filepath.Dir(socket), "library"),
"-journal " + filepath.Join(filepath.Dir(socket), "freeze"),
"-project weknora071full", "-writers app"} {
if !strings.Contains(recorded[0], want) {
t.Fatalf("execution %q missing %q", recorded[0], want)
}
}
if strings.Contains(recorded[0], "..") {
t.Fatal("unexpected path traversal in declaration")
}
}
func TestAgentRejectsUnsupportedRequestsWithoutExecuting(t *testing.T) {
agent, socket, calls := testAgent(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = agent.Serve(ctx) }()
deadline := time.Now().Add(2 * time.Second)
for {
if _, err := os.Stat(socket); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("agent socket never appeared")
}
time.Sleep(10 * time.Millisecond)
}
for _, payload := range []string{
`{"action":"shell","command":"docker ps"}`,
`{"action":"capture","scope":"production"}`,
`{"action":"capture","scope":"internal","job":"bad job; rm -rf /"}`,
`not json`,
} {
out := roundTrip(t, socket, payload)
if out.OK || out.Error == "" {
t.Fatalf("accepted %q: %+v", payload, out)
}
}
if got := calls(); len(got) != 0 {
t.Fatalf("rejected requests still executed: %v", got)
}
}
func TestNewRejectsIncompleteConfiguration(t *testing.T) {
if _, err := New(Config{}); err == nil {
t.Fatal("empty configuration accepted")
}
if _, err := New(Config{Socket: "relative.sock", ArchiveRoot: "/tmp/a", JournalRoot: "/tmp/j",
Binary: "/bin/true", Project: "p", Writers: "app", NonWriters: "frontend=ui", MaxDuration: time.Minute}); err == nil {
t.Fatal("relative socket path accepted")
}
if _, err := New(Config{Socket: "/tmp/a.sock", ArchiveRoot: "/tmp/a", JournalRoot: "/tmp/j",
Binary: "/bin/true", Project: "p", Writers: "app", NonWriters: "frontend", MaxDuration: time.Minute}); err == nil {
t.Fatal("non-writer declaration without a reason accepted")
}
}