1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 06:13:38 +00:00

[PM-22100] Enforce restrictions based on collection type (#15336)

* enforce restrictions based on collection type, set default collection type

* fix ts strict errors

* fix default collection enforcement in vault header

* enforce default collection restrictions in vault collection row

* enforce default collection restrictions in AC vault header

* enforce default collection restriction for select all

* fix ts strict error

* switch to signal, fix feature flag

* fix story

* clean up

* remove feature flag, move check for defaultCollecion to CollecitonView

* fix test

* remove unused configService

* fix test: coerce null to undefined for collection Id

* clean up leaky abstraction for default collection

* fix ts-strict error

* fix parens

* rename defaultCollection getter

* clean up
This commit is contained in:
Brandon Treston
2025-07-18 10:53:12 -04:00
committed by GitHub
parent 8811ec41ab
commit 92bbe0a3c2
14 changed files with 182 additions and 148 deletions

View File

@@ -237,7 +237,7 @@ export class VaultPopupListFiltersService {
return false; return false;
} }
if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id)) { if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id!)) {
return false; return false;
} }

View File

@@ -25,62 +25,67 @@
</bit-breadcrumbs> </bit-breadcrumbs>
<ng-container slot="title-suffix"> <ng-container slot="title-suffix">
<ng-container @if (
*ngIf=" collection != null && (canEditCollection || canDeleteCollection || canViewCollectionInfo)
collection != null && (canEditCollection || canDeleteCollection || canViewCollectionInfo) ) {
" <ng-container>
> <button
<button bitIconButton="bwi-angle-down"
bitIconButton="bwi-angle-down" [bitMenuTriggerFor]="editCollectionMenu"
[bitMenuTriggerFor]="editCollectionMenu" size="small"
size="small" type="button"
type="button" ></button>
></button> <bit-menu #editCollectionMenu>
<bit-menu #editCollectionMenu> <ng-container *ngIf="canEditCollection">
<ng-container *ngIf="canEditCollection"> <button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, false)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "editInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, false)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="!canEditCollection && canViewCollectionInfo">
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, true)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "viewInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, true)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "viewAccess" | i18n }}
</button>
</ng-container>
<button <button
type="button" type="button"
*ngIf="canDeleteCollection"
bitMenuItem bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, false)" (click)="deleteCollection()"
> >
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> <span class="tw-text-danger">
{{ "editInfo" | i18n }} <i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button> </button>
<button </bit-menu>
type="button" </ng-container>
bitMenuItem }
(click)="editCollection(CollectionDialogTabType.Access, false)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "access" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="!canEditCollection && canViewCollectionInfo">
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Info, true)"
>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "viewInfo" | i18n }}
</button>
<button
type="button"
bitMenuItem
(click)="editCollection(CollectionDialogTabType.Access, true)"
>
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "viewAccess" | i18n }}
</button>
</ng-container>
<button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</ng-container>
<small *ngIf="loading"> <small *ngIf="loading">
<i <i
class="bwi bwi-spinner bwi-spin tw-text-muted" class="bwi bwi-spinner bwi-spin tw-text-muted"

View File

@@ -1,14 +1,15 @@
<td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit"> <td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit">
<input @if (this.canEditCollection || this.canDeleteCollection) {
type="checkbox" <input
bitCheckbox type="checkbox"
appStopProp bitCheckbox
*ngIf="showCheckbox" appStopProp
[disabled]="disabled" [disabled]="disabled"
[checked]="checked" [checked]="checked"
(change)="$event ? this.checkedToggled.next() : null" (change)="$event ? this.checkedToggled.next() : null"
[attr.aria-label]="'collectionItemSelect' | i18n" [attr.aria-label]="'collectionItemSelect' | i18n"
/> />
}
</td> </td>
<td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit"> <td bitCell [ngClass]="RowHeightClass" class="tw-min-w-fit">
<div aria-hidden="true"> <div aria-hidden="true">
@@ -57,16 +58,17 @@
</p> </p>
</td> </td>
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right"> <td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
<button @if (canEditCollection || canDeleteCollection || canViewCollectionInfo) {
*ngIf="canEditCollection || canDeleteCollection || canViewCollectionInfo" <button
[disabled]="disabled" [disabled]="disabled"
[bitMenuTriggerFor]="collectionOptions" [bitMenuTriggerFor]="collectionOptions"
size="small" size="small"
bitIconButton="bwi-ellipsis-v" bitIconButton="bwi-ellipsis-v"
type="button" type="button"
appA11yTitle="{{ 'options' | i18n }}" appA11yTitle="{{ 'options' | i18n }}"
appStopProp appStopProp
></button> ></button>
}
<bit-menu #collectionOptions> <bit-menu #collectionOptions>
<ng-container *ngIf="canEditCollection"> <ng-container *ngIf="canEditCollection">
<button type="button" bitMenuItem (click)="edit(false)"> <button type="button" bitMenuItem (click)="edit(false)">

View File

@@ -105,12 +105,4 @@ export class VaultCollectionRowComponent<C extends CipherViewLike> {
protected deleteCollection() { protected deleteCollection() {
this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] }); this.onEvent.next({ type: "delete", items: [{ collection: this.collection }] });
} }
protected get showCheckbox() {
if (this.collection?.id === Unassigned) {
return false; // Never show checkbox for Unassigned
}
return this.canEditCollection || this.canDeleteCollection;
}
} }

View File

@@ -22,41 +22,48 @@
</bit-breadcrumbs> </bit-breadcrumbs>
<ng-container slot="title-suffix"> <ng-container slot="title-suffix">
<ng-container *ngIf="collection != null && (canEditCollection || canDeleteCollection)"> @if (collection != null && (canEditCollection || canDeleteCollection)) {
<button <ng-container>
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"
size="small"
type="button"
aria-haspopup="true"
></button>
<bit-menu #editCollectionMenu>
<button <button
bitIconButton="bwi-angle-down"
[bitMenuTriggerFor]="editCollectionMenu"
size="small"
type="button" type="button"
*ngIf="canEditCollection" aria-haspopup="true"
bitMenuItem ></button>
(click)="editCollection(CollectionDialogTabType.Info)" <bit-menu #editCollectionMenu>
> <button
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i> type="button"
{{ "editInfo" | i18n }} *ngIf="canEditCollection"
</button> bitMenuItem
<button (click)="editCollection(CollectionDialogTabType.Info)"
type="button" >
*ngIf="canEditCollection" <i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
bitMenuItem {{ "editInfo" | i18n }}
(click)="editCollection(CollectionDialogTabType.Access)" </button>
> <button
<i class="bwi bwi-fw bwi-users" aria-hidden="true"></i> type="button"
{{ "access" | i18n }} *ngIf="canEditCollection"
</button> bitMenuItem
<button type="button" *ngIf="canDeleteCollection" bitMenuItem (click)="deleteCollection()"> (click)="editCollection(CollectionDialogTabType.Access)"
<span class="tw-text-danger"> >
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-users" aria-hidden="true"></i>
{{ "delete" | i18n }} {{ "access" | i18n }}
</span> </button>
</button> <button
</bit-menu> type="button"
</ng-container> *ngIf="canDeleteCollection"
bitMenuItem
(click)="deleteCollection()"
>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</ng-container>
}
<small *ngIf="loading"> <small *ngIf="loading">
<i <i
class="bwi bwi-spinner bwi-spin tw-text-muted" class="bwi bwi-spinner bwi-spin tw-text-muted"

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core"; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
@@ -60,10 +58,10 @@ export class VaultHeaderComponent {
* Boolean to determine the loading state of the header. * Boolean to determine the loading state of the header.
* Shows a loading spinner if set to true * Shows a loading spinner if set to true
*/ */
@Input() loading: boolean; @Input() loading: boolean = true;
/** Current active filter */ /** Current active filter */
@Input() filter: RoutedVaultFilterModel; @Input() filter: RoutedVaultFilterModel | undefined;
/** All organizations that can be shown */ /** All organizations that can be shown */
@Input() organizations: Organization[] = []; @Input() organizations: Organization[] = [];
@@ -72,7 +70,7 @@ export class VaultHeaderComponent {
@Input() collection?: TreeNode<CollectionView>; @Input() collection?: TreeNode<CollectionView>;
/** Whether 'Collection' option is shown in the 'New' dropdown */ /** Whether 'Collection' option is shown in the 'New' dropdown */
@Input() canCreateCollections: boolean; @Input() canCreateCollections: boolean = false;
/** Emits an event when the new item button is clicked in the header */ /** Emits an event when the new item button is clicked in the header */
@Output() onAddCipher = new EventEmitter<CipherType | undefined>(); @Output() onAddCipher = new EventEmitter<CipherType | undefined>();
@@ -106,7 +104,7 @@ export class VaultHeaderComponent {
return this.collection.node.organizationId; return this.collection.node.organizationId;
} }
if (this.filter.organizationId !== undefined) { if (this.filter?.organizationId !== undefined) {
return this.filter.organizationId; return this.filter.organizationId;
} }
@@ -119,10 +117,14 @@ export class VaultHeaderComponent {
} }
protected get showBreadcrumbs() { protected get showBreadcrumbs() {
return this.filter.collectionId !== undefined && this.filter.collectionId !== All; return this.filter?.collectionId !== undefined && this.filter.collectionId !== All;
} }
protected get title() { protected get title() {
if (this.filter === undefined) {
return "";
}
if (this.filter.collectionId === Unassigned) { if (this.filter.collectionId === Unassigned) {
return this.i18nService.t("unassigned"); return this.i18nService.t("unassigned");
} }
@@ -144,7 +146,7 @@ export class VaultHeaderComponent {
} }
protected get icon() { protected get icon() {
return this.filter.collectionId && this.filter.collectionId !== All return this.filter?.collectionId && this.filter.collectionId !== All
? "bwi-collection-shared" ? "bwi-collection-shared"
: ""; : "";
} }

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CollectionAccessSelectionView } from "./collection-access-selection.view"; import { CollectionAccessSelectionView } from "./collection-access-selection.view";
@@ -16,12 +14,12 @@ export class CollectionAdminView extends CollectionView {
* Flag indicating the collection has no active user or group assigned to it with CanManage permissions * Flag indicating the collection has no active user or group assigned to it with CanManage permissions
* In this case, the collection can be managed by admins/owners or custom users with appropriate permissions * In this case, the collection can be managed by admins/owners or custom users with appropriate permissions
*/ */
unmanaged: boolean; unmanaged: boolean = false;
/** /**
* Flag indicating the user has been explicitly assigned to this Collection * Flag indicating the user has been explicitly assigned to this Collection
*/ */
assigned: boolean; assigned: boolean = false;
constructor(response?: CollectionAccessDetailsResponse) { constructor(response?: CollectionAccessDetailsResponse) {
super(response); super(response);
@@ -45,6 +43,10 @@ export class CollectionAdminView extends CollectionView {
* Returns true if the user can edit a collection (including user and group access) from the Admin Console. * Returns true if the user can edit a collection (including user and group access) from the Admin Console.
*/ */
override canEdit(org: Organization): boolean { override canEdit(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return ( return (
org?.canEditAnyCollection || org?.canEditAnyCollection ||
(this.unmanaged && org?.canEditUnmanagedCollections) || (this.unmanaged && org?.canEditUnmanagedCollections) ||
@@ -56,6 +58,10 @@ export class CollectionAdminView extends CollectionView {
* Returns true if the user can delete a collection from the Admin Console. * Returns true if the user can delete a collection from the Admin Console.
*/ */
override canDelete(org: Organization): boolean { override canDelete(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return org?.canDeleteAnyCollection || super.canDelete(org); return org?.canDeleteAnyCollection || super.canDelete(org);
} }
@@ -63,6 +69,10 @@ export class CollectionAdminView extends CollectionView {
* Whether the user can modify user access to this collection * Whether the user can modify user access to this collection
*/ */
canEditUserAccess(org: Organization): boolean { canEditUserAccess(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return ( return (
(org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org) (org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org)
); );
@@ -72,6 +82,10 @@ export class CollectionAdminView extends CollectionView {
* Whether the user can modify group access to this collection * Whether the user can modify group access to this collection
*/ */
canEditGroupAccess(org: Organization): boolean { canEditGroupAccess(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return ( return (
(org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) || (org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) ||
this.canEdit(org) this.canEdit(org)
@@ -82,11 +96,13 @@ export class CollectionAdminView extends CollectionView {
* Returns true if the user can view collection info and access in a read-only state from the Admin Console * Returns true if the user can view collection info and access in a read-only state from the Admin Console
*/ */
override canViewCollectionInfo(org: Organization | undefined): boolean { override canViewCollectionInfo(org: Organization | undefined): boolean {
if (this.isUnassignedCollection) { if (this.isUnassignedCollection || this.isDefaultCollection) {
return false; return false;
} }
const isAdmin = org?.isAdmin ?? false;
const permissions = org?.permissions.editAnyCollection ?? false;
return this.manage || org?.isAdmin || org?.permissions.editAnyCollection; return this.manage || isAdmin || permissions;
} }
/** /**

View File

@@ -1,27 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { View } from "@bitwarden/common/models/view/view"; import { View } from "@bitwarden/common/models/view/view";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node"; import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { Collection, CollectionType } from "./collection"; import { Collection, CollectionType, CollectionTypes } from "./collection";
import { CollectionAccessDetailsResponse } from "./collection.response"; import { CollectionAccessDetailsResponse } from "./collection.response";
export const NestingDelimiter = "/"; export const NestingDelimiter = "/";
export class CollectionView implements View, ITreeNodeObject { export class CollectionView implements View, ITreeNodeObject {
id: string = null; id: string | undefined;
organizationId: string = null; organizationId: string | undefined;
name: string = null; name: string | undefined;
externalId: string = null; externalId: string | undefined;
// readOnly applies to the items within a collection // readOnly applies to the items within a collection
readOnly: boolean = null; readOnly: boolean = false;
hidePasswords: boolean = null; hidePasswords: boolean = false;
manage: boolean = null; manage: boolean = false;
assigned: boolean = null; assigned: boolean = false;
type: CollectionType = null; type: CollectionType = CollectionTypes.SharedCollection;
constructor(c?: Collection | CollectionAccessDetailsResponse) { constructor(c?: Collection | CollectionAccessDetailsResponse) {
if (!c) { if (!c) {
@@ -57,7 +55,11 @@ export class CollectionView implements View, ITreeNodeObject {
* Returns true if the user can edit a collection (including user and group access) from the individual vault. * Returns true if the user can edit a collection (including user and group access) from the individual vault.
* Does not include admin permissions - see {@link CollectionAdminView.canEdit}. * Does not include admin permissions - see {@link CollectionAdminView.canEdit}.
*/ */
canEdit(org: Organization): boolean { canEdit(org: Organization | undefined): boolean {
if (this.isDefaultCollection) {
return false;
}
if (org != null && org.id !== this.organizationId) { if (org != null && org.id !== this.organizationId) {
throw new Error( throw new Error(
"Id of the organization provided does not match the org id of the collection.", "Id of the organization provided does not match the org id of the collection.",
@@ -71,7 +73,7 @@ export class CollectionView implements View, ITreeNodeObject {
* Returns true if the user can delete a collection from the individual vault. * Returns true if the user can delete a collection from the individual vault.
* Does not include admin permissions - see {@link CollectionAdminView.canDelete}. * Does not include admin permissions - see {@link CollectionAdminView.canDelete}.
*/ */
canDelete(org: Organization): boolean { canDelete(org: Organization | undefined): boolean {
if (org != null && org.id !== this.organizationId) { if (org != null && org.id !== this.organizationId) {
throw new Error( throw new Error(
"Id of the organization provided does not match the org id of the collection.", "Id of the organization provided does not match the org id of the collection.",
@@ -81,7 +83,7 @@ export class CollectionView implements View, ITreeNodeObject {
const canDeleteManagedCollections = !org?.limitCollectionDeletion || org.isAdmin; const canDeleteManagedCollections = !org?.limitCollectionDeletion || org.isAdmin;
// Only use individual permissions, not admin permissions // Only use individual permissions, not admin permissions
return canDeleteManagedCollections && this.manage; return canDeleteManagedCollections && this.manage && !this.isDefaultCollection;
} }
/** /**
@@ -94,4 +96,8 @@ export class CollectionView implements View, ITreeNodeObject {
static fromJSON(obj: Jsonify<CollectionView>) { static fromJSON(obj: Jsonify<CollectionView>) {
return Object.assign(new CollectionView(new Collection()), obj); return Object.assign(new CollectionView(new Collection()), obj);
} }
get isDefaultCollection() {
return this.type == CollectionTypes.DefaultUserCollection;
}
} }

View File

@@ -42,7 +42,7 @@ export class EmptyVaultNudgeService extends DefaultSingleNudgeService {
const orgIds = new Set(orgs.map((org) => org.id)); const orgIds = new Set(orgs.map((org) => org.id));
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections); const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
const hasManageCollections = collections.some( const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId), (c) => c.manage && orgIds.has(c.organizationId!),
); );
// When the user has dismissed the nudge or spotlight, return the nudge status directly // When the user has dismissed the nudge or spotlight, return the nudge status directly

View File

@@ -46,7 +46,7 @@ export class VaultSettingsImportNudgeService extends DefaultSingleNudgeService {
const orgIds = new Set(orgs.map((org) => org.id)); const orgIds = new Set(orgs.map((org) => org.id));
const canCreateCollections = orgs.some((org) => org.canCreateNewCollections); const canCreateCollections = orgs.some((org) => org.canCreateNewCollections);
const hasManageCollections = collections.some( const hasManageCollections = collections.some(
(c) => c.manage && orgIds.has(c.organizationId), (c) => c.manage && orgIds.has(c.organizationId!),
); );
// When the user has dismissed the nudge or spotlight, return the nudge status directly // When the user has dismissed the nudge or spotlight, return the nudge status directly

View File

@@ -191,6 +191,9 @@ export function sortDefaultCollections(
.sort((a, b) => { .sort((a, b) => {
const aName = orgs.find((o) => o.id === a.organizationId)?.name ?? a.organizationId; const aName = orgs.find((o) => o.id === a.organizationId)?.name ?? a.organizationId;
const bName = orgs.find((o) => o.id === b.organizationId)?.name ?? b.organizationId; const bName = orgs.find((o) => o.id === b.organizationId)?.name ?? b.organizationId;
if (!aName || !bName) {
throw new Error("Collection does not have an organizationId.");
}
return collator.compare(aName, bName); return collator.compare(aName, bName);
}); });
return [ return [

View File

@@ -16,6 +16,6 @@ export class TreeNode<T extends ITreeNodeObject> {
} }
export interface ITreeNodeObject { export interface ITreeNodeObject {
id: string; id: string | undefined;
name: string; name: string | undefined;
} }

View File

@@ -279,7 +279,7 @@ export abstract class BaseImporter {
result.collections = result.folders.map((f) => { result.collections = result.folders.map((f) => {
const collection = new CollectionView(); const collection = new CollectionView();
collection.name = f.name; collection.name = f.name;
collection.id = f.id; collection.id = f.id ?? undefined; // folder id may be null, which is not suitable for collections.
return collection; return collection;
}); });
result.folderRelationships = []; result.folderRelationships = [];

View File

@@ -31,7 +31,7 @@ const createMockCollection = (
organizationId: string, organizationId: string,
readOnly = false, readOnly = false,
canEdit = true, canEdit = true,
) => { ): CollectionView => {
return { return {
id, id,
name, name,
@@ -42,6 +42,7 @@ const createMockCollection = (
manage: true, manage: true,
assigned: true, assigned: true,
type: CollectionTypes.DefaultUserCollection, type: CollectionTypes.DefaultUserCollection,
isDefaultCollection: true,
canEditItems: jest.fn().mockReturnValue(canEdit), canEditItems: jest.fn().mockReturnValue(canEdit),
canEdit: jest.fn(), canEdit: jest.fn(),
canDelete: jest.fn(), canDelete: jest.fn(),