mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
c1403f8bd6
* feat(now-playing): liveness indicator dot in the listening popover Replace the raw "Nm ago" line in the "Who is listening?" popover with a derived presence dot (green playing / amber paused / dim idle). The presence is computed in one tested helper that unifies the playbackReport transport state with the legacy getNowPlaying recency, instead of formatting a raw timestamp inline. The dot carries the localized status as an aria-label and tooltip so it is not conveyed by colour alone. * feat(radio): gate station create/edit/delete behind Navidrome admin role Navidrome >= 0.62 restricts internet-radio management to admins (GHSA-jw24-qqrj-633c); non-admin requests fail. Hide Add Station, Search Directory, the per-card edit chip and delete button for confirmed standard Navidrome users via a canManageNavidromeRadio() helper on the existing useNavidromeAdminRole framework. Admins, non-Navidrome servers and transient states stay unrestricted; playback and favourites remain available to all. * docs(changelog): now-playing status dot + admin-gated radio (#1086)
127 lines
4.0 KiB
TypeScript
127 lines
4.0 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { renderHook, waitFor } from '@testing-library/react';
|
|
import { resetAuthStore } from '@/test/helpers/storeReset';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
|
|
vi.mock('@/api/navidromeAdmin', () => ({
|
|
ndLogin: vi.fn(),
|
|
}));
|
|
|
|
import { ndLogin } from '@/api/navidromeAdmin';
|
|
import { useNavidromeAdminRole, canManageNavidromeRadio } from './useNavidromeAdminRole';
|
|
|
|
beforeEach(() => {
|
|
resetAuthStore();
|
|
vi.mocked(ndLogin).mockReset();
|
|
});
|
|
|
|
function seedNavidromeServer(): string {
|
|
const id = useAuthStore.getState().addServer({
|
|
name: 'Home',
|
|
url: 'music.example.com',
|
|
username: 'tester',
|
|
password: 'pw',
|
|
});
|
|
useAuthStore.getState().setActiveServer(id);
|
|
useAuthStore.getState().setLoggedIn(true);
|
|
useAuthStore.getState().setSubsonicServerIdentity(id, {
|
|
type: 'navidrome',
|
|
serverVersion: '0.62.0',
|
|
openSubsonic: true,
|
|
});
|
|
return id;
|
|
}
|
|
|
|
describe('useNavidromeAdminRole', () => {
|
|
it('returns na when not logged in', () => {
|
|
const id = useAuthStore.getState().addServer({
|
|
name: 'Home',
|
|
url: 'https://music.example.com',
|
|
username: 'tester',
|
|
password: 'pw',
|
|
});
|
|
useAuthStore.getState().setActiveServer(id);
|
|
useAuthStore.getState().setSubsonicServerIdentity(id, {
|
|
type: 'navidrome',
|
|
serverVersion: '0.62.0',
|
|
openSubsonic: true,
|
|
});
|
|
|
|
const { result } = renderHook(() => useNavidromeAdminRole());
|
|
expect(result.current).toBe('na');
|
|
expect(ndLogin).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns checking until server identity is known', () => {
|
|
const id = useAuthStore.getState().addServer({
|
|
name: 'Home',
|
|
url: 'https://music.example.com',
|
|
username: 'tester',
|
|
password: 'pw',
|
|
});
|
|
useAuthStore.getState().setActiveServer(id);
|
|
useAuthStore.getState().setLoggedIn(true);
|
|
|
|
const { result } = renderHook(() => useNavidromeAdminRole());
|
|
expect(result.current).toBe('checking');
|
|
expect(ndLogin).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns na for non-Navidrome servers', () => {
|
|
const id = useAuthStore.getState().addServer({
|
|
name: 'Ampache',
|
|
url: 'https://music.example.com',
|
|
username: 'tester',
|
|
password: 'pw',
|
|
});
|
|
useAuthStore.getState().setActiveServer(id);
|
|
useAuthStore.getState().setLoggedIn(true);
|
|
useAuthStore.getState().setSubsonicServerIdentity(id, {
|
|
type: 'ampache',
|
|
serverVersion: '6.0.0',
|
|
openSubsonic: false,
|
|
});
|
|
|
|
const { result } = renderHook(() => useNavidromeAdminRole());
|
|
expect(result.current).toBe('na');
|
|
expect(ndLogin).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('probes Navidrome native login and reports admin', async () => {
|
|
seedNavidromeServer();
|
|
vi.mocked(ndLogin).mockResolvedValue({ token: 't', userId: '1', isAdmin: true });
|
|
|
|
const { result } = renderHook(() => useNavidromeAdminRole());
|
|
await waitFor(() => expect(result.current).toBe('admin'));
|
|
expect(ndLogin).toHaveBeenCalledWith('http://music.example.com', 'tester', 'pw');
|
|
});
|
|
|
|
it('probes Navidrome native login and reports standard user', async () => {
|
|
seedNavidromeServer();
|
|
vi.mocked(ndLogin).mockResolvedValue({ token: 't', userId: '2', isAdmin: false });
|
|
|
|
const { result } = renderHook(() => useNavidromeAdminRole());
|
|
await waitFor(() => expect(result.current).toBe('user'));
|
|
});
|
|
|
|
it('returns error when native login fails', async () => {
|
|
seedNavidromeServer();
|
|
vi.mocked(ndLogin).mockRejectedValue(new Error('denied'));
|
|
|
|
const { result } = renderHook(() => useNavidromeAdminRole());
|
|
await waitFor(() => expect(result.current).toBe('error'));
|
|
});
|
|
});
|
|
|
|
describe('canManageNavidromeRadio', () => {
|
|
it('blocks only a confirmed standard Navidrome user', () => {
|
|
expect(canManageNavidromeRadio('user')).toBe(false);
|
|
});
|
|
|
|
it('allows admins, non-Navidrome servers, and transient/unknown states', () => {
|
|
for (const role of ['admin', 'na', 'idle', 'checking', 'error'] as const) {
|
|
expect(canManageNavidromeRadio(role)).toBe(true);
|
|
}
|
|
});
|
|
});
|