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
7 changes: 7 additions & 0 deletions fileglancer/apps/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,13 @@ def _validate_parameter_value(param: AppParameter, value, session=None) -> str:

if param.type in ("file", "directory"):
str_val = str_val.replace("\\", "/")
# Expand ~ so the path works inside shlex.quote() single quotes,
# where the shell would not perform tilde expansion.
# Use euid so this works when the server runs as root with seteuid.
if str_val.startswith("~/") or str_val == "~":
import pwd
home = pwd.getpwuid(os.geteuid()).pw_dir
str_val = home + str_val[1:]
if session is not None:
error = validate_path_in_filestore(str_val, session)
else:
Expand Down
80 changes: 60 additions & 20 deletions fileglancer/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,53 @@ def _clear_sharing_key_cache():
logger.debug(f"Cleared entire sharing key cache, removed {old_size} entries")


def _find_best_fsp_match(
fsps: list[FileSharePath],
normalized_input: str,
get_candidates: callable,
separator: str = "/",
) -> Optional[tuple[FileSharePath, str]]:
"""Find the FSP whose candidate path is the longest prefix of *normalized_input*.

Used by ``find_fsp_from_absolute_path`` to check filesystem-resolved mount paths.

Args:
fsps: All file share paths to search.
normalized_input: The input path, already normalised by the caller.
get_candidates: ``fn(fsp) -> list[str | None]`` returning the candidate
prefix strings to test for each FSP.
separator: The path separator used for the boundary check (``/`` or
``os.sep``).

Returns:
``(best_fsp, subpath)`` for the longest match, or *None*.
"""
best_fsp: Optional[FileSharePath] = None
best_len = 0

for fsp in fsps:
for candidate in get_candidates(fsp):
if not candidate:
continue
if (
normalized_input.startswith(candidate)
and len(candidate) > best_len
):
rest = normalized_input[len(candidate):]
if rest == "" or rest.startswith(separator):
best_fsp = fsp
best_len = len(candidate)

if best_fsp is None:
return None

subpath = normalized_input[best_len:]
if subpath.startswith(separator):
subpath = subpath.lstrip(separator)

return (best_fsp, subpath)


def find_fsp_from_absolute_path(session: Session, absolute_path: str) -> Optional[tuple[FileSharePath, str]]:
"""
Find the file share path that exactly matches the given absolute path.
Expand All @@ -546,27 +593,20 @@ def find_fsp_from_absolute_path(session: Session, absolute_path: str) -> Optiona
# Get all file share paths
paths = get_file_share_paths(session)

# Pre-compute expanded mount paths so the helper can use them
expanded_mounts: dict[str, str] = {}
for fsp in paths:
# Expand ~ to user's home directory and resolve symlinks to match Filestore behavior
expanded_mount_path = os.path.expanduser(fsp.mount_path)
expanded_mount_path = os.path.realpath(expanded_mount_path)

# Check if the normalized path starts with this mount path
if normalized_path.startswith(expanded_mount_path):
# Calculate the relative subpath
if normalized_path == expanded_mount_path:
subpath = ""
logger.trace(f"Found exact match for path: {absolute_path} in fsp: {fsp.name} with subpath: {subpath}")
return (fsp, subpath)
else:
# Ensure we're matching on a directory boundary
remainder = normalized_path[len(expanded_mount_path):]
if remainder.startswith(os.sep):
subpath = remainder.lstrip(os.sep)
logger.trace(f"Found exact match for path: {absolute_path} in fsp: {fsp.name} with subpath: {subpath}")
return (fsp, subpath)

return None
expanded = os.path.expanduser(fsp.mount_path)
expanded_mounts[fsp.name] = os.path.realpath(expanded)

def _expanded_mount(fsp: FileSharePath):
return [expanded_mounts[fsp.name]]

result = _find_best_fsp_match(paths, normalized_path, _expanded_mount, separator=os.sep)
if result is not None:
fsp, subpath = result
logger.trace(f"Found exact match for path: {absolute_path} in fsp: {fsp.name} with subpath: {subpath}")
return result


def _validate_proxied_path(session: Session, fsp_name: str, path: str) -> None:
Expand Down
271 changes: 271 additions & 0 deletions frontend/src/__tests__/componentTests/FileSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';

import FileSelectorButton from '@/components/ui/FileSelector/FileSelectorButton';
import { render, screen, waitFor } from '@/__tests__/test-utils';
import { server } from '@/__tests__/mocks/node';

describe('FileSelector', () => {
const onSelect = vi.fn();

describe('Smoke tests', () => {
it('opens dialog when button is clicked', async () => {
const user = userEvent.setup();
render(<FileSelectorButton onSelect={onSelect} />, {
initialEntries: ['/browse']
});

await user.click(screen.getByRole('button', { name: /browse/i }));

await waitFor(() => {
expect(screen.getByText('Select File or Folder')).toBeInTheDocument();
});
});

it('displays zones at top level', async () => {
const user = userEvent.setup();
render(<FileSelectorButton onSelect={onSelect} />, {
initialEntries: ['/browse']
});

await user.click(screen.getByRole('button', { name: /browse/i }));

await waitFor(() => {
expect(screen.getByText('Zone1')).toBeInTheDocument();
});
expect(screen.getByText('Zone2')).toBeInTheDocument();
});

it('closes dialog when Cancel is clicked', async () => {
const user = userEvent.setup();
render(<FileSelectorButton onSelect={onSelect} />, {
initialEntries: ['/browse']
});

await user.click(screen.getByRole('button', { name: /browse/i }));

await waitFor(() => {
expect(screen.getByText('Select File or Folder')).toBeInTheDocument();
});

await user.click(screen.getByRole('button', { name: /cancel/i }));

await waitFor(() => {
expect(
screen.queryByText('Select File or Folder')
).not.toBeInTheDocument();
});
});
});

describe('Search functionality', () => {
beforeEach(async () => {
const user = userEvent.setup();
render(<FileSelectorButton onSelect={onSelect} />, {
initialEntries: ['/browse']
});

await user.click(screen.getByRole('button', { name: /browse/i }));

await waitFor(() => {
expect(screen.getByText('Zone1')).toBeInTheDocument();
});
});

it('shows search input at zones level', () => {
expect(screen.getByPlaceholderText('Type to filter')).toBeInTheDocument();
});

it('filters displayed zones by name when typing in search', async () => {
const user = userEvent.setup();

const searchInput = screen.getByPlaceholderText('Type to filter');
await user.click(searchInput);
await user.keyboard('1');

expect(screen.getByText('Zone1')).toBeInTheDocument();
expect(screen.queryByText('Zone2')).not.toBeInTheDocument();
});

it('restores full list when search is cleared', async () => {
const user = userEvent.setup();

const searchInput = screen.getByPlaceholderText('Type to filter');
await user.click(searchInput);
await user.keyboard('1');

expect(screen.queryByText('Zone2')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: /clear search/i }));

await waitFor(() => {
expect(screen.getByText('Zone1')).toBeInTheDocument();
expect(screen.getByText('Zone2')).toBeInTheDocument();
});
});

it('clears search when navigating into a zone', async () => {
const user = userEvent.setup();

const searchInput = screen.getByPlaceholderText('Type to filter');
await user.click(searchInput);
await user.keyboard('Zone');

expect(searchInput).toHaveValue('Zone');

// Double-click Zone1 to navigate into it
await user.dblClick(screen.getByText('Zone1'));

await waitFor(() => {
const input = screen.getByPlaceholderText('Type to filter');
expect(input).toHaveValue('');
});
});
});

describe('FSP-level search', () => {
it('filters FSPs by name when typing in search at zone level', async () => {
// Add a second FSP in Zone1 so we can test filtering
server.use(
http.get('/api/file-share-paths', () => {
return HttpResponse.json({
paths: [
{
name: 'test_fsp',
zone: 'Zone1',
group: 'group1',
storage: 'primary',
mount_path: '/test/fsp',
mac_path: 'smb://test/fsp',
windows_path: '\\\\test\\fsp',
linux_path: '/test/fsp'
},
{
name: 'alpha_fsp',
zone: 'Zone1',
group: 'group1',
storage: 'primary',
mount_path: '/alpha/fsp',
mac_path: 'smb://alpha/fsp',
windows_path: '\\\\alpha\\fsp',
linux_path: '/alpha/fsp'
}
]
});
})
);

const user = userEvent.setup();
render(<FileSelectorButton onSelect={onSelect} />, {
initialEntries: ['/browse']
});

await user.click(screen.getByRole('button', { name: /browse/i }));

await waitFor(() => {
expect(screen.getByText('Zone1')).toBeInTheDocument();
});

// Navigate into Zone1
await user.dblClick(screen.getByText('Zone1'));

await waitFor(() => {
expect(screen.getByText('/test/fsp')).toBeInTheDocument();
expect(screen.getByText('/alpha/fsp')).toBeInTheDocument();
});

// Filter by typing
const searchInput = screen.getByPlaceholderText('Type to filter');
await user.click(searchInput);
await user.keyboard('alpha');

expect(screen.getByText('/alpha/fsp')).toBeInTheDocument();
expect(screen.queryByText('/test/fsp')).not.toBeInTheDocument();
});
});

describe('Group filter status', () => {
it('hides status message when user has no groups', async () => {
const user = userEvent.setup();
render(<FileSelectorButton onSelect={onSelect} />, {
initialEntries: ['/browse']
});

await user.click(screen.getByRole('button', { name: /browse/i }));

await waitFor(() => {
expect(screen.getByText('Zone1')).toBeInTheDocument();
});

expect(
screen.queryByText('Viewing zones for your groups only')
).not.toBeInTheDocument();
expect(screen.queryByText('Viewing all zones')).not.toBeInTheDocument();
});

it('shows "Viewing zones for your groups only" when user has groups and isFilteredByGroups is true', async () => {
server.use(
http.get('/api/profile', () => {
return HttpResponse.json({
username: 'testuser',
groups: ['group1']
});
})
);

const user = userEvent.setup();
render(<FileSelectorButton onSelect={onSelect} />, {
initialEntries: ['/browse']
});

await user.click(screen.getByRole('button', { name: /browse/i }));

await waitFor(() => {
expect(
screen.getByText('Viewing zones for your groups only')
).toBeInTheDocument();
});
});

it('hides status message at filesystem level', async () => {
server.use(
http.get('/api/profile', () => {
return HttpResponse.json({
username: 'testuser',
groups: ['group1']
});
})
);

const user = userEvent.setup();
render(<FileSelectorButton onSelect={onSelect} />, {
initialEntries: ['/browse']
});

await user.click(screen.getByRole('button', { name: /browse/i }));

await waitFor(() => {
expect(screen.getByText('Zone1')).toBeInTheDocument();
});

// Navigate into Zone1
await user.dblClick(screen.getByText('Zone1'));

// At zone level, FSPs are displayed using their preferred path format
await waitFor(() => {
expect(screen.getByText('/test/fsp')).toBeInTheDocument();
});

// Navigate into FSP
await user.dblClick(screen.getByText('/test/fsp'));

await waitFor(() => {
expect(
screen.queryByText('Viewing zones for your groups only')
).not.toBeInTheDocument();
expect(screen.queryByText('Viewing all zones')).not.toBeInTheDocument();
});
});
});
});
Loading
Loading