mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
adding permission check for creation and delete of links
This commit is contained in:
@@ -24,6 +24,10 @@ const mockDayNotesState = vi.hoisted(() => ({
|
|||||||
moveNote: vi.fn(),
|
moveNote: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const mockPermissionsState = vi.hoisted(() => ({
|
||||||
|
canDo: true,
|
||||||
|
}))
|
||||||
|
|
||||||
// ── Module mocks ────────────────────────────────────────────────────────────
|
// ── Module mocks ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
vi.mock('../../api/client', async (importOriginal) => {
|
vi.mock('../../api/client', async (importOriginal) => {
|
||||||
@@ -79,7 +83,7 @@ vi.mock('../../store/permissionsStore', async (importOriginal) => {
|
|||||||
const actual = await importOriginal() as any
|
const actual = await importOriginal() as any
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useCanDo: () => () => true,
|
useCanDo: () => () => mockPermissionsState.canDo,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,6 +129,7 @@ beforeEach(() => {
|
|||||||
// Reset mutable day-notes state
|
// Reset mutable day-notes state
|
||||||
mockDayNotesState.noteUi = {}
|
mockDayNotesState.noteUi = {}
|
||||||
mockDayNotesState.dayNotes = {}
|
mockDayNotesState.dayNotes = {}
|
||||||
|
mockPermissionsState.canDo = true
|
||||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) })
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as any)
|
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius' } } as any)
|
||||||
@@ -1007,6 +1012,25 @@ describe('DayPlanSidebar', () => {
|
|||||||
revokeObjURL.mockRestore()
|
revokeObjURL.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYPLAN-099: ICS dialog hides delete link button without share_manage permission', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
mockPermissionsState.canDo = false
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ token: 'existing-token' }),
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
render(<DayPlanSidebar {...makeDefaultProps()} />)
|
||||||
|
await user.click(screen.getByText('ICS').closest('button')!)
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/subscribe.ics', expect.any(Object)))
|
||||||
|
expect(await screen.findByDisplayValue(`${window.location.origin}/api/shared/existing-token/calendar.ics`)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Delete link' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fetchSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
// ── openAddNote button click ──────────────────────────────────────────
|
// ── openAddNote button click ──────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-DAYPLAN-059: clicking Add Note button calls openAddNote', async () => {
|
it('FE-PLANNER-DAYPLAN-059: clicking Add Note button calls openAddNote', async () => {
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const tripActions = useRef(useTripStore.getState()).current
|
const tripActions = useRef(useTripStore.getState()).current
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const canEditDays = can('day_edit', trip)
|
const canEditDays = can('day_edit', trip)
|
||||||
|
const canManageShare = can('share_manage', trip)
|
||||||
|
|
||||||
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||||
|
|
||||||
@@ -2270,23 +2271,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
{icsCopied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
{icsCopied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleIcsDeleteLink} style={{
|
{canManageShare && (
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
<button onClick={handleIcsDeleteLink} style={{
|
||||||
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
|
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
|
||||||
}}>
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
<Trash2 size={11} /> {t('dayplan.calendarDeleteLink')}
|
}}>
|
||||||
</button>
|
<Trash2 size={11} /> {t('dayplan.calendarDeleteLink')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={handleIcsCreateLink} style={{
|
<button onClick={handleIcsCreateLink}
|
||||||
|
disabled={!canManageShare}
|
||||||
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
||||||
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
}}>
|
}}>
|
||||||
<Link2 size={12} /> {t('dayplan.calendarCreateLink')}
|
{canManageShare ? <><Link2 size={12} /> {t('dayplan.calendarCreateLink')}</> : <>{t('dayplan.calendarCreateLinkNoPermission')}</>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dayplan.calendarShareTitle': 'Calendar share',
|
'dayplan.calendarShareTitle': 'Calendar share',
|
||||||
'dayplan.calendarShareDescription': 'Create a subscription link for calendar apps, or download the ICS file.',
|
'dayplan.calendarShareDescription': 'Create a subscription link for calendar apps, or download the ICS file.',
|
||||||
'dayplan.calendarCreateLink': 'Create link',
|
'dayplan.calendarCreateLink': 'Create link',
|
||||||
|
'dayplan.calendarCreateLinkNoPermission': 'You do not have permission to create calendar links for this trip.',
|
||||||
'dayplan.calendarDeleteLink': 'Delete link',
|
'dayplan.calendarDeleteLink': 'Delete link',
|
||||||
'dayplan.calendarDownloadFile': 'Download ICS file',
|
'dayplan.calendarDownloadFile': 'Download ICS file',
|
||||||
'dayplan.calendarLinkFailed': 'Calendar link failed',
|
'dayplan.calendarLinkFailed': 'Calendar link failed',
|
||||||
|
|||||||
@@ -382,9 +382,13 @@ router.get('/:id/subscribe.ics', authenticate, (req: Request, res: Response) =>
|
|||||||
|
|
||||||
router.post('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
|
router.post('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (!canAccessTrip(req.params.id, authReq.user.id)) {
|
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||||
|
if (!access) {
|
||||||
return res.status(404).json({ error: 'Trip not found' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
}
|
}
|
||||||
|
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) {
|
||||||
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
}
|
||||||
|
|
||||||
const result = createOrUpdateCalendarShareLink(req.params.id, authReq.user.id);
|
const result = createOrUpdateCalendarShareLink(req.params.id, authReq.user.id);
|
||||||
const host = req.get('host');
|
const host = req.get('host');
|
||||||
@@ -404,6 +408,9 @@ router.delete('/:id/subscribe.ics', authenticate, (req: Request, res: Response)
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||||
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
if (!access) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) {
|
||||||
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
}
|
||||||
|
|
||||||
deleteCalendarShareLink(req.params.id);
|
deleteCalendarShareLink(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -921,6 +921,38 @@ describe('ICS export', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('TRIP-025 — member without share_manage cannot create subscribe link → 403', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
||||||
|
.set('Host', 'trek.example.com')
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRIP-025 — member without share_manage cannot delete subscribe link → 403', async () => {
|
||||||
|
const { user: owner } = createUser(testDb);
|
||||||
|
const { user: member } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, owner.id, { title: 'Shared Trip' });
|
||||||
|
addTripMember(testDb, trip.id, member.id);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/subscribe.ics`)
|
||||||
|
.set('Host', 'trek.example.com')
|
||||||
|
.set('Cookie', authCookie(owner.id));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/trips/${trip.id}/subscribe.ics`)
|
||||||
|
.set('Cookie', authCookie(member.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user