Files
xinmi/internal/router/router_files_test.go
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

815 lines
27 KiB
Go

package router
import (
"context"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
var _ interfaces.FileService = (*stubFileService)(nil)
type stubFileService struct {
getFile func(ctx context.Context, filePath string) (io.ReadCloser, error)
}
type stubResourceCatalog struct {
resource *types.StoredResource
}
type authorizingFileCatalog struct {
stubResourceCatalog
authorize func(context.Context, string, interfaces.ResourceFileScope) error
}
func (s *authorizingFileCatalog) AuthorizeFileRead(ctx context.Context, value string, scope interfaces.ResourceFileScope) error {
return s.authorize(ctx, value, scope)
}
func TestFileProxyAuthorizesBeforeOpeningStorage(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
opened := false
catalog := &authorizingFileCatalog{
stubResourceCatalog: stubResourceCatalog{resource: &types.StoredResource{TenantID: 42, PhysicalPath: "local://42/exports/a.png"}},
authorize: func(ctx context.Context, value string, scope interfaces.ResourceFileScope) error {
if scope.TenantID != 42 || value != ref {
t.Fatal("wrong authorization scope")
}
return errors.New("private file owned by another user")
},
}
engine := gin.New()
serveFilesWithResources(engine, &stubFileService{getFile: func(context.Context, string) (io.ReadCloser, error) {
opened = true
return io.NopCloser(strings.NewReader("private")), nil
}}, nil, catalog)
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(ref), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden || opened {
t.Fatalf("status=%d storage_opened=%v", recorder.Code, opened)
}
catAllowed := false
catalog.authorize = func(context.Context, string, interfaces.ResourceFileScope) error { catAllowed = true; return nil }
recorder = httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK || !catAllowed || recorder.Header().Get("Cache-Control") != "private, no-store" {
t.Fatalf("authorized status=%d cache=%s", recorder.Code, recorder.Header().Get("Cache-Control"))
}
}
type stubMessageFileLookup struct {
get func(ctx context.Context, sessionID, messageID string) (*types.Message, error)
}
func (s *stubMessageFileLookup) GetMessage(
ctx context.Context,
sessionID, messageID string,
) (*types.Message, error) {
return s.get(ctx, sessionID, messageID)
}
type stubSharedAgentFileLookup struct {
get func(
ctx context.Context,
tenantID uint64,
callerTenantRole types.TenantRole,
agentID string,
sourceTenantID ...uint64,
) (*types.CustomAgent, error)
}
func (s *stubSharedAgentFileLookup) GetSharedAgentForTenant(
ctx context.Context,
tenantID uint64,
callerTenantRole types.TenantRole,
agentID string,
sourceTenantID ...uint64,
) (*types.CustomAgent, error) {
return s.get(ctx, tenantID, callerTenantRole, agentID, sourceTenantID...)
}
func (s *stubResourceCatalog) Register(
context.Context,
uint64,
string,
interfaces.ResourceRegistration,
) (string, error) {
panic("unexpected Register")
}
func (s *stubResourceCatalog) Resolve(context.Context, string) (*types.StoredResource, error) {
return s.resource, nil
}
func (s *stubResourceCatalog) ResolvePath(_ context.Context, value string) (string, *types.StoredResource, error) {
if _, ok := types.ParseResourcePath(value); ok && s.resource != nil {
return s.resource.PhysicalPath, s.resource, nil
}
return value, nil, nil
}
func (s *stubResourceCatalog) Bind(context.Context, string, string, string, string) error {
panic("unexpected Bind")
}
func (s *stubResourceCatalog) Release(context.Context, string, string, string) (int64, error) {
panic("unexpected Release")
}
func (s *stubResourceCatalog) MarkDeleted(context.Context, string) error {
panic("unexpected MarkDeleted")
}
func (s *stubResourceCatalog) CreateAccessGrant(context.Context, string, time.Duration) (string, error) {
panic("unexpected CreateAccessGrant")
}
func (s *stubResourceCatalog) ResolveAccessGrant(context.Context, string) (*types.StoredResource, error) {
return s.resource, nil
}
func (s *stubFileService) CheckConnectivity(ctx context.Context) error {
return nil
}
func (s *stubFileService) SaveFile(ctx context.Context, file *multipart.FileHeader, tenantID uint64, knowledgeID string) (string, error) {
panic("unexpected call to SaveFile")
}
func (s *stubFileService) SaveBytes(ctx context.Context, data []byte, tenantID uint64, fileName string, temp bool) (string, error) {
panic("unexpected call to SaveBytes")
}
func (s *stubFileService) GetFile(ctx context.Context, filePath string) (io.ReadCloser, error) {
if s.getFile == nil {
panic("unexpected call to GetFile")
}
return s.getFile(ctx, filePath)
}
func (s *stubFileService) GetFileURL(ctx context.Context, filePath string) (string, error) {
panic("unexpected call to GetFileURL")
}
func (s *stubFileService) DeleteFile(ctx context.Context, filePath string) error {
panic("unexpected call to DeleteFile")
}
func (s *stubFileService) CopyFile(ctx context.Context, srcPath string, tenantID uint64, knowledgeID string) (string, error) {
panic("unexpected call to CopyFile")
}
func TestServeFilesFallsBackToGlobalFileService(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
engine := gin.New()
var requestedPath string
serveFiles(engine, &stubFileService{
getFile: func(ctx context.Context, filePath string) (io.ReadCloser, error) {
requestedPath = filePath
return io.NopCloser(strings.NewReader("fallback-body")), nil
},
})
filePath := "local://42/docs/example.txt"
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(filePath), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if requestedPath != filePath {
t.Fatalf("requested path = %q, want %q", requestedPath, filePath)
}
if body := recorder.Body.String(); body != "fallback-body" {
t.Fatalf("body = %q, want %q", body, "fallback-body")
}
}
func TestServeFilesResolvesShortResourceReference(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
const physical = "local://42/exports/a.png"
engine := gin.New()
var requestedPath string
serveFilesWithResources(engine, &stubFileService{getFile: func(_ context.Context, path string) (io.ReadCloser, error) {
requestedPath = path
return io.NopCloser(strings.NewReader("image")), nil
}}, nil, &stubResourceCatalog{resource: &types.StoredResource{TenantID: 42, PhysicalPath: physical}})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(ref), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
}
if requestedPath != physical {
t.Fatalf("requested path = %q, want %q", requestedPath, physical)
}
}
func TestServeFilesRejectsCrossTenantResourceReference(t *testing.T) {
gin.SetMode(gin.TestMode)
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
engine := gin.New()
serveFilesWithResources(engine, &stubFileService{getFile: func(context.Context, string) (io.ReadCloser, error) {
t.Fatal("GetFile should not be called")
return nil, nil
}}, nil, &stubResourceCatalog{resource: &types.StoredResource{TenantID: 7, PhysicalPath: "local://7/exports/a.png"}})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(ref), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
}
}
func TestResourceGrantServesShortPublicURL(t *testing.T) {
gin.SetMode(gin.TestMode)
physical := "local://42/exports/a.png"
engine := gin.New()
serveResourceGrants(
engine,
&stubResourceCatalog{resource: &types.StoredResource{
ID: "resource-1",
TenantID: 42,
PhysicalPath: physical,
OriginalName: "a.png",
MimeType: "image/png",
}},
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
return &types.Tenant{ID: id}, nil
}},
&stubFileService{getFile: func(_ context.Context, path string) (io.ReadCloser, error) {
if path != physical {
t.Fatalf("path = %q, want %q", path, physical)
}
return io.NopCloser(strings.NewReader("image")), nil
}},
nil,
)
req := httptest.NewRequest(http.MethodGet, "/r/GrantTokenAbCdEfGhIjKlM", nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK || recorder.Body.String() != "image" {
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
}
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q", got)
}
}
func TestServeFilesDoesNotFallbackWhenProviderDoesNotMatchGlobalStorage(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "minio")
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(ctx context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called for mismatched provider, got %q", filePath)
return nil, nil
},
})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape("local://42/docs/example.txt"), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestServeFilesRejectsCrossTenantPath(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(ctx context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called for cross-tenant path, got %q", filePath)
return nil, nil
},
})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape("local://7/knowledge/secret.pdf"), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusForbidden; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestServeFilesRejectsPathWithoutTenantSegment(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(ctx context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called without tenant segment, got %q", filePath)
return nil, nil
},
})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape("local://docs/example.txt"), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusForbidden; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
// /files carries its own API-key guard (middleware.AllowFileServeAPIKey):
// full-access and tenant-wide retrieve keys may serve tenant-bounded paths,
// but KB-restricted keys (and keys lacking retrieve) are denied because a raw
// storage path cannot be bounded to a KB allow-list.
func TestServeFilesAPIKeyScopeMatrix(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const filePath = "local://42/docs/example.txt"
cases := []struct {
name string
scope types.TenantAPIKeyScope
wantCode int
}{
{
name: "full access allowed",
scope: types.TenantAPIKeyScope{FullAccess: true},
wantCode: http.StatusOK,
},
{
name: "tenant-wide retrieve allowed",
scope: types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityRetrieve)},
},
wantCode: http.StatusOK,
},
{
name: "kb-restricted retrieve denied",
scope: types.TenantAPIKeyScope{
KnowledgeBaseIDs: types.StringArray{"kb-1"},
Capabilities: types.StringArray{string(types.APIKeyCapabilityRetrieve)},
},
wantCode: http.StatusForbidden,
},
{
name: "non-retrieve capability denied",
scope: types.TenantAPIKeyScope{
Capabilities: types.StringArray{string(types.APIKeyCapabilityChat)},
},
wantCode: http.StatusForbidden,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(_ context.Context, _ string) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("body")), nil
},
})
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(filePath), nil)
ctx := context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42})
ctx = types.WithTenantAPIKeyScope(ctx, tc.scope)
req = req.WithContext(ctx)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got := recorder.Code; got != tc.wantCode {
t.Fatalf("status = %d, want %d body=%s", got, tc.wantCode, recorder.Body.String())
}
})
}
}
// newKBScopedFilesTestEngine wires newKBScopedFileServeHandler behind a
// middleware that injects effectiveTenantID into the request context, mirroring
// what RequireKBAccess does after resolving an org-shared KB to its source
// tenant. This lets the handler be exercised without the full RBAC stack.
func newKBScopedFilesTestEngine(
effectiveTenantID uint64,
tenantSvc interfaces.TenantService,
global interfaces.FileService,
) *gin.Engine {
engine := gin.New()
engine.GET("/knowledge-bases/:id/files",
func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, effectiveTenantID)
c.Request = c.Request.WithContext(ctx)
c.Next()
},
newKBScopedFileServeHandler(tenantSvc, global),
)
return engine
}
// A tenant whose owner-tenant (10008) storage objects are requested by a
// borrowing tenant via a shared KB: the effective tenant in context is the
// owner, so the path validates and the file is served.
func TestKBScopedFilesServesOwnerTenantPath(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ownerTenantID = uint64(10008)
var requestedPath string
engine := newKBScopedFilesTestEngine(
ownerTenantID,
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
return &types.Tenant{ID: id}, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
requestedPath = filePath
return io.NopCloser(strings.NewReader("shared-body")), nil
}},
)
filePath := "local://10008/exports/img.jpg"
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/kb-1/files?file_path="+url.QueryEscape(filePath), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d body=%s", got, want, recorder.Body.String())
}
if requestedPath != filePath {
t.Fatalf("requested path = %q, want %q", requestedPath, filePath)
}
if body := recorder.Body.String(); body != "shared-body" {
t.Fatalf("body = %q, want %q", body, "shared-body")
}
}
// The path must belong to the effective (owner) tenant. A path pointing at a
// different tenant than the resolved KB owner is still rejected, so the guard
// cannot be used to reach arbitrary tenants' files.
func TestKBScopedFilesRejectsPathNotOwnedByKBTenant(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ownerTenantID = uint64(10008)
engine := newKBScopedFilesTestEngine(
ownerTenantID,
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
t.Fatalf("GetTenantByID should not be called for mismatched path, got %d", id)
return nil, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called for mismatched path, got %q", filePath)
return nil, nil
}},
)
req := httptest.NewRequest(http.MethodGet,
"/knowledge-bases/kb-1/files?file_path="+url.QueryEscape("local://9999/exports/other.jpg"), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusForbidden; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
// KB-scoped proxy is for embedded exports/ images only; raw knowledge uploads
// must use /knowledge/:id/download even when the tenant matches.
func TestKBScopedFilesRejectsNonExportsPath(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const ownerTenantID = uint64(10008)
engine := newKBScopedFilesTestEngine(
ownerTenantID,
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
t.Fatalf("GetTenantByID should not be called for non-exports path, got %d", id)
return nil, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
t.Fatalf("GetFile should not be called for non-exports path, got %q", filePath)
return nil, nil
}},
)
req := httptest.NewRequest(http.MethodGet,
"/knowledge-bases/kb-1/files?file_path="+url.QueryEscape("local://10008/knowledge-id/123.pdf"), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusForbidden; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestKBScopedFilesRequiresFilePath(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := newKBScopedFilesTestEngine(
10008,
&stubTenantService{},
&stubFileService{},
)
req := httptest.NewRequest(http.MethodGet, "/knowledge-bases/kb-1/files", nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func newMessageScopedFilesTestEngine(
callerTenantID uint64,
messageService messageFileLookup,
agentShareService sharedAgentFileLookup,
tenantService interfaces.TenantService,
global interfaces.FileService,
resourceCatalog interfaces.ResourceCatalog,
) *gin.Engine {
engine := gin.New()
engine.GET("/sessions/:id/messages/:message_id/files",
func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, callerTenantID)
c.Request = c.Request.WithContext(ctx)
c.Next()
},
newMessageScopedFileServeHandler(
messageService,
agentShareService,
tenantService,
global,
nil,
resourceCatalog,
),
)
return engine
}
func TestMessageScopedFilesServesSharedAgentResource(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const (
callerTenantID = uint64(42)
ownerTenantID = uint64(7)
ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
physical = "local://7/exports/chart.png"
)
var requestedPath string
engine := newMessageScopedFilesTestEngine(
callerTenantID,
&stubMessageFileLookup{get: func(_ context.Context, sessionID, messageID string) (*types.Message, error) {
if sessionID != "session-1" || messageID != "message-1" {
t.Fatalf("unexpected message scope %s/%s", sessionID, messageID)
}
return &types.Message{AgentID: "agent-1", AgentTenantID: ownerTenantID}, nil
}},
&stubSharedAgentFileLookup{get: func(
_ context.Context,
tenantID uint64,
_ types.TenantRole,
agentID string,
sourceTenantID ...uint64,
) (*types.CustomAgent, error) {
if tenantID != callerTenantID || agentID != "agent-1" || len(sourceTenantID) != 1 || sourceTenantID[0] != ownerTenantID {
t.Fatalf("unexpected shared-agent lookup tenant=%d agent=%s source=%v", tenantID, agentID, sourceTenantID)
}
return &types.CustomAgent{ID: agentID, TenantID: ownerTenantID}, nil
}},
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
return &types.Tenant{ID: id}, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
requestedPath = filePath
return io.NopCloser(strings.NewReader("shared-agent-image")), nil
}},
&stubResourceCatalog{resource: &types.StoredResource{
TenantID: ownerTenantID,
PhysicalPath: physical,
MimeType: "image/png",
}},
)
req := httptest.NewRequest(http.MethodGet,
"/sessions/session-1/messages/message-1/files?file_path="+url.QueryEscape(ref), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK || recorder.Body.String() != "shared-agent-image" {
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
}
if requestedPath != physical {
t.Fatalf("requested path = %q, want %q", requestedPath, physical)
}
}
func TestMessageScopedFilesServesSameTenantResource(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
const (
tenantID = uint64(42)
ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
physical = "local://42/exports/chart.png"
)
var requestedPath string
engine := newMessageScopedFilesTestEngine(
tenantID,
&stubMessageFileLookup{get: func(_ context.Context, sessionID, messageID string) (*types.Message, error) {
if sessionID != "session-1" || messageID != "message-1" {
t.Fatalf("unexpected message scope %s/%s", sessionID, messageID)
}
return &types.Message{AgentTenantID: tenantID}, nil
}},
&stubSharedAgentFileLookup{get: func(
context.Context, uint64, types.TenantRole, string, ...uint64,
) (*types.CustomAgent, error) {
t.Fatal("shared-agent lookup should not run for same-tenant resources")
return nil, nil
}},
&stubTenantService{get: func(_ context.Context, id uint64) (*types.Tenant, error) {
return &types.Tenant{ID: id}, nil
}},
&stubFileService{getFile: func(_ context.Context, filePath string) (io.ReadCloser, error) {
requestedPath = filePath
return io.NopCloser(strings.NewReader("same-tenant-image")), nil
}},
&stubResourceCatalog{resource: &types.StoredResource{
TenantID: tenantID,
PhysicalPath: physical,
MimeType: "image/png",
}},
)
req := httptest.NewRequest(http.MethodGet,
"/sessions/session-1/messages/message-1/files?file_path="+url.QueryEscape(ref), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK || recorder.Body.String() != "same-tenant-image" {
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
}
if requestedPath != physical {
t.Fatalf("requested path = %q, want %q", requestedPath, physical)
}
}
func TestMessageScopedFilesRequiresFilePath(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := newMessageScopedFilesTestEngine(
42,
&stubMessageFileLookup{get: func(context.Context, string, string) (*types.Message, error) {
return &types.Message{AgentTenantID: 42}, nil
}},
&stubSharedAgentFileLookup{},
&stubTenantService{},
&stubFileService{},
&stubResourceCatalog{},
)
req := httptest.NewRequest(http.MethodGet, "/sessions/session-1/messages/message-1/files", nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestMessageScopedFilesRejectsRevokedSharedAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
engine := newMessageScopedFilesTestEngine(
42,
&stubMessageFileLookup{get: func(context.Context, string, string) (*types.Message, error) {
return &types.Message{AgentID: "agent-1", AgentTenantID: 7}, nil
}},
&stubSharedAgentFileLookup{get: func(
context.Context, uint64, types.TenantRole, string, ...uint64,
) (*types.CustomAgent, error) {
return nil, nil
}},
&stubTenantService{get: func(context.Context, uint64) (*types.Tenant, error) {
t.Fatal("tenant lookup should not run after share revocation")
return nil, nil
}},
&stubFileService{getFile: func(context.Context, string) (io.ReadCloser, error) {
t.Fatal("GetFile should not run after share revocation")
return nil, nil
}},
&stubResourceCatalog{resource: &types.StoredResource{TenantID: 7, PhysicalPath: "local://7/exports/chart.png"}},
)
req := httptest.NewRequest(http.MethodGet,
"/sessions/session-1/messages/message-1/files?file_path="+url.QueryEscape(ref), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d, want %d", recorder.Code, http.StatusForbidden)
}
}
func TestMessageScopedFilesRejectsResourceOutsideMessageTenant(t *testing.T) {
gin.SetMode(gin.TestMode)
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
engine := newMessageScopedFilesTestEngine(
42,
&stubMessageFileLookup{get: func(context.Context, string, string) (*types.Message, error) {
return &types.Message{AgentID: "agent-1", AgentTenantID: 8}, nil
}},
&stubSharedAgentFileLookup{get: func(
context.Context, uint64, types.TenantRole, string, ...uint64,
) (*types.CustomAgent, error) {
t.Fatal("shared-agent lookup should not run for a mismatched resource tenant")
return nil, nil
}},
&stubTenantService{},
&stubFileService{},
&stubResourceCatalog{resource: &types.StoredResource{TenantID: 7, PhysicalPath: "local://7/exports/chart.png"}},
)
req := httptest.NewRequest(http.MethodGet,
"/sessions/session-1/messages/message-1/files?file_path="+url.QueryEscape(ref), nil)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status=%d, want %d", recorder.Code, http.StatusForbidden)
}
}
func TestServeFilesForcesActiveContentDownload(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("STORAGE_TYPE", "local")
engine := gin.New()
serveFiles(engine, &stubFileService{
getFile: func(_ context.Context, _ string) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader(`<svg onload="alert(1)"></svg>`)), nil
},
})
filePath := "local://42/docs/payload.svg"
req := httptest.NewRequest(http.MethodGet, "/files?file_path="+url.QueryEscape(filePath), nil)
req = req.WithContext(context.WithValue(req.Context(), types.TenantInfoContextKey, &types.Tenant{ID: 42}))
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if got, want := recorder.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
if got := recorder.Header().Get("Content-Type"); got != "application/octet-stream" {
t.Fatalf("Content-Type = %q, want application/octet-stream", got)
}
if got := recorder.Header().Get("Content-Disposition"); got != "attachment" {
t.Fatalf("Content-Disposition = %q, want attachment", got)
}
if got := recorder.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
}