Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions frontend/src/app/pages/cloud/cloud.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ <h1 class="cloud-title text-base sm:text-lg">Cloud Page</h1>
</p>
</div>
<div class="cloud-headline-actions">
<p-button
*ngIf="currentFolder"
label="Download"
icon="pi pi-download"
[outlined]="true"
styleClass="p-button-sm cloud-accent-btn text-xxs sm:text-xs"
pTooltip="Download current folder as ZIP"
tooltipPosition="bottom"
[loading]="isDownloading(currentFolder.path)"
[disabled]="isDownloading(currentFolder.path)"
(click)="downloadCurrentFolder()"
></p-button>
<p-button
label="Scan"
icon="pi pi-shield"
Expand Down Expand Up @@ -234,15 +246,26 @@ <h1 class="cloud-title text-base sm:text-lg">Cloud Page</h1>
ariaLabel="Create share link"
></p-button>
<p-button
*ngIf="entry.kind === 'file'"
icon="pi pi-download"
styleClass="p-button-rounded p-button-text p-button-sm cloud-icon-btn"
[loading]="isDownloading(entry.path)"
[disabled]="isDownloading(entry.path)"
(click)="downloadFileByPath(entry.path, entry.name)"
pTooltip="Download file"
(click)="
entry.kind === 'folder'
? downloadFolderByPath(entry.path, entry.name)
: downloadFileByPath(entry.path, entry.name)
"
[pTooltip]="
entry.kind === 'folder'
? 'Download folder as ZIP'
: 'Download file'
"
tooltipPosition="top"
ariaLabel="Download file"
[ariaLabel]="
entry.kind === 'folder'
? 'Download folder as ZIP'
: 'Download file'
"
></p-button>
<p-button
icon="pi pi-pencil"
Expand Down
100 changes: 100 additions & 0 deletions frontend/src/app/pages/cloud/cloud.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,106 @@ describe('CloudComponent virus scan', () => {

expect(cloudMock.getScanJob).not.toHaveBeenCalled();
});

describe('folder download', () => {
let component: CloudComponent;
let cloudMock: jasmine.SpyObj<CloudService>;
let toastMock: jasmine.SpyObj<UiToastService>;

beforeEach(() => {
jasmine.clock().uninstall();
cloudMock = jasmine.createSpyObj<CloudService>('CloudService', [
'getFolderArchive',
]);
toastMock = jasmine.createSpyObj<UiToastService>('UiToastService', [
'success',
'info',
'warn',
'error',
]);
component = new CloudComponent(
cloudMock,
{} as never,
toastMock as never,
{} as never,
{} as never,
);
component.rootPath = '/root';
component.currentFolder = { path: '/root/sub', name: 'sub' } as never;
});

it('downloads the folder as a zip archive and triggers browser download', () => {
const mockBlob = new Blob(['zip content'], { type: 'application/zip' });
cloudMock.getFolderArchive.and.returnValue(of(mockBlob));

const mockAnchor = jasmine.createSpyObj<HTMLAnchorElement>(
'HTMLAnchorElement',
['click'],
);
spyOn(document, 'createElement').and.returnValue(mockAnchor as any);
spyOn(URL, 'createObjectURL').and.returnValue('blob:mock-url');
spyOn(URL, 'revokeObjectURL');

component.downloadFolder('/root/sub', 'sub');

expect(cloudMock.getFolderArchive).toHaveBeenCalledWith('sub');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error specs wait on a raw setTimeout of 50ms before asserting, which is a flake risk on slow CI. parseBlobError completes its observable, so you can assert inside the subscribe callback or use fakeAsync with tick instead of a wall clock delay.

expect(document.createElement).toHaveBeenCalledWith('a');
expect(mockAnchor.href).toBe('blob:mock-url');
expect(mockAnchor.download).toBe('sub.zip');
expect(mockAnchor.click).toHaveBeenCalled();
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
expect(toastMock.info).toHaveBeenCalledWith(
'Download started',
'"sub.zip" is downloading.',
);
});

it('surfaces folder archive backend errors using toast notifications', (done) => {
const mockErrorResponse = {
error: new Blob([JSON.stringify({ message: 'Rate limit exceeded' })], {
type: 'application/json',
}),
};
spyOn<any>(component, 'getErrorMessage').and.returnValue(
'Rate limit exceeded',
);
cloudMock.getFolderArchive.and.returnValue(
throwError(() => mockErrorResponse),
);

component.downloadFolder('/root/sub', 'sub');

setTimeout(() => {
expect(toastMock.error).toHaveBeenCalledWith(
'Download failed',
'Rate limit exceeded',
);
done();
}, 50);
});

it('falls back to generic error message if error blob is not parseable', (done) => {
const mockErrorResponse = {
error: new Blob(['invalid json'], { type: 'application/json' }),
};
spyOn<any>(component, 'getErrorMessage').and.returnValue(
'Request failed. Please try again.',
);
cloudMock.getFolderArchive.and.returnValue(
throwError(() => mockErrorResponse),
);

component.downloadFolder('/root/sub', 'sub');

setTimeout(() => {
expect(toastMock.error).toHaveBeenCalledWith(
'Download failed',
'Request failed. Please try again.',
);
done();
}, 50);
});
});
});

describe('CloudComponent Secure Send Flow', () => {
Expand Down
73 changes: 72 additions & 1 deletion frontend/src/app/pages/cloud/cloud.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { ScanJobDto } from '../../models/dtos/ScanJobDto';
import { FileScanResultDto } from '../../models/dtos/FileScanResultDto';
import { SecureSendLinkDto } from '../../models/dtos/SecureSendLinkDto';
import { CloudService } from '../../services/cloud.service';
import { finalize, firstValueFrom } from 'rxjs';
import { finalize, firstValueFrom, Observable, of } from 'rxjs';
import { UiToastService } from '../../core/services/ui-toast.service';

interface Breadcrumb {
Expand Down Expand Up @@ -1018,6 +1018,77 @@ export class CloudComponent implements OnInit, OnDestroy {
this.downloadFile(this.toFileRef(path, name));
}

downloadFolder(folderPath: string, folderName: string) {
const pathKey = folderPath;
const relativePath = this.getRelativePath(folderPath);
this.downloadingPaths.add(pathKey);

this.cloudService
.getFolderArchive(relativePath)
.pipe(
finalize(() => {
this.downloadingPaths.delete(pathKey);
}),
)
.subscribe({
next: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const zipName = folderName ? `${folderName}.zip` : 'archive.zip';
a.download = zipName;
a.click();
window.URL.revokeObjectURL(url);
this.toast.info('Download started', `"${zipName}" is downloading.`);
},
error: (err) => {
this.parseBlobError(err).subscribe((msg) => {
this.toast.error('Download failed', msg);
});
},
});
}

downloadFolderByPath(path: string, name: string) {
this.downloadFolder(path, name);
}

downloadCurrentFolder() {
if (this.currentFolder) {
const folderName =
this.currentFolder.path === this.rootPath
? 'cloud-root'
: this.getNameFromPath(this.currentFolder.path);
this.downloadFolder(this.currentFolder.path, folderName);
}
}

private parseBlobError(err: unknown): Observable<string> {
const candidate = err as { error?: unknown };
if (candidate && candidate.error instanceof Blob) {
const reader = new FileReader();
return new Observable<string>((observer) => {
reader.onload = () => {
try {
const parsed = JSON.parse(reader.result as string);
observer.next(
parsed.message || 'Request failed. Please try again.',
);
} catch {
observer.next('Request failed. Please try again.');
}
observer.complete();
};
reader.onerror = () => {
observer.next('Request failed. Please try again.');
observer.complete();
};
reader.readAsText(candidate.error as Blob);
});
}
return of(this.getErrorMessage(err));
}

previewFileByPath(path: string, name: string) {
this.previewFile(this.toFileRef(path, name));
}
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/services/cloud.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ export class CloudService {
});
}

getFolderArchive(relativePath: string): Observable<Blob> {
const path = this.normalizePath(relativePath);
return this.http.get(`${this.apiUrl}/folders/archive`, {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint is not implemented in cloud-page yet (the dependency Vault-Web/cloud-page#105 is an open issue with no PR). The URL and the folderPath param name are guesses until the backend lands. Once it does, verify both against the actual controller and add rate limit and oversized folder handling if the backend returns specific statuses for those, since the acceptance criteria in #279 call them out.

params: new HttpParams().set('folderPath', path),
responseType: 'blob',
});
}

getFileView(path: string) {
return this.http.get(`${this.apiUrl}/files/view`, {
params: { path },
Expand Down