164 lines
5.4 KiB
Go
164 lines
5.4 KiB
Go
package logbundle
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/kandev/kandev/internal/auth/authn"
|
|
)
|
|
|
|
func TestBundleRoutesEnforceIdentityOwnership(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
service := newTestService(t, Config{HomeDir: t.TempDir(), CaptureWindow: time.Hour})
|
|
router := gin.New()
|
|
group := router.Group("/api/v1/system")
|
|
group.Use(func(c *gin.Context) {
|
|
userID := c.GetHeader("X-Test-User")
|
|
if userID != "" {
|
|
authn.SetOnGin(c, authn.Identity{UserID: userID, Role: authn.RoleMember})
|
|
}
|
|
c.Next()
|
|
})
|
|
RegisterRoutes(group, service)
|
|
|
|
create := requestBundle(t, router, http.MethodPost, "/api/v1/system/logs/bundles",
|
|
"user-1", `{"sources":["frontend"]}`)
|
|
if create.Code != http.StatusAccepted {
|
|
t.Fatalf("create status = %d body=%s", create.Code, create.Body.String())
|
|
}
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
foreign := requestBundle(t, router, http.MethodGet,
|
|
"/api/v1/system/logs/bundles/"+created.ID, "user-2", "")
|
|
if foreign.Code != http.StatusNotFound {
|
|
t.Fatalf("foreign status = %d body=%s", foreign.Code, foreign.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBundleCapabilityAndACPSessionRoutesUseBackendDebugState(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
provider := &diagnosticSessionProviderStub{sessions: []DiagnosticSession{
|
|
{
|
|
TaskID: "task-1", TaskTitle: "Repair diagnostic export", SessionID: "session-1",
|
|
Agent: "claude-acp", Status: "running",
|
|
},
|
|
}}
|
|
service := newTestService(t, Config{
|
|
HomeDir: t.TempDir(),
|
|
ACPDebugEnabled: func() bool { return true },
|
|
SessionProvider: provider,
|
|
})
|
|
router := gin.New()
|
|
router.Use(func(c *gin.Context) {
|
|
authn.SetOnGin(c, authn.Identity{UserID: "user-1", Role: authn.RoleMember})
|
|
c.Next()
|
|
})
|
|
group := router.Group("/api/v1/system")
|
|
RegisterRoutes(group, service)
|
|
|
|
capabilities := requestBundle(t, router, http.MethodGet,
|
|
"/api/v1/system/logs/capabilities", "", "")
|
|
if capabilities.Code != http.StatusOK {
|
|
t.Fatalf("capabilities status = %d body=%s", capabilities.Code, capabilities.Body.String())
|
|
}
|
|
if !strings.Contains(capabilities.Body.String(), `"acp_debug_enabled":true`) {
|
|
t.Fatalf("capabilities = %s", capabilities.Body.String())
|
|
}
|
|
|
|
sessions := requestBundle(t, router, http.MethodGet,
|
|
"/api/v1/system/logs/acp-sessions", "", "")
|
|
if sessions.Code != http.StatusOK || !strings.Contains(sessions.Body.String(), "session-1") {
|
|
t.Fatalf("sessions status = %d body=%s", sessions.Code, sessions.Body.String())
|
|
}
|
|
if !strings.Contains(sessions.Body.String(), `"task_title":"Repair diagnostic export"`) {
|
|
t.Fatalf("ACP picker response omits task title: %s", sessions.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDiagnosticSessionTaskTitleStaysOutOfArchiveJSON(t *testing.T) {
|
|
field, ok := reflect.TypeOf(DiagnosticSession{}).FieldByName("TaskTitle")
|
|
if !ok {
|
|
t.Fatal("DiagnosticSession must retain the picker-only TaskTitle field")
|
|
}
|
|
if actual := field.Tag.Get("json"); actual != "-" {
|
|
t.Fatalf("TaskTitle JSON tag = %q, want -", actual)
|
|
}
|
|
}
|
|
|
|
func TestLosingFrontendStreamIsAcknowledgedBeforeEntriesDecode(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
service := newTestService(t, Config{HomeDir: t.TempDir(), CaptureWindow: time.Hour})
|
|
job, _, err := service.Create("user-1", []string{"frontend"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := service.ClaimStream("user-1", job.ID, "browser-a", "winner", "memory"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router := gin.New()
|
|
group := router.Group("/api/v1/system")
|
|
group.Use(func(c *gin.Context) {
|
|
authn.SetOnGin(c, authn.Identity{UserID: "user-1", Role: authn.RoleMember})
|
|
c.Next()
|
|
})
|
|
RegisterRoutes(group, service)
|
|
|
|
body := `{"browser_id":"browser-a","capture_stream_id":"loser","chunk_index":99,` +
|
|
`"done":false,"storage_mode":"memory","capture_metadata":null,"entries":{"not":"an array"}}`
|
|
response := requestBundle(t, router, http.MethodPost,
|
|
"/api/v1/system/logs/bundles/"+job.ID+"/frontend", "", body)
|
|
if response.Code != http.StatusNoContent {
|
|
t.Fatalf("losing stream status = %d body=%s", response.Code, response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFrontendUploadBodyIsBounded(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
service := newTestService(t, Config{HomeDir: t.TempDir(), CaptureWindow: time.Hour})
|
|
job, _, err := service.Create("user-1", []string{"frontend"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router := gin.New()
|
|
group := router.Group("/api/v1/system")
|
|
group.Use(func(c *gin.Context) {
|
|
authn.SetOnGin(c, authn.Identity{UserID: "user-1"})
|
|
c.Next()
|
|
})
|
|
RegisterRoutes(group, service)
|
|
|
|
body := `{"browser_id":"browser-a","capture_stream_id":"stream-a","chunk_index":0,` +
|
|
`"done":false,"entries":["` + strings.Repeat("x", 1024*1024) + `"]}`
|
|
response := requestBundle(t, router, http.MethodPost,
|
|
"/api/v1/system/logs/bundles/"+job.ID+"/frontend", "", body)
|
|
if response.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("oversize status = %d body=%s", response.Code, response.Body.String())
|
|
}
|
|
}
|
|
|
|
func requestBundle(
|
|
t *testing.T, router http.Handler, method, path, userID, body string,
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
request := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
if userID != "" {
|
|
request.Header.Set("X-Test-User", userID)
|
|
}
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
return response
|
|
}
|