Skip to content
Merged
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
29 changes: 27 additions & 2 deletions src_assets/common/assets/web/Apps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
:aria-label="$t('_common.close')"></button>
</div>
<div class="modal-body">
<!-- Error -->
<div v-if="editFormError" class="alert alert-danger py-2 small">{{ editFormError }}</div>
<!-- Application Name -->
<div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.app_name') }}</label>
Expand Down Expand Up @@ -306,7 +308,7 @@
<input type="text" class="form-control monospace" id="appImagePath" aria-describedby="appImagePathHelp"
v-model="editForm['image-path']" />
<button class="btn btn-secondary" type="button"
@click="browseFor('file', 'file_browser.select_file', editForm['image-path'], v => editForm['image-path'] = v)">
@click="browseFor('file', 'file_browser.select_file', editForm['image-path'], v => editForm['image-path'] = v, ['.png'])">
<folder-open :size="18" class="icon"></folder-open>
</button>
<button class="btn btn-secondary" type="button" @click="showCoverFinder">
Expand Down Expand Up @@ -682,6 +684,7 @@
return {
apps: [],
editForm: null,
editFormError: "",
detachedCmd: "",
coverSearching: false,
coverFinderBusy: false,
Expand All @@ -691,6 +694,7 @@
fileBrowserType: "any",
fileBrowserTitle: "",
fileBrowserCallback: null,
fileBrowserAcceptedExtensions: null,
fileBrowserCurrentPath: "",
fileBrowserParentPath: "",
fileBrowserEntries: [],
Expand Down Expand Up @@ -802,6 +806,7 @@
detached: [],
"image-path": ""
};
this.editFormError = "";
this.openEditModal();
},
editApp(id) {
Expand All @@ -825,6 +830,7 @@
if (this.editForm["exit-timeout"] === undefined) {
this.editForm["exit-timeout"] = 5;
}
this.editFormError = "";
this.openEditModal();
},
showDeleteModal(id) {
Expand Down Expand Up @@ -920,10 +926,11 @@
})
.finally(() => this.coverFinderBusy = false);
},
browseFor(type, titleKey, startPath, callback) {
browseFor(type, titleKey, startPath, callback, acceptedExtensions = null) {
this.fileBrowserType = type;
this.fileBrowserTitle = this.$t(titleKey);
this.fileBrowserCallback = callback;
this.fileBrowserAcceptedExtensions = acceptedExtensions;
this.fileBrowserSelectedPath = startPath || '';
this.fileBrowserTypedPath = startPath || '';
this.fileBrowserError = '';
Expand All @@ -937,6 +944,14 @@
fileBrowserConfirm() {
const path = this.fileBrowserSelectedPath || this.fileBrowserTypedPath;
if (path) {
if (this.fileBrowserAcceptedExtensions && this.fileBrowserType !== 'directory') {
const lowerPath = path.toLowerCase();
const isValid = this.fileBrowserAcceptedExtensions.some(ext => lowerPath.endsWith(ext.toLowerCase()));
if (!isValid) {
this.fileBrowserError = this.$t('file_browser.error_invalid_extension', { extensions: this.fileBrowserAcceptedExtensions.join(', ') });
return;
}
}
if (this.fileBrowserCallback) {
this.fileBrowserCallback(path);
this.fileBrowserCallback = null;
Expand Down Expand Up @@ -997,7 +1012,17 @@
});
},
save() {
this.editFormError = "";
this.editForm["image-path"] = this.editForm["image-path"].toString().replaceAll('"', '');

const imagePath = this.editForm["image-path"];
if (imagePath && !imagePath.toLowerCase().endsWith('.png')) {
this.editFormError = this.$t('file_browser.error_invalid_extension', { extensions: '.png' });
const modalBody = this.$refs.editModal.querySelector('.modal-body');
if (modalBody) modalBody.scrollTop = 0;
return;
}

apiFetch("./api/apps", {
method: "POST",
headers: {
Expand Down
1 change: 1 addition & 0 deletions src_assets/common/assets/web/public/assets/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,7 @@
},
"file_browser": {
"empty": "No items to display",
"error_invalid_extension": "Selected file must have one of the following extensions: {extensions}",
"root": "Root",
"select": "Select",
"select_directory": "Select Directory",
Expand Down
174 changes: 174 additions & 0 deletions tests/web/Apps.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('../../src_assets/common/assets/web/Navbar.vue', () => ({
default: { template: '<div />' },
}))
vi.mock('../../src_assets/common/assets/web/fetch_utils', () => ({
apiFetch: vi.fn(() => Promise.resolve({ status: 200 })),
}))

import { apiFetch } from '../../src_assets/common/assets/web/fetch_utils'
import Apps from '../../src_assets/common/assets/web/Apps.vue'

beforeEach(() => vi.clearAllMocks())

/**
* Build a minimal context object that satisfies
* the `this` contract of fileBrowserConfirm().
*
* @param {string} path Selected file path.
* @param {string[]|null} acceptedExtensions Allowed extensions or null.
* @param {string} type Browser type (e.g. 'file', 'directory').
* @returns {object} A stub context for `.call()`.
*/
function browserContext(path, acceptedExtensions, type = 'file') {
return {
fileBrowserSelectedPath: path,
fileBrowserTypedPath: '',
fileBrowserAcceptedExtensions: acceptedExtensions,
fileBrowserType: type,
fileBrowserError: '',
fileBrowserCallback: vi.fn(),
fileBrowserClose: vi.fn(),
$t: (key, _params) => key,
}
}

/**
* Build a minimal context object that satisfies
* the `this` contract of save().
*
* @param {string} imagePath The image-path value in the edit form.
* @returns {object} A stub context for `.call()`.
*/
function saveContext(imagePath) {
const modalBody = { scrollTop: 100 }
return {
editForm: { 'image-path': imagePath },
editFormError: '',
$refs: { editModal: { querySelector: () => modalBody } },
$t: (key, _params) => key,
_modalBody: modalBody,
}
}

describe('fileBrowserConfirm – extension validation', () => {
it('rejects a non-PNG file when acceptedExtensions is [".png"]', () => {
const ctx = browserContext('/covers/cover.jpg', ['.png'])
const onSelect = ctx.fileBrowserCallback

Apps.methods.fileBrowserConfirm.call(ctx)

expect(ctx.fileBrowserError).toBe('file_browser.error_invalid_extension')
expect(onSelect).not.toHaveBeenCalled()
expect(ctx.fileBrowserClose).not.toHaveBeenCalled()
})

it('accepts a PNG with mixed-case extension', () => {
const ctx = browserContext('/covers/cover.PNG', ['.png'])
const onSelect = ctx.fileBrowserCallback

Apps.methods.fileBrowserConfirm.call(ctx)

expect(ctx.fileBrowserError).toBe('')
expect(onSelect).toHaveBeenCalledWith('/covers/cover.PNG')
expect(ctx.fileBrowserClose).toHaveBeenCalledOnce()
})

it('allows any extension when acceptedExtensions is null', () => {
const ctx = browserContext('/output/log.txt', null)
const onSelect = ctx.fileBrowserCallback

Apps.methods.fileBrowserConfirm.call(ctx)

expect(ctx.fileBrowserError).toBe('')
expect(onSelect).toHaveBeenCalledWith('/output/log.txt')
expect(ctx.fileBrowserClose).toHaveBeenCalledOnce()
})

it('skips extension check for directory type even with acceptedExtensions', () => {
const ctx = browserContext('/some/directory', ['.png'], 'directory')
const onSelect = ctx.fileBrowserCallback

Apps.methods.fileBrowserConfirm.call(ctx)

expect(ctx.fileBrowserError).toBe('')
expect(onSelect).toHaveBeenCalledWith('/some/directory')
expect(ctx.fileBrowserClose).toHaveBeenCalledOnce()
})

it('does nothing when no path is selected', () => {
const ctx = browserContext('', ['.png'])
const onSelect = ctx.fileBrowserCallback

Apps.methods.fileBrowserConfirm.call(ctx)

expect(ctx.fileBrowserError).toBe('')
expect(onSelect).not.toHaveBeenCalled()
expect(ctx.fileBrowserClose).not.toHaveBeenCalled()
})

it('uses fileBrowserTypedPath as fallback when selectedPath is empty', () => {
const ctx = browserContext('', ['.png'])
ctx.fileBrowserTypedPath = '/covers/art.png'
const onSelect = ctx.fileBrowserCallback

Apps.methods.fileBrowserConfirm.call(ctx)

expect(onSelect).toHaveBeenCalledWith('/covers/art.png')
expect(ctx.fileBrowserClose).toHaveBeenCalledOnce()
})
})

describe('save – cover image validation', () => {
it('blocks a manually typed non-PNG image path', () => {
const ctx = saveContext('/covers/cover.jpg')

Apps.methods.save.call(ctx)

expect(ctx.editFormError).toBe('file_browser.error_invalid_extension')
expect(ctx._modalBody.scrollTop).toBe(0)
expect(apiFetch).not.toHaveBeenCalled()
})

it('blocks a .bmp image path', () => {
const ctx = saveContext('/covers/image.bmp')

Apps.methods.save.call(ctx)

expect(ctx.editFormError).toBe('file_browser.error_invalid_extension')
expect(apiFetch).not.toHaveBeenCalled()
})

it('accepts a valid .PNG path (mixed case) and submits', () => {
const ctx = saveContext('/covers/cover.PnG')

Apps.methods.save.call(ctx)

expect(ctx.editFormError).toBe('')
expect(apiFetch).toHaveBeenCalledOnce()
expect(apiFetch).toHaveBeenCalledWith(
'./api/apps',
expect.objectContaining({ method: 'POST' })
)
})

it('submits when the image path is empty', () => {
const ctx = saveContext('')

Apps.methods.save.call(ctx)

expect(ctx.editFormError).toBe('')
expect(apiFetch).toHaveBeenCalledOnce()
})

it('strips double quotes from the image path before validating', () => {
const ctx = saveContext('""/covers/cover.png""')

Apps.methods.save.call(ctx)

expect(ctx.editForm['image-path']).toBe('/covers/cover.png')
expect(ctx.editFormError).toBe('')
expect(apiFetch).toHaveBeenCalledOnce()
})
})
Loading