feat(file): conceal file resource existence from other users
- feat: cross-user file access returns not-found instead of permission-denied - fix: repository delete now only affects active rows and returns ErrNotFound when no row changes; repeated deletes yield not-found - feat: parent directory operations (list, upload, create-dir, move) conceal ownership of other user's directories - test: update handler, service, repository, and integration tests to assert not-found behavior for cross-user and missing file operations
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -44,6 +45,32 @@ type fileIntegrationList struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func assertFileIntegrationNotFound(
|
||||
t *testing.T,
|
||||
rec *httptest.ResponseRecorder,
|
||||
wantMessage string,
|
||||
) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode error response: %v", err)
|
||||
}
|
||||
if response.Error.Message != wantMessage {
|
||||
t.Fatalf("message=%q, want %q", response.Error.Message, wantMessage)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "access denied") {
|
||||
t.Fatalf("response leaks authorization result: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newFileIntegrationFixture(t *testing.T) *fileIntegrationFixture {
|
||||
t.Helper()
|
||||
|
||||
@@ -402,6 +429,7 @@ func TestFileRoutesEnforceAuthenticatedOwnership(t *testing.T) {
|
||||
if otherList.Total != 0 || len(otherList.Files) != 0 {
|
||||
t.Fatalf("other user's root list=%+v, want empty list", otherList)
|
||||
}
|
||||
otherFile := fixture.uploadFile(t, otherTokens.AccessToken, "other.txt", []byte("other content"))
|
||||
|
||||
createBody, err := json.Marshal(map[string]any{
|
||||
"name": "intrusion",
|
||||
@@ -410,18 +438,81 @@ func TestFileRoutesEnforceAuthenticatedOwnership(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cross-owner create body: %v", err)
|
||||
}
|
||||
createRequest := newAuthenticatedFileRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/files",
|
||||
bytes.NewReader(createBody),
|
||||
moveBody, err := json.Marshal(map[string]any{"parent_id": privateDir.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cross-owner move body: %v", err)
|
||||
}
|
||||
parentRequests := []struct {
|
||||
name string
|
||||
newRequest func(*testing.T) *http.Request
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
newRequest: func(*testing.T) *http.Request {
|
||||
return newAuthenticatedFileRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/files?parent_id="+privateDir.ID,
|
||||
nil,
|
||||
otherTokens.AccessToken,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create directory",
|
||||
newRequest: func(*testing.T) *http.Request {
|
||||
req := newAuthenticatedFileRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/files",
|
||||
bytes.NewReader(createBody),
|
||||
otherTokens.AccessToken,
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "upload",
|
||||
newRequest: func(t *testing.T) *http.Request {
|
||||
return newFileUploadRequest(
|
||||
t,
|
||||
"/api/v1/files?parent_id="+privateDir.ID,
|
||||
otherTokens.AccessToken,
|
||||
"intrusion.txt",
|
||||
[]byte("must not persist"),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move",
|
||||
newRequest: func(*testing.T) *http.Request {
|
||||
req := newAuthenticatedFileRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/files/"+otherFile.ID,
|
||||
bytes.NewReader(moveBody),
|
||||
otherTokens.AccessToken,
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, request := range parentRequests {
|
||||
t.Run("parent/"+request.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(rec, request.newRequest(t))
|
||||
assertFileIntegrationNotFound(t, rec, "parent directory not found")
|
||||
})
|
||||
}
|
||||
missingParentRequest := newAuthenticatedFileRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/files?parent_id=missing-directory",
|
||||
nil,
|
||||
otherTokens.AccessToken,
|
||||
)
|
||||
createRequest.Header.Set("Content-Type", "application/json")
|
||||
createResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(createResponse, createRequest)
|
||||
if createResponse.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-owner create: status=%d, want %d; body=%s", createResponse.Code, http.StatusForbidden, createResponse.Body.String())
|
||||
}
|
||||
missingParentResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(missingParentResponse, missingParentRequest)
|
||||
assertFileIntegrationNotFound(t, missingParentResponse, "parent directory not found")
|
||||
|
||||
updateBody := []byte(`{"name":"stolen.txt"}`)
|
||||
requests := []struct {
|
||||
@@ -451,9 +542,7 @@ func TestFileRoutesEnforceAuthenticatedOwnership(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
|
||||
}
|
||||
assertFileIntegrationNotFound(t, rec, "file not found")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -499,4 +588,38 @@ func TestFileRoutesEnforceAuthenticatedOwnership(t *testing.T) {
|
||||
if dirList.Total != 0 || len(dirList.Files) != 0 {
|
||||
t.Fatalf("private directory after rejected create=%+v, want empty list", dirList)
|
||||
}
|
||||
|
||||
otherRootList := fixture.listFiles(t, otherTokens.AccessToken, "/api/v1/files")
|
||||
if otherRootList.Total != 1 || len(otherRootList.Files) != 1 || otherRootList.Files[0].ID != otherFile.ID {
|
||||
t.Fatalf("other user's root list after rejected move=%+v, want original file", otherRootList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDeleteReturnsNotFoundForMissingAndDeletedFiles(t *testing.T) {
|
||||
fixture := newFileIntegrationFixture(t)
|
||||
_, tokens := fixture.registerAndLogin(t, "delete-user")
|
||||
|
||||
missingRequest := newAuthenticatedFileRequest(
|
||||
http.MethodDelete,
|
||||
"/api/v1/files/missing-file",
|
||||
nil,
|
||||
tokens.AccessToken,
|
||||
)
|
||||
missingResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(missingResponse, missingRequest)
|
||||
assertFileIntegrationNotFound(t, missingResponse, "file not found")
|
||||
|
||||
uploaded := fixture.uploadFile(t, tokens.AccessToken, "delete-me.txt", []byte("delete content"))
|
||||
deleteTarget := "/api/v1/files/" + uploaded.ID
|
||||
firstRequest := newAuthenticatedFileRequest(http.MethodDelete, deleteTarget, nil, tokens.AccessToken)
|
||||
firstResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(firstResponse, firstRequest)
|
||||
if firstResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("first delete: status=%d, want %d; body=%s", firstResponse.Code, http.StatusNoContent, firstResponse.Body.String())
|
||||
}
|
||||
|
||||
secondRequest := newAuthenticatedFileRequest(http.MethodDelete, deleteTarget, nil, tokens.AccessToken)
|
||||
secondResponse := httptest.NewRecorder()
|
||||
fixture.router.ServeHTTP(secondResponse, secondRequest)
|
||||
assertFileIntegrationNotFound(t, secondResponse, "file not found")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user