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
+5 -6
View File
@@ -304,10 +304,6 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
file, err := s.getOwnedFile(ctx, userID, fileID)
if err != nil {
var ae *model.AppError
if errors.As(err, &ae) && errors.Is(ae.Err, model.ErrNotFound) {
return nil // idempotent: already deleted
}
return err
}
@@ -323,6 +319,9 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
}
if err := s.fileRepo.Delete(ctx, fileID); err != nil {
if errors.Is(err, model.ErrNotFound) {
return model.NewNotFoundError("file not found", model.ErrNotFound)
}
return model.NewInternalError("delete file record", err)
}
@@ -378,7 +377,7 @@ func (s *FileService) getOwnedFile(ctx context.Context, userID, fileID string) (
}
if file.UserID != userID {
return nil, model.NewForbiddenError("access denied", model.ErrForbidden)
return nil, model.NewNotFoundError("file not found", model.ErrNotFound)
}
return file, nil
@@ -395,7 +394,7 @@ func (s *FileService) verifyParent(ctx context.Context, userID, parentID string)
}
if parent.UserID != userID {
return model.NewForbiddenError("access denied", model.ErrForbidden)
return model.NewNotFoundError("parent directory not found", model.ErrNotFound)
}
if !parent.IsDir {
+87 -15
View File
@@ -32,6 +32,18 @@ func assertAppError(t *testing.T, err error, wantKind model.ErrorKind) bool {
return true
}
func assertAppErrorMessage(t *testing.T, err error, wantKind model.ErrorKind, wantMessage string) {
t.Helper()
if !assertAppError(t, err, wantKind) {
return
}
var ae *model.AppError
errors.As(err, &ae)
if ae.Message != wantMessage {
t.Errorf("message = %q, want %q", ae.Message, wantMessage)
}
}
// memStorage is an in-memory implementation of storage.StorageBackend for tests.
type memStorage struct {
mu sync.RWMutex
@@ -274,7 +286,7 @@ func TestFileService_Download(t *testing.T) {
}
}
func TestFileService_DownloadForbidden(t *testing.T) {
func TestFileService_DownloadReturnsNotFoundForOtherUser(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
@@ -284,7 +296,7 @@ func TestFileService_DownloadForbidden(t *testing.T) {
}
_, _, err = svc.Download(ctx, "user2", info.ID)
assertAppError(t, err, model.KindPermissionDenied)
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
}
func TestFileService_DownloadDirectory(t *testing.T) {
@@ -328,7 +340,7 @@ func TestFileService_GetNotFound(t *testing.T) {
assertAppError(t, err, model.KindNotFound)
}
func TestFileService_GetForbidden(t *testing.T) {
func TestFileService_GetReturnsNotFoundForOtherUser(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
@@ -338,7 +350,7 @@ func TestFileService_GetForbidden(t *testing.T) {
}
_, err = svc.Get(ctx, "user2", info.ID)
assertAppError(t, err, model.KindPermissionDenied)
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
}
func TestFileService_List(t *testing.T) {
@@ -395,6 +407,19 @@ func TestFileService_Update(t *testing.T) {
}
}
func TestFileService_UpdateReturnsNotFoundForOtherUser(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
info, err := svc.Upload(ctx, "user1", nil, "private.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload = %v", err)
}
_, err = svc.Update(ctx, "user2", info.ID, "stolen.txt", nil)
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
}
func TestFileService_UpdateMove(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
@@ -458,7 +483,7 @@ func TestFileService_DeleteNonEmptyDirectory(t *testing.T) {
}
}
func TestFileService_DeleteForbidden(t *testing.T) {
func TestFileService_DeleteReturnsNotFoundForOtherUser(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
@@ -468,7 +493,14 @@ func TestFileService_DeleteForbidden(t *testing.T) {
}
err = svc.Delete(ctx, "user2", info.ID)
assertAppError(t, err, model.KindPermissionDenied)
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
}
func TestFileService_DeleteReturnsNotFoundForMissingFile(t *testing.T) {
svc := setupFileService(t)
err := svc.Delete(context.Background(), "user1", "missing-file")
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
}
func TestFileService_CreateDir(t *testing.T) {
@@ -502,7 +534,7 @@ func TestFileService_CreateDirDuplicateName(t *testing.T) {
}
}
func TestFileService_VerifyParentForbidden(t *testing.T) {
func TestFileService_ParentOperationsReturnNotFoundForOtherUser(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
@@ -510,9 +542,50 @@ func TestFileService_VerifyParentForbidden(t *testing.T) {
if err != nil {
t.Fatalf("CreateDir = %v", err)
}
otherFile, err := svc.Upload(ctx, "user2", nil, "other.txt", strings.NewReader("data"))
if err != nil {
t.Fatalf("Upload other file = %v", err)
}
_, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
assertAppError(t, err, model.KindPermissionDenied)
tests := []struct {
name string
run func() error
}{
{
name: "list",
run: func() error {
_, err := svc.List(ctx, "user2", &dir.ID, 0, 50)
return err
},
},
{
name: "upload",
run: func() error {
_, err := svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
return err
},
},
{
name: "create directory",
run: func() error {
_, err := svc.CreateDir(ctx, "user2", &dir.ID, "stolen-dir")
return err
},
},
{
name: "move",
run: func() error {
_, err := svc.Update(ctx, "user2", otherFile.ID, "", &dir.ID)
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assertAppErrorMessage(t, tt.run(), model.KindNotFound, "parent directory not found")
})
}
}
func TestFileService_SoftDelete(t *testing.T) {
@@ -562,7 +635,7 @@ func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
}
}
func TestFileService_SoftDeleteIdempotent(t *testing.T) {
func TestFileService_SoftDeleteRepeatedReturnsNotFound(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
@@ -574,12 +647,11 @@ func TestFileService_SoftDeleteIdempotent(t *testing.T) {
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("first Delete = %v", err)
}
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
t.Fatalf("second Delete should be idempotent, got: %v", err)
}
err = svc.Delete(ctx, "user1", info.ID)
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
}
func TestFileService_SoftDeleteForbidden(t *testing.T) {
func TestFileService_SoftDeleteReturnsNotFoundForOtherUser(t *testing.T) {
svc := setupFileService(t)
ctx := context.Background()
@@ -589,5 +661,5 @@ func TestFileService_SoftDeleteForbidden(t *testing.T) {
}
err = svc.Delete(ctx, "user2", info.ID)
assertAppError(t, err, model.KindPermissionDenied)
assertAppErrorMessage(t, err, model.KindNotFound, "file not found")
}