fix(handlers): Phase 1 stub — /agent-home root key + 501 dispatch (internal#425 RFC)
Some checks failed
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / all-required (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
Harness Replays / detect-changes (pull_request) Successful in 24s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 1m6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m0s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 56s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m39s
gate-check-v3 / gate-check (pull_request) Successful in 28s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 32s
qa-review / approved (pull_request) Successful in 33s
security-review / approved (pull_request) Successful in 29s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m8s
sop-tier-check / tier-check (pull_request) Successful in 27s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m43s
sop-checklist / all-items-acked (pull_request) Successful in 45s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m53s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m36s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m46s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m42s
CI / Platform (Go) (pull_request) Failing after 16m59s
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Successful in 20m1s
Some checks failed
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / all-required (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
Harness Replays / detect-changes (pull_request) Successful in 24s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 1m6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m0s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 56s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m39s
gate-check-v3 / gate-check (pull_request) Successful in 28s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 32s
qa-review / approved (pull_request) Successful in 33s
security-review / approved (pull_request) Successful in 29s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m8s
sop-tier-check / tier-check (pull_request) Successful in 27s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m43s
sop-checklist / all-items-acked (pull_request) Successful in 45s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m53s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m36s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m46s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m42s
CI / Platform (Go) (pull_request) Failing after 16m59s
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Successful in 20m1s
Phase 1 stub for the Files API /agent-home root (internal#425 RFC). Changes: - allowedRoots gains "/agent-home": true (templates.go) - isAgentHomeStubRequest helper short-circuits all four verbs (ListFiles/ReadFile/WriteFile/DeleteFile) with 501 + canonical JSON body BEFORE the DB workspace lookup — prevents "workspace not found" log noise from canvas probe requests - Error messages updated to list /agent-home as a known root key - New test file: template_files_agent_home_stub_test.go — 5 tests: body-is-canonical, ListFiles 501, ReadFile 501, WriteFile 501, DeleteFile 501, allowedRoots pinned. sqlmock used so DB is never consulted for stub requests (verifies pre-DB short-circuit). Phase 2b (docker-exec backend) implements the actual file operations. Closes #1247.
This commit is contained in:
parent
7533265b99
commit
d6fbc3726b
@ -0,0 +1,187 @@
|
||||
package handlers
|
||||
|
||||
// template_files_agent_home_stub_test.go — Phase 1 stub tests for issue #1247
|
||||
// (internal#425 RFC).
|
||||
//
|
||||
// Phase 1 stub contract:
|
||||
// • allowedRoots["/agent-home"] = true (templates.go)
|
||||
// • GET/POST/PUT/DELETE with ?root=/agent-home short-circuits with
|
||||
// 501 + the canonical JSON error body BEFORE the DB workspace lookup.
|
||||
// This prevents "workspace not found" log noise from probe requests.
|
||||
// • Error message for an unknown root now mentions /agent-home as a known key.
|
||||
//
|
||||
// Phase 2b (not here) implements the docker-exec backend to fulfil those requests.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// canonicalAgentHomeBody is the exact 501 response body the Phase 1 stub must emit.
|
||||
const canonicalAgentHomeBody = `{"error":"/agent-home not implemented yet (internal#425 RFC Phase 2b — docker-exec backend pending)"}`
|
||||
|
||||
// setupAgentHomeTest wires a minimal TemplatesHandler and sqlmock for
|
||||
// agent-home stub tests. The DB is never consulted — the 501 fires before
|
||||
// the workspace lookup — but the mock must exist so the router doesn't panic.
|
||||
func setupAgentHomeTest(t *testing.T) (*TemplatesHandler, sqlmock.Sqlmock, *httptest.ResponseRecorder, *gin.Context) {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
}
|
||||
db.DB = mockDB
|
||||
t.Cleanup(func() { db.DB = nil; mockDB.Close() })
|
||||
|
||||
handler := NewTemplatesHandler(t.TempDir(), nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
return handler, mock, w, c
|
||||
}
|
||||
|
||||
// stubNotImplementedBody pinned to the exact canonical string so the test
|
||||
// verifies byte-for-byte equivalence with the handler constant.
|
||||
func TestAgentHomeStub_NotImplementedBodyIsCanonical(t *testing.T) {
|
||||
if stubNotImplementedBody != canonicalAgentHomeBody {
|
||||
t.Fatalf("stubNotImplementedBody = %q; want %q", stubNotImplementedBody, canonicalAgentHomeBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentHomeStub_ListFiles_501 verifies that GET /workspaces/:id/files?root=/agent-home
|
||||
// returns 501 with the canonical body and does NOT query the database.
|
||||
func TestAgentHomeStub_ListFiles_501(t *testing.T) {
|
||||
h, mock, w, c := setupAgentHomeTest(t)
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/files?root=/agent-home", nil)
|
||||
c.Params = []gin.Param{{Key: "id", Value: "ws-123"}}
|
||||
// DB must not be called — 501 fires before workspace lookup.
|
||||
// We do NOT set up any mock rows; if the handler queries the DB the test
|
||||
// will fail with "there is a remaining expectations which was not matched".
|
||||
h.ListFiles(c)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Errorf("ListFiles?root=/agent-home: got status %d, want 501", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if body := w.Body.String(); body != canonicalAgentHomeBody {
|
||||
t.Errorf("body = %q; want %q", body, canonicalAgentHomeBody)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentHomeStub_ReadFile_501 verifies that GET /workspaces/:id/files/path?root=/agent-home
|
||||
// returns 501 and does NOT query the database.
|
||||
func TestAgentHomeStub_ReadFile_501(t *testing.T) {
|
||||
h, mock, w, c := setupAgentHomeTest(t)
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-123/files/config.yaml?root=/agent-home", nil)
|
||||
c.Params = []gin.Param{{Key: "id", Value: "ws-123"}}
|
||||
c.Params = append(c.Params, gin.Param{Key: "path", Value: "config.yaml"})
|
||||
h.ReadFile(c)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Errorf("ReadFile?root=/agent-home: got status %d, want 501", w.Code)
|
||||
}
|
||||
if body := w.Body.String(); body != canonicalAgentHomeBody {
|
||||
t.Errorf("body = %q; want %q", body, canonicalAgentHomeBody)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentHomeStub_WriteFile_501 verifies that PUT /workspaces/:id/files/path?root=/agent-home
|
||||
// returns 501 and does NOT query the database.
|
||||
func TestAgentHomeStub_WriteFile_501(t *testing.T) {
|
||||
h, mock, w, c := setupAgentHomeTest(t)
|
||||
c.Request = httptest.NewRequest("PUT", "/workspaces/ws-123/files/config.yaml?root=/agent-home", nil)
|
||||
c.Params = []gin.Param{{Key: "id", Value: "ws-123"}}
|
||||
c.Params = append(c.Params, gin.Param{Key: "path", Value: "config.yaml"})
|
||||
h.WriteFile(c)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Errorf("WriteFile?root=/agent-home: got status %d, want 501", w.Code)
|
||||
}
|
||||
if body := w.Body.String(); body != canonicalAgentHomeBody {
|
||||
t.Errorf("body = %q; want %q", body, canonicalAgentHomeBody)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentHomeStub_DeleteFile_501 verifies that DELETE /workspaces/:id/files/path?root=/agent-home
|
||||
// returns 501 and does NOT query the database.
|
||||
func TestAgentHomeStub_DeleteFile_501(t *testing.T) {
|
||||
h, mock, w, c := setupAgentHomeTest(t)
|
||||
c.Request = httptest.NewRequest("DELETE", "/workspaces/ws-123/files/config.yaml?root=/agent-home", nil)
|
||||
c.Params = []gin.Param{{Key: "id", Value: "ws-123"}}
|
||||
c.Params = append(c.Params, gin.Param{Key: "path", Value: "config.yaml"})
|
||||
h.DeleteFile(c)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Errorf("DeleteFile?root=/agent-home: got status %d, want 501", w.Code)
|
||||
}
|
||||
if body := w.Body.String(); body != canonicalAgentHomeBody {
|
||||
t.Errorf("body = %q; want %q", body, canonicalAgentHomeBody)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentHomeStub_AllowedRootsPinned verifies that /agent-home is in
|
||||
// allowedRoots so that the stub short-circuit is reachable.
|
||||
func TestAgentHomeStub_AllowedRootsPinned(t *testing.T) {
|
||||
if !allowedRoots["/agent-home"] {
|
||||
t.Error("/agent-home must be in allowedRoots")
|
||||
}
|
||||
// Sanity-check the other roots are still present (regression guard).
|
||||
for _, root := range []string{"/configs", "/workspace", "/home", "/plugins"} {
|
||||
if !allowedRoots[root] {
|
||||
t.Errorf("%s must still be in allowedRoots", root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentHomeStub_ListFiles_ExistingRootsUnaffected verifies that the four
|
||||
// original roots still route through to the DB path (we don't wire sqlmock
|
||||
// expectations here — we just confirm they return 400, not 501, for an
|
||||
// invalid workspace ID).
|
||||
func TestAgentHomeStub_OtherRoots_Return400OnBadWorkspace(t *testing.T) {
|
||||
h, mock, w, c := setupAgentHomeTest(t)
|
||||
|
||||
for _, root := range []string{"/configs", "/workspace", "/home", "/plugins"} {
|
||||
// The DB returns ErrNoRows → 404, not 400 or 501.
|
||||
mock.ExpectQuery(`SELECT name, COALESCE`).WithArgs("bad-ws").WillReturnError(sql.ErrNoRows)
|
||||
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/bad-ws/files?root="+root, nil)
|
||||
c.Params = []gin.Param{{Key: "id", Value: "bad-ws"}}
|
||||
// Reset the response recorder between calls.
|
||||
w = httptest.NewRecorder()
|
||||
c.Reset()
|
||||
|
||||
// We can't use c.Reset() cleanly across different request/response pairs
|
||||
// in a loop without re-creating the context. Just run one representative case.
|
||||
if root == "/configs" {
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/bad-ws/files?root="+root, nil)
|
||||
c.Params = []gin.Param{{Key: "id", Value: "bad-ws"}}
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/bad-ws/files?root="+root, nil)
|
||||
c.Params = []gin.Param{{Key: "id", Value: "bad-ws"}}
|
||||
h.ListFiles(c)
|
||||
// 404 → workspace not found (DB returned ErrNoRows), not 501.
|
||||
if w.Code == http.StatusNotImplemented {
|
||||
t.Error("/configs must NOT return 501 — only /agent-home short-circuits")
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = h // suppress unused
|
||||
}
|
||||
@ -19,12 +19,22 @@ import (
|
||||
|
||||
// allowedRoots are the container paths that the Files API can browse.
|
||||
var allowedRoots = map[string]bool{
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
"/agent-home": true, // Phase 1 stub — docker-exec backend in Phase 2b (internal#425 RFC)
|
||||
}
|
||||
|
||||
// isAgentHomeStubRequest returns true when rootPath targets the /agent-home
|
||||
// root. Phase 1 returns 501; Phase 2b implements the docker-exec backend.
|
||||
func isAgentHomeStubRequest(rootPath string) bool {
|
||||
return rootPath == "/agent-home"
|
||||
}
|
||||
|
||||
// stubNotImplementedBody is the canonical 501 response body for Phase 1 stub requests.
|
||||
const stubNotImplementedBody = `{"error":"/agent-home not implemented yet (internal#425 RFC Phase 2b — docker-exec backend pending)"}`
|
||||
|
||||
// maxUploadFiles limits the number of files in a single import/replace.
|
||||
const maxUploadFiles = 200
|
||||
|
||||
@ -219,7 +229,15 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
// ?depth= — max depth to recurse (default: 1, max: 5)
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
|
||||
return
|
||||
}
|
||||
// Phase 1 stub — fires BEFORE the DB lookup so probe requests don't
|
||||
// generate "workspace not found" log noise. Phase 2b implements the
|
||||
// docker-exec backend (internal#425 RFC).
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.String(http.StatusNotImplemented, stubNotImplementedBody)
|
||||
return
|
||||
}
|
||||
subPath := c.DefaultQuery("path", "")
|
||||
@ -383,7 +401,13 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
|
||||
return
|
||||
}
|
||||
// Phase 1 stub — fires BEFORE the DB lookup (internal#425 RFC Phase 2b).
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.String(http.StatusNotImplemented, stubNotImplementedBody)
|
||||
return
|
||||
}
|
||||
|
||||
@ -496,7 +520,13 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
|
||||
return
|
||||
}
|
||||
// Phase 1 stub — fires BEFORE the DB lookup (internal#425 RFC Phase 2b).
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.String(http.StatusNotImplemented, stubNotImplementedBody)
|
||||
return
|
||||
}
|
||||
var wsName, instanceID, runtime string
|
||||
@ -573,7 +603,13 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins, /agent-home"})
|
||||
return
|
||||
}
|
||||
// Phase 1 stub — fires BEFORE the DB lookup (internal#425 RFC Phase 2b).
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.String(http.StatusNotImplemented, stubNotImplementedBody)
|
||||
return
|
||||
}
|
||||
var wsName, instanceID, runtime string
|
||||
|
||||
Loading…
Reference in New Issue
Block a user