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:
@@ -0,0 +1,8 @@
|
||||
<button
|
||||
[bitAction]="download"
|
||||
bitIconButton="bwi-download"
|
||||
buttonType="main"
|
||||
size="small"
|
||||
type="button"
|
||||
[appA11yTitle]="'downloadAttachmentName' | i18n: attachment.fileName"
|
||||
></button>
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user