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:
2026-07-16 00:06:31 +08:00
parent bb86950632
commit 6604ecb026
8 changed files with 292 additions and 42 deletions
+136 -13
View File
@@ -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")
}