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
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { of, throwError } from 'rxjs';
import { Location } from '@angular/common';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { signal, WritableSignal } from '@angular/core';
import { signal, Signal, WritableSignal } from '@angular/core';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';

Expand Down Expand Up @@ -1285,6 +1285,16 @@ describe('DotContentDriveShellComponent', () => {
spectator.detectChanges();
};

it('should leave the upload restricted to nothing', () => {
// Content Drive shares the selector and the dropzone with the Asset Picker, which
// scopes uploads to the field that opened it (#37365). Content Drive has no such field:
// it must keep accepting every file type, with today's wording. An empty
// `restrictionLabel` is what keeps the default descriptions rendering.
openViaButton(TARGET_FOLDER_DATA);

expect(spectator.query(DotUploadTypeSelectorComponent).$restrictionLabel()).toBe('');
});

it('should open the upload menu with the selected folder when the upload button is clicked', () => {
openViaButton(TARGET_FOLDER_DATA);

Expand Down Expand Up @@ -2873,11 +2883,26 @@ describe('DotContentDriveShellComponent', () => {
url: string;
}) => void;

it('routes Back through the panel close guard (does not discard silently)', () => {
// The panel lives behind `@defer`, so the view child is not resolved synchronously —
// the tests stub the signal instead. `$sidePanel` is protected, so it is absent from
// the public type `jest.spyOn` infers its keys from; cast to the shape being stubbed.
const stubSidePanel = () => {
const requestClose = jest.fn();
jest.spyOn(spectator.component, '$sidePanel').mockReturnValue({

jest.spyOn(
spectator.component as unknown as {
$sidePanel: Signal<DotEditContentSidePanelComponent | undefined>;
},
'$sidePanel'
).mockReturnValue({
requestClose
} as unknown as DotEditContentSidePanelComponent);

return requestClose;
};

it('routes Back through the panel close guard (does not discard silently)', () => {
const requestClose = stubSidePanel();
setPanelRequest(EDIT_REQUEST);

getPopstateHandler()({ url: '/c/content-drive?path=/foo' });
Expand All @@ -2890,10 +2915,7 @@ describe('DotContentDriveShellComponent', () => {
});

it('keeps the panel open when Back preserves the same editContent param', () => {
const requestClose = jest.fn();
jest.spyOn(spectator.component, '$sidePanel').mockReturnValue({
requestClose
} as unknown as DotEditContentSidePanelComponent);
const requestClose = stubSidePanel();
setPanelRequest(EDIT_REQUEST);

getPopstateHandler()({ url: '/c/content-drive?editContent=id-1' });
Expand All @@ -2903,10 +2925,7 @@ describe('DotContentDriveShellComponent', () => {
});

it('routes Back through the guard for an open new-mode panel too (AC8)', () => {
const requestClose = jest.fn();
jest.spyOn(spectator.component, '$sidePanel').mockReturnValue({
requestClose
} as unknown as DotEditContentSidePanelComponent);
const requestClose = stubSidePanel();
setPanelRequest({ mode: 'new', contentTypeId: 'ct-1', title: 'New content' });

// Back removed the `new` marker entirely — the popstate handler must still close
Expand All @@ -2919,10 +2938,7 @@ describe('DotContentDriveShellComponent', () => {
});

it('keeps a new-mode panel open when Back preserves the editContent=new marker', () => {
const requestClose = jest.fn();
jest.spyOn(spectator.component, '$sidePanel').mockReturnValue({
requestClose
} as unknown as DotEditContentSidePanelComponent);
const requestClose = stubSidePanel();
setPanelRequest({ mode: 'new', contentTypeId: 'ct-1', title: 'New content' });

getPopstateHandler()({ url: '/c/content-drive?editContent=new' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
data-testid="asset-picker-upload-selector"
[targetFolder]="payload.targetFolder"
[files]="payload.files"
[restrictionLabel]="$uploadRestrictionLabel()"
(selectUploadType)="onUploadTypeSelected($event)" />
}
</ng-template>
Expand Down Expand Up @@ -124,6 +125,14 @@
<ng-container *ngTemplateOutlet="uploadSelectorTpl" />
</p-dialog>

<input type="file" (change)="onFileChange($event)" #fileInput hidden />
<!-- `[attr.accept]` so an unrestricted mode removes the attribute outright: `accept=""` is a
different thing to the browser. A hint only — the OS dialog lets the user override it, so
`#refuseDisallowedUpload` is what actually guarantees the restriction. -->
<input
type="file"
[attr.accept]="$uploadAccept()"
(change)="onFileChange($event)"
#fileInput
hidden />

<dot-toast position="top-center" />
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
DotDialogHeaderComponent
} from '../dot-dialog';
import { DotToastComponent } from '../dot-toast/dot-toast.component';
import { DotUploadTypeSelectorComponent } from '../dot-upload-type-selector/dot-upload-type-selector.component';

/**
* What every `overrideComponent({ set: { imports } })` below has to keep real.
Expand All @@ -61,7 +62,8 @@ const PICKER_REAL_IMPORTS = [
DotDialogComponent,
DotDialogHeaderComponent,
DotDialogContentComponent,
DotDialogFooterComponent
DotDialogFooterComponent,
DotUploadTypeSelectorComponent
];

const SITE: DotSite = {
Expand Down Expand Up @@ -187,7 +189,12 @@ describe('DotAssetPickerComponent', () => {
provide: DotMessageService,
useValue: new MockDotMessageService({
'dot.common.dialog.accept': 'Add',
'dot.common.dialog.reject': 'Cancel'
'dot.common.dialog.reject': 'Cancel',
'dot.asset.picker.upload.rejected': "Can't upload this file",
'dot.asset.picker.upload.rejected.detail': 'Only {0} can be uploaded here.',
'dot.asset.picker.upload.types.image': 'images',
'dot.asset.picker.upload.types.video': 'video files',
'dot.asset.picker.upload.types.audio': 'audio files'
})
},
{ provide: DynamicDialogConfig, useValue: { data: CONFIG } }
Expand Down Expand Up @@ -593,6 +600,206 @@ describe('DotAssetPickerComponent', () => {
expect(store.loadItems).not.toHaveBeenCalled();
});
});

describe('upload restriction', () => {
const messageService = () => spectator.inject(MessageService, true);

/** `new File()` defaults `type` to `''`, which the restriction deliberately allows. */
const fileList = (type: string, name = 'asset.bin'): FileList => {
const files = [new File([''], name, { type })] as unknown as FileList;
Object.defineProperty(files, 'length', { value: 1 });

return files;
};

/** Puts the picker in a media mode, the way an Image field opens it. */
const restrictToImages = () => {
store.config.set({ ...CONFIG, mimeTypes: ['image/*'] });
spectator.detectChanges();
};

describe('in a media mode', () => {
beforeEach(() => restrictToImages());

it('should refuse a dropped file outside the allowed types', () => {
const spyAdd = jest.spyOn(messageService(), 'add');

spectator.component['onRequestUpload']({
files: fileList('application/pdf', 'report.pdf'),
targetFolder: PINNED_FOLDER
});

expect(uploadService.uploadFileByBaseType).not.toHaveBeenCalled();
expect(spyAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'Only images can be uploaded here.'
})
);
});

it('should not open the Asset/File prompt for a refused drop', () => {
// Without the early gate the user is asked to choose a storage type and only then
// told the file was never eligible.
spectator.component['onRequestUpload']({
files: fileList('application/pdf', 'report.pdf')
});

expect(spectator.component.$uploadSelectorPayload()).toBeUndefined();
expect(spectator.component.$uploadModalVisible()).toBe(false);
});

it('should refuse a file chosen through the OS dialog', () => {
// `accept` is a hint the user can override from the dialog's own filter, so the
// pre-upload check has to stand on its own.
spectator.component.$activeSelection.set({
baseType: DotCMSBaseTypesContentTypes.DOTASSET
});

spectator.component['onFileChange']({
target: { files: fileList('application/pdf', 'report.pdf'), value: 'x' }
} as unknown as Event);

expect(uploadService.uploadFileByBaseType).not.toHaveBeenCalled();
});

it('should refuse a file after the Asset/File prompt is answered', () => {
spectator.component['onUploadTypeSelected']({
baseType: DotCMSBaseTypesContentTypes.DOTASSET,
files: fileList('application/pdf', 'report.pdf')
});

expect(uploadService.uploadFileByBaseType).not.toHaveBeenCalled();
});

it('should refuse a drop into a folder that pins a base type', () => {
// This route skips the prompt entirely — the one most easily left unguarded.
spectator.component['onRequestUpload']({
files: fileList('audio/mpeg', 'song.mp3'),
targetFolder: PINNED_FOLDER
});

expect(uploadService.uploadFileByBaseType).not.toHaveBeenCalled();
});

it('should refuse a button upload into a folder that pins a base type', () => {
store.selectedNode.set({ data: PINNED_FOLDER });
spectator.detectChanges();

spectator.component['onUpload'](new MouseEvent('click'));
spectator.component['onFileChange']({
target: { files: fileList('application/pdf', 'report.pdf'), value: 'x' }
} as unknown as Event);

expect(uploadService.uploadFileByBaseType).not.toHaveBeenCalled();
});

it('should allow a file whose type the browser does not report', () => {
// AC-010: the server stays the authority rather than blocking a file we cannot
// classify.
spectator.component['onRequestUpload']({
files: fileList('', 'mystery.dat'),
targetFolder: PINNED_FOLDER
});

expect(uploadService.uploadFileByBaseType).toHaveBeenCalled();
});

it('should upload an allowed file and refresh the list with the restriction intact', () => {
store.$request.set({ mimeTypes: ['image/*'] });

spectator.component['onRequestUpload']({
files: fileList('image/png', 'logo.png'),
targetFolder: PINNED_FOLDER
});

expect(uploadService.uploadFileByBaseType).toHaveBeenCalled();
expect(store.loadItems).toHaveBeenCalledWith({ mimeTypes: ['image/*'] });
});
});

describe('the hidden file input', () => {
const fileInput = () =>
spectator.query('input[type="file"]') as HTMLInputElement | null;

it('should filter the OS dialog to the restricted family', () => {
restrictToImages();

expect(fileInput()?.getAttribute('accept')).toBe('image/*');
});

it('should carry every pattern a browse caller asked for', () => {
store.config.set({ ...CONFIG, mimeTypes: ['image/*', 'video/*'] });
spectator.detectChanges();

expect(fileInput()?.getAttribute('accept')).toBe('image/*,video/*');
});

it('should carry no accept attribute at all when nothing is restricted', () => {
// Absence, not `accept=""` — an empty value is a different thing to the browser,
// and a test asserting `''` would pass against a broken implementation.
expect(fileInput()?.hasAttribute('accept')).toBe(false);
});
});

describe('the Asset/File prompt', () => {
const selector = () => spectator.query(DotUploadTypeSelectorComponent);

it('should hand the restriction label to the selector in a media mode', () => {
restrictToImages();

spectator.component['onUpload'](new MouseEvent('click'));
spectator.detectChanges();

expect(selector()?.$restrictionLabel()).toBe('images');
});

it('should hand the selector no label when nothing is restricted', () => {
spectator.component['onUpload'](new MouseEvent('click'));
spectator.detectChanges();

expect(selector()?.$restrictionLabel()).toBe('');
});
});

describe('in the File field, which restricts nothing', () => {
// The over-reach guard. CONFIG carries no `mimeTypes`, exactly as a File field opens.
it('should upload a dropped PDF', () => {
const spyAdd = jest.spyOn(messageService(), 'add');

spectator.component['onRequestUpload']({
files: fileList('application/pdf', 'report.pdf'),
targetFolder: PINNED_FOLDER
});

expect(uploadService.uploadFileByBaseType).toHaveBeenCalled();
expect(spyAdd).not.toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
);
});

it('should upload a PDF chosen through the OS dialog', () => {
spectator.component.$activeSelection.set({
baseType: DotCMSBaseTypesContentTypes.DOTASSET
});

spectator.component['onFileChange']({
target: { files: fileList('application/zip', 'bundle.zip'), value: 'x' }
} as unknown as Event);

expect(uploadService.uploadFileByBaseType).toHaveBeenCalled();
});

it('should upload a PDF after the Asset/File prompt is answered', () => {
spectator.component['onUploadTypeSelected']({
baseType: DotCMSBaseTypesContentTypes.FILEASSET,
files: fileList('application/pdf', 'report.pdf')
});

expect(uploadService.uploadFileByBaseType).toHaveBeenCalled();
});
});
});
});

describe('DotAssetPickerComponent — opened without dialog data', () => {
Expand Down
Loading
Loading