mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 12:22:44 +00:00
Rewrite inline comments added during the agent identity hardening so they explain the code as it stands today, rather than tying to internal plan terminology (phase numbers, FR/NFR/H/M/C codes, PR references, trap zone labels). Commit history already carries the plan archaeology. Comments now keep the non-obvious invariants (cache boundaries, bypass gaps, silent-nil traps, dual-tenant semantics) and drop the scaffolding. Comment-only — no runtime behavior change.
140 lines
4.9 KiB
Go
140 lines
4.9 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// buildUploadRequest constructs a multipart POST request with optional form fields
|
|
// and one minimal file part. It does NOT attempt to reach storage — the handler's
|
|
// boundary UUID validation runs before any store or workspace access.
|
|
func buildUploadRequest(t *testing.T, fields map[string]string) *http.Request {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
for k, v := range fields {
|
|
if err := writer.WriteField(k, v); err != nil {
|
|
t.Fatalf("writer.WriteField(%q): %v", k, err)
|
|
}
|
|
}
|
|
// Minimal file part so ParseMultipartForm sees at least one file.
|
|
part, err := writer.CreateFormFile("files", "note.md")
|
|
if err != nil {
|
|
t.Fatalf("CreateFormFile: %v", err)
|
|
}
|
|
if _, err := part.Write([]byte("hello")); err != nil {
|
|
t.Fatalf("part.Write: %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("writer.Close: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/vault/upload", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
return req
|
|
}
|
|
|
|
// assertBadRequest decodes a JSON error response and checks the HTTP status
|
|
// is 400 and the error message contains the expected substring.
|
|
func assertBadRequest(t *testing.T, rr *httptest.ResponseRecorder, wantFragment string) {
|
|
t.Helper()
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusBadRequest, rr.Body.String())
|
|
}
|
|
var payload map[string]string
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode json: %v (body: %s)", err, rr.Body.String())
|
|
}
|
|
msg, ok := payload["error"]
|
|
if !ok {
|
|
t.Fatalf("response missing `error` field: %s", rr.Body.String())
|
|
}
|
|
if !strings.Contains(msg, wantFragment) {
|
|
t.Errorf("error = %q, want fragment %q", msg, wantFragment)
|
|
}
|
|
}
|
|
|
|
// TestHandleUpload_InvalidAgentIDReturns400 asserts bad form `agent_id` is
|
|
// rejected at the HTTP boundary before any store call. Closes the
|
|
// owner / lite edition gap where validateTeamMembership would skip the UUID
|
|
// check.
|
|
func TestHandleUpload_InvalidAgentIDReturns400(t *testing.T) {
|
|
h := &VaultHandler{} // no store wired — boundary check runs first
|
|
req := buildUploadRequest(t, map[string]string{
|
|
"agent_id": "goctech-leader", // agent_key, not UUID
|
|
})
|
|
rr := httptest.NewRecorder()
|
|
|
|
h.handleUpload(rr, req)
|
|
|
|
assertBadRequest(t, rr, "invalid agent_id")
|
|
}
|
|
|
|
// TestHandleUpload_InvalidTeamIDReturns400 asserts bad form `team_id` is
|
|
// rejected at the HTTP boundary. validateTeamMembership short-circuits on
|
|
// owner role and on nil teamAccess (lite edition), never parsing the UUID —
|
|
// the boundary check closes that hole.
|
|
func TestHandleUpload_InvalidTeamIDReturns400(t *testing.T) {
|
|
h := &VaultHandler{} // lite edition: teamAccess is nil
|
|
req := buildUploadRequest(t, map[string]string{
|
|
"team_id": "my-team-key", // team_key, not UUID
|
|
})
|
|
rr := httptest.NewRecorder()
|
|
|
|
h.handleUpload(rr, req)
|
|
|
|
assertBadRequest(t, rr, "invalid team_id")
|
|
}
|
|
|
|
// TestHandleUpload_InvalidAgentID_OwnerContext asserts the boundary check
|
|
// fires regardless of role. The UUID hole existed for every caller — the
|
|
// boundary check ignores role entirely.
|
|
func TestHandleUpload_InvalidAgentID_OwnerContext(t *testing.T) {
|
|
h := &VaultHandler{}
|
|
req := buildUploadRequest(t, map[string]string{
|
|
"agent_id": "definitely-not-a-uuid",
|
|
})
|
|
// Simulate owner role context — the boundary check ignores role entirely.
|
|
ctx := context.WithValue(req.Context(), ownerContextKey{}, true)
|
|
req = req.WithContext(ctx)
|
|
rr := httptest.NewRecorder()
|
|
|
|
h.handleUpload(rr, req)
|
|
|
|
assertBadRequest(t, rr, "invalid agent_id")
|
|
}
|
|
|
|
// TestHandleUpload_EmptyAgentIDAndTeamID_NoBoundaryError asserts the boundary
|
|
// check skips empty strings (shared-scope uploads). It should proceed to the
|
|
// next validation layer (workspace resolution) — which will fail with a
|
|
// different error since our test VaultHandler has no workspace. The important
|
|
// thing is that the boundary check itself does NOT return 400 with "invalid
|
|
// agent_id" or "invalid team_id" for empty values.
|
|
func TestHandleUpload_EmptyAgentIDAndTeamID_BoundaryCheckSkipped(t *testing.T) {
|
|
h := &VaultHandler{}
|
|
req := buildUploadRequest(t, map[string]string{})
|
|
rr := httptest.NewRecorder()
|
|
|
|
h.handleUpload(rr, req)
|
|
|
|
// Expect a failure AFTER boundary validation (e.g. workspace missing),
|
|
// not the boundary UUID validation errors.
|
|
if rr.Code == http.StatusBadRequest {
|
|
body := rr.Body.String()
|
|
if strings.Contains(body, "invalid agent_id") || strings.Contains(body, "invalid team_id") {
|
|
t.Errorf("boundary check fired on empty input (should skip): body=%s", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ownerContextKey is a private test helper for simulating owner role in
|
|
// context. The real context key type lives in internal/store; we only need
|
|
// to ensure the boundary check fires independent of role-related context.
|
|
type ownerContextKey struct{}
|