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
4 changes: 2 additions & 2 deletions frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "10kB",
"maximumError": "16kB"
"maximumWarning": "24kB",
"maximumError": "32kB"
}
],
"outputHashing": "all"
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/models/dtos/StorageQuotaDto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface StorageQuotaDto {
usedBytes: number;
totalBytes: number;
}
21 changes: 21 additions & 0 deletions frontend/src/app/pages/cloud/cloud.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@ <h1 class="cloud-title text-base sm:text-lg">Cloud Page</h1>
</ng-container>
</p>
</div>
<div class="cloud-quota-section" *ngIf="storageQuota">
<div class="quota-header text-xxs sm:text-xs">
<span class="quota-title">
<i class="pi pi-database"></i> Storage
</span>
<span class="quota-usage-text">
{{ usedSpaceLabel }} / {{ totalSpaceLabel }} ({{ quotaPercentage }}%)
</span>
</div>
<div
class="quota-progress-bg"
[pTooltip]="usedSpaceLabel + ' of ' + totalSpaceLabel + ' used'"
tooltipPosition="bottom"
>
<div
class="quota-progress-bar"
[style.width.%]="quotaPercentage"
[ngClass]="quotaStatusClass"
></div>
</div>
</div>
<div class="cloud-headline-actions">
<p-button
label="Scan"
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/app/pages/cloud/cloud.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,69 @@ $font-mono: ui-monospace, monospace;
}
}

.cloud-quota-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.35rem 0.65rem;
border-radius: 0.6rem;
border: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
min-width: 13rem;
max-width: 18rem;
flex-shrink: 0;

.quota-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
color: var(--color-text-secondary);
}

.quota-title {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
color: var(--color-text-primary);
}

.quota-usage-text {
font-weight: 600;
white-space: nowrap;
}

.quota-progress-bg {
height: 0.4rem;
width: 100%;
border-radius: 999px;
background: color-mix(in srgb, var(--color-border) 60%, transparent);
overflow: hidden;
cursor: pointer;
}

.quota-progress-bar {
height: 100%;
border-radius: 999px;
transition:
width 0.3s ease,
background-color 0.3s ease;

&.quota-normal {
background: linear-gradient(90deg, #3b82f6, #6366f1);
}

&.quota-warning {
background: linear-gradient(90deg, #f59e0b, #d97706);
}

&.quota-danger {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
}
}

.cloud-title {
margin: 0;
}
Expand Down Expand Up @@ -451,6 +514,12 @@ $font-mono: ui-monospace, monospace;
.cloud-headline-actions {
flex-wrap: wrap;
}
.cloud-quota-section {
min-width: 100%;
max-width: 100%;
margin-top: 0.25rem;
}

:host ::ng-deep {
.cloud-toolbar {
align-items: stretch;
Expand Down
148 changes: 134 additions & 14 deletions frontend/src/app/pages/cloud/cloud.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Router } from '@angular/router';
import { of, throwError, Subject } from 'rxjs';
import { ConfirmationService } from 'primeng/api';
import { UiToastService } from '../../core/services/ui-toast.service';
import { CloudComponent } from './cloud.component';
import { CloudService } from '../../services/cloud.service';
import { UiToastService } from '../../core/services/ui-toast.service';
import { ScanJobDto } from '../../models/dtos/ScanJobDto';
import { SecureSendLinkDto } from '../../models/dtos/SecureSendLinkDto';

Expand Down Expand Up @@ -69,17 +70,17 @@ describe('CloudComponent virus scan', () => {
expect(component.scanning).toBeTrue();
expect(component.scanJob?.status).toBe('RUNNING');

jasmine.clock().tick(1200); // first poll -> still running -> reschedule
jasmine.clock().tick(1200);
expect(component.scanning).toBeTrue();

jasmine.clock().tick(1200); // second poll -> completed
jasmine.clock().tick(1200);
expect(component.scanning).toBeFalse();
expect(component.scanJob?.status).toBe('COMPLETED');
expect(component.scanInfectedFindings.length).toBe(1);
expect(component.scanNoThreats).toBeFalse();

const callsSoFar = cloudMock.getScanJob.calls.count();
jasmine.clock().tick(6000); // terminal: must not poll again
jasmine.clock().tick(6000);
expect(cloudMock.getScanJob.calls.count()).toBe(callsSoFar);
});

Expand Down Expand Up @@ -173,19 +174,18 @@ describe('CloudComponent virus scan', () => {

it('keeps the scan alive when polling is rate limited, and still completes', () => {
cloudMock.startFolderScan.and.returnValue(of({ ...runningJob }));
// throttled once, then the backend lets us through again
cloudMock.getScanJob.and.returnValues(
throwError(() => ({ status: 429 })),
of({ ...runningJob, status: 'COMPLETED', findings: [] }),
);

component.scanCurrentFolder();
jasmine.clock().tick(1200); // first poll -> 429
jasmine.clock().tick(1200);

expect(component.scanning).toBeTrue();
expect(component.scanError).toBeUndefined();

jasmine.clock().tick(5000); // backoff elapses -> poll succeeds
jasmine.clock().tick(5000);
expect(component.scanning).toBeFalse();
expect(component.scanError).toBeUndefined();
expect(component.scanJob?.status).toBe('COMPLETED');
Expand All @@ -196,12 +196,12 @@ describe('CloudComponent virus scan', () => {
cloudMock.getScanJob.and.returnValue(throwError(() => ({ status: 429 })));

component.scanCurrentFolder();
jasmine.clock().tick(1200); // first poll
jasmine.clock().tick(5000 * 5); // exhaust the retries
jasmine.clock().tick(1200);
jasmine.clock().tick(5000 * 5);

expect(component.scanning).toBeFalse();
expect(component.scanError).toContain('may still be running');
expect(cloudMock.getScanJob).toHaveBeenCalledTimes(6); // initial + 5 retries
expect(cloudMock.getScanJob).toHaveBeenCalledTimes(6);
});

it('handles an expired job (404) during polling', () => {
Expand All @@ -217,7 +217,7 @@ describe('CloudComponent virus scan', () => {

it('stops polling once the dialog is closed mid-scan', () => {
cloudMock.startFolderScan.and.returnValue(of({ ...runningJob }));
cloudMock.getScanJob.and.returnValue(of({ ...runningJob })); // never terminal
cloudMock.getScanJob.and.returnValue(of({ ...runningJob }));

component.scanCurrentFolder();
jasmine.clock().tick(1200);
Expand All @@ -234,9 +234,9 @@ describe('CloudComponent virus scan', () => {
cloudMock.startFolderScan.and.returnValue(start$.asObservable());
cloudMock.getScanJob.and.returnValue(of({ ...runningJob }));

component.scanCurrentFolder(); // POST in flight
component.onScanDialogHide(); // user closes before it resolves
start$.next({ ...runningJob }); // POST resolves late
component.scanCurrentFolder();
component.onScanDialogHide();
start$.next({ ...runningJob });
start$.complete();
jasmine.clock().tick(6000);
});
Expand Down Expand Up @@ -520,6 +520,7 @@ describe('CloudComponent Secure Send Flow', () => {
'createSecureSendLink',
'listSecureSendLinks',
'revokeSecureSendLink',
'getStorageQuota',
]);

cloudServiceSpy.getRootFolder.and.returnValue(
Expand All @@ -528,6 +529,9 @@ describe('CloudComponent Secure Send Flow', () => {
cloudServiceSpy.getFolderContent.and.returnValue(
of({ content: [], totalElements: 0, totalPages: 0, page: 0 } as any),
);
cloudServiceSpy.getStorageQuota.and.returnValue(
of({ usedBytes: 500, totalBytes: 1000 }),
);

toastSpy = jasmine.createSpyObj('UiToastService', [
'success',
Expand Down Expand Up @@ -757,3 +761,119 @@ describe('CloudComponent unsaved-edit guard', () => {
expect(dirty.preventDefault).toHaveBeenCalled();
});
});

describe('CloudComponent Storage Quota', () => {
let component: CloudComponent;
let fixture: ComponentFixture<CloudComponent>;
let cloudServiceSpy: jasmine.SpyObj<CloudService>;
let toastSpy: jasmine.SpyObj<UiToastService>;

beforeEach(async () => {
cloudServiceSpy = jasmine.createSpyObj('CloudService', [
'getRootFolder',
'getFolderContent',
'getStorageQuota',
'uploadFile',
'deleteFile',
]);
toastSpy = jasmine.createSpyObj('UiToastService', [
'success',
'error',
'info',
]);

cloudServiceSpy.getRootFolder.and.returnValue(
of({
name: 'Root',
path: '/',
folders: [],
files: [],
lastModifiedAt: 0,
} as any),
);
cloudServiceSpy.getFolderContent.and.returnValue(
of({
content: [],
totalElements: 0,
totalPages: 1,
pageNumber: 0,
pageSize: 50,
} as any),
);
cloudServiceSpy.getStorageQuota.and.returnValue(
of({ usedBytes: 500, totalBytes: 1000 }),
);

await TestBed.configureTestingModule({
imports: [
CloudComponent,
HttpClientTestingModule,
RouterTestingModule,
NoopAnimationsModule,
],
providers: [
{ provide: CloudService, useValue: cloudServiceSpy },
{ provide: UiToastService, useValue: toastSpy },
{
provide: Router,
useValue: { navigate: jasmine.createSpy('navigate') },
},
ConfirmationService,
],
}).compileComponents();

fixture = TestBed.createComponent(CloudComponent);
component = fixture.componentInstance;
});

it('should initialize and load storage quota', () => {
fixture.detectChanges();
expect(component.storageQuota).toEqual({
usedBytes: 500,
totalBytes: 1000,
});
expect(component.quotaPercentage).toBe(50);
expect(component.usedSpaceLabel).toBe('500 Bytes');
expect(component.totalSpaceLabel).toBe('1000 Bytes');
expect(component.quotaStatusClass).toBe('quota-normal');
});

it('should set warning and danger status classes based on quota percentage', () => {
component.storageQuota = { usedBytes: 800, totalBytes: 1000 };
expect(component.quotaPercentage).toBe(80);
expect(component.quotaStatusClass).toBe('quota-warning');

component.storageQuota = { usedBytes: 950, totalBytes: 1000 };
expect(component.quotaPercentage).toBe(95);
expect(component.quotaStatusClass).toBe('quota-danger');
});

it('should detect quota-exceeded errors correctly', () => {
expect(component.isQuotaExceededError({ status: 413 })).toBeTrue();
expect(component.isQuotaExceededError({ status: 507 })).toBeTrue();
expect(
component.isQuotaExceededError({
error: { message: 'Storage quota exceeded' },
}),
).toBeTrue();
expect(
component.isQuotaExceededError({
message: 'User is out of space',
}),
).toBeTrue();
expect(
component.isQuotaExceededError({
status: 400,
message: 'Invalid format',
}),
).toBeFalse();
});

it('should hide storage quota widget when storage quota endpoint fails', () => {
cloudServiceSpy.getStorageQuota.and.returnValue(
throwError(() => new Error('Quota endpoint unavailable')),
);
component.loadStorageQuota();
expect(component.storageQuota).toBeUndefined();
});
});
Loading