1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

[PM-9809] attachments v2 refactor (#10142)

* update attachments v2 view. using download attachment component. remove excess code. Refactor location of attachments v2
This commit is contained in:
Jason Ng
2024-07-23 13:27:39 -04:00
committed by GitHub
parent decc7a3031
commit 6041c460b7
19 changed files with 126 additions and 196 deletions

View File

@@ -0,0 +1,8 @@
<button
[bitAction]="download"
bitIconButton="bwi-download"
buttonType="main"
size="small"
type="button"
[appA11yTitle]="'downloadAttachmentName' | i18n: attachment.fileName"
></button>

View File

@@ -0,0 +1,146 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CipherType } from "@bitwarden/common/vault/enums";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "../../services/password-reprompt.service";
import { DownloadAttachmentComponent } from "./download-attachment.component";
class MockRequest {
constructor(public url: string) {}
}
describe("DownloadAttachmentComponent", () => {
let component: DownloadAttachmentComponent;
let fixture: ComponentFixture<DownloadAttachmentComponent>;
const activeUserId$ = new BehaviorSubject("888-333-222-222");
const showToast = jest.fn();
const getAttachmentData = jest
.fn()
.mockResolvedValue({ url: "https://www.downloadattachement.com" });
const download = jest.fn();
const attachment = {
id: "222-3333-4444",
url: "https://www.attachment.com",
fileName: "attachment-filename",
size: "1234",
} as AttachmentView;
const cipherView = {
id: "5555-444-3333",
type: CipherType.Login,
name: "Test Login",
login: {
username: "username",
password: "password",
},
} as CipherView;
beforeEach(async () => {
showToast.mockClear();
getAttachmentData.mockClear();
download.mockClear();
await TestBed.configureTestingModule({
imports: [DownloadAttachmentComponent],
providers: [
{ provide: EncryptService, useValue: mock<EncryptService>() },
{ provide: CryptoService, useValue: mock<CryptoService>() },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: StateProvider, useValue: { activeUserId$ } },
{ provide: ToastService, useValue: { showToast } },
{ provide: ApiService, useValue: { getAttachmentData } },
{ provide: FileDownloadService, useValue: { download } },
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DownloadAttachmentComponent);
component = fixture.componentInstance;
component.attachment = attachment;
component.cipher = cipherView;
fixture.detectChanges();
});
it("renders delete button", () => {
const deleteButton = fixture.debugElement.query(By.css("button"));
expect(deleteButton.attributes["title"]).toBe("downloadAttachmentName");
});
describe("download attachment", () => {
let fetchMock: jest.Mock;
beforeEach(() => {
fetchMock = jest.fn().mockResolvedValue({});
global.fetch = fetchMock;
// Request is not defined in the Jest runtime
// eslint-disable-next-line no-global-assign
Request = MockRequest as any;
});
it("uses the attachment url when available when getAttachmentData returns a 404", async () => {
getAttachmentData.mockRejectedValue(new ErrorResponse({}, 404));
await component.download();
expect(fetchMock).toHaveBeenCalledWith({ url: attachment.url });
});
it("calls file download service with the attachment url", async () => {
getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" });
fetchMock.mockResolvedValue({ status: 200 });
EncArrayBuffer.fromResponse = jest.fn().mockResolvedValue({});
await component.download();
expect(download).toHaveBeenCalledWith({ blobData: undefined, fileName: attachment.fileName });
});
describe("errors", () => {
it("shows an error toast when fetch fails", async () => {
getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" });
fetchMock.mockResolvedValue({ status: 500 });
await component.download();
expect(showToast).toHaveBeenCalledWith({
message: "errorOccurred",
title: null,
variant: "error",
});
});
it("shows an error toast when EncArrayBuffer fails", async () => {
getAttachmentData.mockResolvedValue({ url: "https://www.downloadattachement.com" });
fetchMock.mockResolvedValue({ status: 200 });
EncArrayBuffer.fromResponse = jest.fn().mockRejectedValue({});
await component.download();
expect(showToast).toHaveBeenCalledWith({
message: "errorOccurred",
title: null,
variant: "error",
});
});
});
});
});

View File

@@ -0,0 +1,121 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NEVER, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AsyncActionsModule, IconButtonModule, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "../../services/password-reprompt.service";
@Component({
standalone: true,
selector: "app-download-attachment",
templateUrl: "./download-attachment.component.html",
imports: [AsyncActionsModule, CommonModule, JslibModule, IconButtonModule],
})
export class DownloadAttachmentComponent {
/** Attachment to download */
@Input({ required: true }) attachment: AttachmentView;
/** The cipher associated with the attachment */
@Input({ required: true }) cipher: CipherView;
// When in view mode, we will want to check for the master password reprompt
@Input() checkPwReprompt?: boolean = false;
/** The organization key if the cipher is associated with one */
private orgKey: OrgKey | null = null;
private passwordReprompted = false;
constructor(
private i18nService: I18nService,
private apiService: ApiService,
private fileDownloadService: FileDownloadService,
private toastService: ToastService,
private encryptService: EncryptService,
private stateProvider: StateProvider,
private cryptoService: CryptoService,
private passwordRepromptService: PasswordRepromptService,
) {
this.stateProvider.activeUserId$
.pipe(
switchMap((userId) => (userId !== null ? this.cryptoService.orgKeys$(userId) : NEVER)),
takeUntilDestroyed(),
)
.subscribe((data: Record<OrganizationId, OrgKey> | null) => {
if (data) {
this.orgKey = data[this.cipher.organizationId as OrganizationId];
}
});
}
/** Download the attachment */
download = async () => {
if (this.checkPwReprompt) {
this.passwordReprompted =
this.passwordReprompted ||
(await this.passwordRepromptService.passwordRepromptCheck(this.cipher));
if (!this.passwordReprompted) {
return;
}
}
let url: string;
try {
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
this.cipher.id,
this.attachment.id,
);
url = attachmentDownloadResponse.url;
} catch (e) {
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
url = this.attachment.url;
} else if (e instanceof ErrorResponse) {
throw new Error((e as ErrorResponse).getSingleMessage());
} else {
throw e;
}
}
const response = await fetch(new Request(url, { cache: "no-store" }));
if (response.status !== 200) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
return;
}
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const key = this.attachment.key != null ? this.attachment.key : this.orgKey;
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
this.fileDownloadService.download({
fileName: this.attachment.fileName,
blobData: decBuf,
});
} catch (e) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
};
}