Files
Agent-System-Backend/internal/server/disk_full_test.go
T
weichen 0a7413903d
ci / vet-and-test (pull_request) Successful in 48s
Harden cross-instance HITL/cancel command paths and add disk-full rejection tests (U7)
Why: two-process HTTP acceptance (Dev tests/integration/multi-instance, added in
the matching Dev-side change) exposed three real cross-instance defects that the
U5 library-level tests bypassed via direct Manager calls, plus the U7 fault
matrix needs explicit disk-full rejection evidence.
Evidence: go test ./... green on SQLite and real PG (AGENT_PG_TEST_DSN);
new regressions TestCrossInstanceResolveDoesNotSettleCard (runmgr, PG-gated),
TestUploadRejectsCleanlyWhenObjectDirUnwritable and
TestArtifactOverwriteRejectsCleanlyWhenObjectDirUnwritable (server).
Impact: ResolveHITL no longer settles the card when the run executes on another
instance (command-bus fallback stays available); cancelRun enqueues the command
with the real conversation id and no longer reports a false local success;
object-store failures now provably leave zero DB/object residue.
Refs: collaboration storage proposal U7 (sections 3/5/7); task card
feature/u7-load-fault-release.
2026-10-12 02:28:48 +00:00

218 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runmgr"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
)
// 协作存储 U7 故障矩阵(§7「磁盘满/备份恢复/整机失效」行的磁盘满维度):
// 对象目录不可写时,写入类入口必须**清楚拒绝**(明确 5xx,绝不 2xx 静默或
// 半成品落库),且恢复后不留后遗症。注入不依赖 chmod(CI 容器以 root 运行,
// 0500 目录仍可写):把 artifacts 目录移走并在原位放置同名普通文件,
// blobstore 临时文件写入得到 ENOTDIR——与磁盘满同为 os.WriteFile 层的
// 持久性失败。
//
// 覆盖:
// 1. 上传(POST /api/projects/{id}/uploads)在对象目录失效时 5xx,
// artifacts 表零新增、对象目录零残留;恢复后同一上传成功。
// 2. 工件内容覆写(PUT /api/artifacts/{id}/content)同样拒绝,
// 且原内容保持可读(拒绝不产生半写状态)。
// breakArtifactsDir 将 <dir>/artifacts 移至 <dir>/artifacts.moved 并在原位
// 放置同名文件;返回恢复函数。
func breakArtifactsDir(t *testing.T, dataDir string) (restore func()) {
t.Helper()
dir := filepath.Join(dataDir, "artifacts")
moved := dir + ".moved"
if err := os.Rename(dir, moved); err != nil {
t.Fatalf("move artifacts dir: %v", err)
}
if err := os.WriteFile(dir, []byte("not a directory"), 0o644); err != nil {
t.Fatalf("plant file at artifacts path: %v", err)
}
return func() {
if err := os.Remove(dir); err != nil {
t.Fatalf("remove planted file: %v", err)
}
if err := os.Rename(moved, dir); err != nil {
t.Fatalf("restore artifacts dir: %v", err)
}
}
}
// diskFullServer 自建可控 dataDir 的服务(artifacts 根 = <dataDir>/artifacts,
// 见 store.OpenSQLite 的 finishOpen 约定),其余装配与 admission/e2e 一致。
func diskFullServer(t *testing.T) (http.Handler, *store.Store, string) {
t.Helper()
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
profSvc := profiles.New(st)
profSvc.EnsureDefault()
mgr := runmgr.New(st, bus.New(), reg, profSvc, setSvc, &runtime.MockRunner{}, &runtime.MockRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
t.Cleanup(mgr.Shutdown)
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
srv := New(st, mgr, reg, profSvc, setSvc, ph)
srv.Bus = cmdbus.New(st, nil, nil)
srv.WireBuiltinCommands()
return srv.Handler(), st, dir
}
func multipartUpload(t *testing.T, h http.Handler, projectID, filename string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
if _, err := io.Copy(fw, bytes.NewReader([]byte("disk-full probe content: "+filename))); err != nil {
t.Fatal(err)
}
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+projectID+"/uploads", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func artifactCount(t *testing.T, st *store.Store, projectID string) (int, map[string]bool) {
t.Helper()
arts, err := st.ListArtifacts(store.OwnerDefault, projectID)
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, a := range arts {
names[a.Name] = true
}
return len(arts), names
}
func objectsIn(t *testing.T, dir string) map[string]bool {
t.Helper()
out := map[string]bool{}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
out[e.Name()] = true
}
return out
}
func TestUploadRejectsCleanlyWhenObjectDirUnwritable(t *testing.T) {
h, st, dir := diskFullServer(t)
code, out := doJSON(t, h, "POST", "/api/projects", map[string]any{"name": "disk-full"})
if code != http.StatusOK {
t.Fatalf("create project: %d %v", code, out)
}
projID := out["id"].(string)
seed := multipartUpload(t, h, projID, "seed.txt")
if seed.Code != http.StatusOK {
t.Fatalf("seed upload: %d %.200s", seed.Code, seed.Body.String())
}
beforeCount, _ := artifactCount(t, st, projID)
beforeObjs := objectsIn(t, filepath.Join(dir, "artifacts"))
restore := breakArtifactsDir(t, dir)
rec := multipartUpload(t, h, projID, "probe-diskfull.txt")
if rec.Code < 500 {
t.Fatalf("upload under broken object dir: status %d (want explicit 5xx rejection), body %.200s", rec.Code, rec.Body.String())
}
if got, _ := artifactCount(t, st, projID); got != beforeCount {
t.Fatalf("rejected upload left DB rows: %d -> %d", beforeCount, got)
}
if got := objectsIn(t, filepath.Join(dir, "artifacts.moved")); len(got) != len(beforeObjs) {
t.Fatalf("rejected upload left object residue: %v -> %v", beforeObjs, got)
}
restore()
after := multipartUpload(t, h, projID, "probe-diskfull-recovered.txt")
if after.Code != http.StatusOK {
t.Fatalf("upload after recovery: %d %.200s", after.Code, after.Body.String())
}
if got, names := artifactCount(t, st, projID); got != beforeCount+1 || !names["probe-diskfull-recovered.txt"] {
t.Fatalf("recovered upload missing row: count %d names %v", got, names)
}
}
func TestArtifactOverwriteRejectsCleanlyWhenObjectDirUnwritable(t *testing.T) {
h, st, dir := diskFullServer(t)
code, out := doJSON(t, h, "POST", "/api/projects", map[string]any{"name": "disk-full-2"})
if code != http.StatusOK {
t.Fatalf("create project: %d %v", code, out)
}
projID := out["id"].(string)
seed := multipartUpload(t, h, projID, "base.txt")
if seed.Code != http.StatusOK {
t.Fatalf("seed upload: %d %.200s", seed.Code, seed.Body.String())
}
arts, err := st.ListArtifacts(store.OwnerDefault, projID)
if err != nil || len(arts) != 1 {
t.Fatalf("seed artifacts: %d err %v", len(arts), err)
}
artID := string(arts[0].ID)
getContent := func() string {
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+artID+"/content", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get content: %d %.200s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
before := getContent()
restore := breakArtifactsDir(t, dir)
put := httptest.NewRequest(http.MethodPut, "/api/artifacts/"+artID+"/content", bytes.NewReader([]byte("half-written?")))
putRec := httptest.NewRecorder()
h.ServeHTTP(putRec, put)
if putRec.Code < 500 {
t.Fatalf("overwrite under broken object dir: status %d (want explicit 5xx)", putRec.Code)
}
restore()
// 原位文件占位已撤;拒绝期间不允许半写污染读取路径。
if got := getContent(); got != before {
t.Fatalf("rejected overwrite altered readable content: %q -> %q", before, got)
}
put2 := httptest.NewRequest(http.MethodPut, "/api/artifacts/"+artID+"/content", bytes.NewReader([]byte("recovered write")))
put2Rec := httptest.NewRecorder()
h.ServeHTTP(put2Rec, put2)
if put2Rec.Code != http.StatusOK {
t.Fatalf("overwrite after recovery: %d %.200s", put2Rec.Code, put2Rec.Body.String())
}
if got := getContent(); got != "recovered write" {
t.Fatalf("content after recovered overwrite: %q", got)
}
}