-
-
-
+
+
diff --git a/modules/ui/src/app/pages/certificates/certificates.component.scss b/modules/ui/src/app/pages/certificates/certificates.component.scss
index aa0903cde..eca247502 100644
--- a/modules/ui/src/app/pages/certificates/certificates.component.scss
+++ b/modules/ui/src/app/pages/certificates/certificates.component.scss
@@ -62,15 +62,6 @@
grid-template-rows: auto 1fr auto;
}
-.browse-files-button {
- margin: 18px 16px;
- padding: 8px 24px;
- font-size: 14px;
- font-weight: 500;
- line-height: 20px;
- letter-spacing: 0.25px;
-}
-
.content-certificates {
padding: 0 16px;
border-bottom: 1px solid $lighter-grey;
diff --git a/modules/ui/src/app/pages/certificates/certificates.component.spec.ts b/modules/ui/src/app/pages/certificates/certificates.component.spec.ts
index 1b7554b70..abe237775 100644
--- a/modules/ui/src/app/pages/certificates/certificates.component.spec.ts
+++ b/modules/ui/src/app/pages/certificates/certificates.component.spec.ts
@@ -24,17 +24,31 @@ import SpyObj = jasmine.SpyObj;
import { of } from 'rxjs';
import { MatDialogRef } from '@angular/material/dialog';
import { DeleteFormComponent } from '../../components/delete-form/delete-form.component';
+import { TestRunService } from '../../services/test-run.service';
describe('CertificatesComponent', () => {
let component: CertificatesComponent;
let mockLiveAnnouncer: SpyObj
;
+ let mockService: SpyObj;
let fixture: ComponentFixture;
beforeEach(async () => {
+ mockService = jasmine.createSpyObj([
+ 'fetchCertificates',
+ 'deleteCertificate',
+ 'uploadCertificate',
+ ]);
+ mockService.deleteCertificate.and.returnValue(of(true));
+ mockService.fetchCertificates.and.returnValue(
+ of([certificate, certificate])
+ );
mockLiveAnnouncer = jasmine.createSpyObj(['announce']);
await TestBed.configureTestingModule({
imports: [CertificatesComponent, MatIconTestingModule, MatIcon],
- providers: [{ provide: LiveAnnouncer, useValue: mockLiveAnnouncer }],
+ providers: [
+ { provide: LiveAnnouncer, useValue: mockLiveAnnouncer },
+ { provide: TestRunService, useValue: mockService },
+ ],
}).compileComponents();
fixture = TestBed.createComponent(CertificatesComponent);
@@ -84,11 +98,6 @@ describe('CertificatesComponent', () => {
});
describe('with certificates', () => {
- beforeEach(() => {
- component.certificates = [certificate, certificate];
- fixture.detectChanges();
- });
-
it('should have certificates list', () => {
const certificateList = fixture.nativeElement.querySelectorAll(
'app-certificate-item'
diff --git a/modules/ui/src/app/pages/certificates/certificates.component.ts b/modules/ui/src/app/pages/certificates/certificates.component.ts
index cd2dbc845..b29f17014 100644
--- a/modules/ui/src/app/pages/certificates/certificates.component.ts
+++ b/modules/ui/src/app/pages/certificates/certificates.component.ts
@@ -13,19 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {
- Component,
- EventEmitter,
- Input,
- OnDestroy,
- Output,
-} from '@angular/core';
+import { Component, EventEmitter, OnDestroy, Output } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { CertificateItemComponent } from './certificate-item/certificate-item.component';
-import { NgForOf } from '@angular/common';
+import { CommonModule, DatePipe } from '@angular/common';
import { MatButtonModule } from '@angular/material/button';
-import { Certificate } from '../../model/certificate';
import { LiveAnnouncer } from '@angular/cdk/a11y';
+import { CertificateUploadButtonComponent } from './certificate-upload-button/certificate-upload-button.component';
+import { CertificatesStore } from './certificates.store';
import { DeleteFormComponent } from '../../components/delete-form/delete-form.component';
import { Subject, takeUntil } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
@@ -33,21 +28,30 @@ import { MatDialog } from '@angular/material/dialog';
@Component({
selector: 'app-certificates',
standalone: true,
- imports: [MatIcon, CertificateItemComponent, NgForOf, MatButtonModule],
+ imports: [
+ MatIcon,
+ CertificateItemComponent,
+ MatButtonModule,
+ CertificateUploadButtonComponent,
+ CommonModule,
+ ],
+ providers: [CertificatesStore, DatePipe],
templateUrl: './certificates.component.html',
styleUrl: './certificates.component.scss',
})
export class CertificatesComponent implements OnDestroy {
- @Input() certificates: Certificate[] = [];
+ viewModel$ = this.store.viewModel$;
@Output() closeCertificatedEvent = new EventEmitter();
- @Output() deleteCertificateEvent = new EventEmitter();
private destroy$: Subject = new Subject();
constructor(
private liveAnnouncer: LiveAnnouncer,
+ private store: CertificatesStore,
public dialog: MatDialog
- ) {}
+ ) {
+ this.store.getCertificates();
+ }
ngOnDestroy() {
this.destroy$.next(true);
@@ -59,6 +63,10 @@ export class CertificatesComponent implements OnDestroy {
this.closeCertificatedEvent.emit();
}
+ uploadFile(file: File) {
+ this.store.uploadCertificate(file);
+ }
+
deleteCertificate(name: string) {
const dialogRef = this.dialog.open(DeleteFormComponent, {
ariaLabel: 'Delete certificate',
@@ -77,7 +85,7 @@ export class CertificatesComponent implements OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe(deleteCertificate => {
if (deleteCertificate) {
- this.deleteCertificateEvent.emit(name);
+ this.store.deleteCertificate(name);
}
});
}
diff --git a/modules/ui/src/app/pages/certificates/certificates.store.spec.ts b/modules/ui/src/app/pages/certificates/certificates.store.spec.ts
new file mode 100644
index 000000000..bf7415b0b
--- /dev/null
+++ b/modules/ui/src/app/pages/certificates/certificates.store.spec.ts
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { TestBed } from '@angular/core/testing';
+import { of, skip, take } from 'rxjs';
+import { provideMockStore } from '@ngrx/store/testing';
+import { TestRunService } from '../../services/test-run.service';
+import SpyObj = jasmine.SpyObj;
+import {
+ certificate,
+ certificate2,
+ certificate_uploading,
+} from '../../mocks/certificate.mock';
+import { CertificatesStore } from './certificates.store';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { DatePipe } from '@angular/common';
+import { NotificationService } from '../../services/notification.service';
+
+describe('CertificatesStore', () => {
+ let certificateStore: CertificatesStore;
+ let mockService: SpyObj;
+ const notificationServiceMock: jasmine.SpyObj =
+ jasmine.createSpyObj(['notify']);
+
+ beforeEach(() => {
+ mockService = jasmine.createSpyObj([
+ 'fetchCertificates',
+ 'uploadCertificate',
+ 'deleteCertificate',
+ ]);
+
+ TestBed.configureTestingModule({
+ imports: [NoopAnimationsModule],
+ providers: [
+ CertificatesStore,
+ provideMockStore({}),
+ { provide: TestRunService, useValue: mockService },
+ { provide: NotificationService, useValue: notificationServiceMock },
+ DatePipe,
+ ],
+ });
+
+ certificateStore = TestBed.inject(CertificatesStore);
+ });
+
+ it('should be created', () => {
+ expect(certificateStore).toBeTruthy();
+ });
+
+ describe('updaters', () => {
+ it('should update certificates', (done: DoneFn) => {
+ certificateStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => {
+ expect(store.certificates).toEqual([certificate]);
+ done();
+ });
+
+ certificateStore.updateCertificates([certificate]);
+ });
+ });
+
+ describe('selectors', () => {
+ it('should select state', done => {
+ certificateStore.viewModel$.pipe(take(1)).subscribe(store => {
+ expect(store).toEqual({
+ certificates: [],
+ });
+ done();
+ });
+ });
+ });
+
+ describe('effects', () => {
+ describe('fetchCertificates', () => {
+ const certificates = [certificate];
+
+ beforeEach(() => {
+ mockService.fetchCertificates.and.returnValue(of(certificates));
+ });
+
+ it('should update certificates', done => {
+ certificateStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => {
+ expect(store.certificates).toEqual(certificates);
+ done();
+ });
+
+ certificateStore.getCertificates();
+ });
+ });
+
+ describe('uploadCertificate', () => {
+ const uploadingCertificate = certificate_uploading;
+
+ beforeEach(() => {
+ mockService.uploadCertificate.and.returnValue(of(true));
+ mockService.fetchCertificates.and.returnValue(of([certificate]));
+ });
+
+ it('should update certificates', done => {
+ certificateStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => {
+ expect(store.certificates).toContain(uploadingCertificate);
+ });
+
+ certificateStore.viewModel$.pipe(skip(2), take(1)).subscribe(store => {
+ expect(store.certificates).toEqual([certificate]);
+ done();
+ });
+
+ certificateStore.uploadCertificate(new File([], 'test'));
+ });
+
+ it('should notify', () => {
+ certificateStore.uploadCertificate(new File([], 'test'));
+ expect(notificationServiceMock.notify).toHaveBeenCalledWith(
+ 'Certificate successfully added.\niot.bms.google.com by Google, Inc. valid until 01 Sep 2024',
+ 0,
+ 'certificate-notification'
+ );
+ });
+ });
+
+ describe('deleteCertificate', () => {
+ it('should update store', done => {
+ mockService.deleteCertificate.and.returnValue(of(true));
+
+ certificateStore.updateCertificates([certificate, certificate2]);
+
+ certificateStore.viewModel$.pipe(skip(1), take(1)).subscribe(store => {
+ expect(store.certificates).toEqual([certificate2]);
+ done();
+ });
+
+ certificateStore.deleteCertificate('iot.bms.google.com');
+ });
+ });
+ });
+});
diff --git a/modules/ui/src/app/pages/certificates/certificates.store.ts b/modules/ui/src/app/pages/certificates/certificates.store.ts
new file mode 100644
index 000000000..4cc0830ff
--- /dev/null
+++ b/modules/ui/src/app/pages/certificates/certificates.store.ts
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Injectable } from '@angular/core';
+import { ComponentStore } from '@ngrx/component-store';
+import { tap, withLatestFrom } from 'rxjs/operators';
+import { catchError, EMPTY, exhaustMap, throwError } from 'rxjs';
+import { Certificate } from '../../model/certificate';
+import { TestRunService } from '../../services/test-run.service';
+import { NotificationService } from '../../services/notification.service';
+import { DatePipe } from '@angular/common';
+
+export interface AppComponentState {
+ certificates: Certificate[];
+}
+@Injectable()
+export class CertificatesStore extends ComponentStore {
+ private certificates$ = this.select(state => state.certificates);
+
+ viewModel$ = this.select({
+ certificates: this.certificates$,
+ });
+
+ updateCertificates = this.updater((state, certificates: Certificate[]) => ({
+ ...state,
+ certificates,
+ }));
+
+ getCertificates = this.effect(trigger$ => {
+ return trigger$.pipe(
+ exhaustMap(() => {
+ return this.testRunService.fetchCertificates().pipe(
+ tap((certificates: Certificate[]) => {
+ this.updateCertificates(certificates);
+ })
+ );
+ })
+ );
+ });
+
+ uploadCertificate = this.effect(trigger$ => {
+ return trigger$.pipe(
+ withLatestFrom(this.certificates$),
+ tap(([file, certificates]) => {
+ this.addCertificate(file.name, certificates);
+ }),
+ exhaustMap(([file, certificates]) => {
+ return this.testRunService.uploadCertificate(file).pipe(
+ exhaustMap(uploaded => {
+ if (uploaded) {
+ return this.testRunService.fetchCertificates();
+ }
+ return throwError('Failed to upload certificate');
+ }),
+ tap(newCertificates => {
+ const uploadedCertificate = newCertificates.filter(
+ certificate =>
+ !certificates.some(cert => cert.name === certificate.name)
+ )[0];
+ this.updateCertificates(newCertificates);
+ this.notificationService.notify(
+ `Certificate successfully added.\n${uploadedCertificate.name} by ${uploadedCertificate.organisation} valid until ${this.datePipe.transform(uploadedCertificate.expires, 'dd MMM yyyy')}`,
+ 0,
+ 'certificate-notification'
+ );
+ }),
+ catchError(() => {
+ this.removeCertificate(file.name, certificates);
+ return EMPTY;
+ })
+ );
+ })
+ );
+ });
+
+ addCertificate(name: string, certificates: Certificate[]) {
+ const certificate = { name, uploading: true } as Certificate;
+ this.updateCertificates([certificate, ...certificates]);
+ }
+
+ deleteCertificate = this.effect(trigger$ => {
+ return trigger$.pipe(
+ exhaustMap((name: string) => {
+ return this.testRunService.deleteCertificate(name).pipe(
+ withLatestFrom(this.certificates$),
+ tap(([, current]) => {
+ this.removeCertificate(name, current);
+ })
+ );
+ })
+ );
+ });
+
+ private removeCertificate(name: string, current: Certificate[]) {
+ const certificates = current.filter(
+ certificate => certificate.name !== name
+ );
+ this.updateCertificates(certificates);
+ }
+
+ constructor(
+ private testRunService: TestRunService,
+ private notificationService: NotificationService,
+ private datePipe: DatePipe
+ ) {
+ super({
+ certificates: [],
+ });
+ }
+}
diff --git a/modules/ui/src/app/services/notification.service.ts b/modules/ui/src/app/services/notification.service.ts
index 3a068b4e7..aab437c97 100644
--- a/modules/ui/src/app/services/notification.service.ts
+++ b/modules/ui/src/app/services/notification.service.ts
@@ -36,10 +36,10 @@ export class NotificationService {
private focusManagerService: FocusManagerService
) {}
- notify(message: string, duration = 0) {
+ notify(message: string, duration = 0, panelClass = 'test-run-notification') {
this.snackBarRef = this.snackBar.open(message, 'OK', {
horizontalPosition: 'center',
- panelClass: 'test-run-notification',
+ panelClass: panelClass,
duration: duration,
politeness: 'assertive',
});
diff --git a/modules/ui/src/app/services/test-run.service.spec.ts b/modules/ui/src/app/services/test-run.service.spec.ts
index 4bf137dbb..1c54810a6 100644
--- a/modules/ui/src/app/services/test-run.service.spec.ts
+++ b/modules/ui/src/app/services/test-run.service.spec.ts
@@ -476,6 +476,20 @@ describe('TestRunService', () => {
req.flush(certificates);
});
+ it('uploadCertificates should upload certificate', () => {
+ service.uploadCertificate(new File([], 'test')).subscribe(res => {
+ expect(res).toEqual(true);
+ });
+
+ const req = httpTestingController.expectOne(
+ 'http://localhost:8000/system/config/certs/upload'
+ );
+
+ expect(req.request.method).toBe('POST');
+
+ req.flush(true);
+ });
+
it('deleteCertificate should delete certificate', () => {
service.deleteCertificate('test').subscribe(res => {
expect(res).toEqual(true);
diff --git a/modules/ui/src/app/services/test-run.service.ts b/modules/ui/src/app/services/test-run.service.ts
index daa409125..719f0cfa3 100644
--- a/modules/ui/src/app/services/test-run.service.ts
+++ b/modules/ui/src/app/services/test-run.service.ts
@@ -230,4 +230,13 @@ export class TestRunService {
})
.pipe(map(() => true));
}
+
+ uploadCertificate(file: File): Observable {
+ const formData: FormData = new FormData();
+ formData.append('file', file, file.name);
+ formData.append('mode', 'file');
+ return this.http
+ .post(`${API_URL}/system/config/certs/upload`, formData)
+ .pipe(map(() => true));
+ }
}
diff --git a/modules/ui/src/styles.scss b/modules/ui/src/styles.scss
index 49863c3c3..c0c058ef6 100644
--- a/modules/ui/src/styles.scss
+++ b/modules/ui/src/styles.scss
@@ -272,3 +272,7 @@ body
opacity: var(--mat-text-button-focus-state-layer-opacity);
}
}
+
+.certificate-notification .mat-mdc-snack-bar-label {
+ white-space: pre-line;
+}