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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user