wip: tests and permission descriptions

This commit is contained in:
Nikolai Giman
2026-05-14 23:40:22 +02:00
parent a5446ea825
commit 0d01b4d532
5 changed files with 116 additions and 0 deletions
+11
View File
@@ -538,5 +538,16 @@
"Added compound indexes on tasks.time_entries (goal_id, started_at, user_id) and (goal_id, started_at, task_id) for time-tracking report aggregations",
"Added partial index on tasks.time_entries(goal_id, started_at) WHERE billable = TRUE for billable-only reports"
]
},
"43": {
"version": "1.50.0",
"name": "Release 1.50.0",
"releaseDate": "20260514",
"scripts": [
"/1.50.0/0.update-timetracking-permissions-descriptions.sql"
],
"description": [
"Updated descriptions of timetracking_can_view and timetracking_can_manage_all to document that these permissions also expose contributor emails through the time-entry log"
]
}
}
@@ -0,0 +1,11 @@
UPDATE tv_auth.permissions
SET
description = 'View all time entries on this project — both own and other members''. Also exposes the emails of all contributors via the time-entry log',
description_locales = '{"en": "View time entries. User can see all time entries on this project — both own and other members''. Also exposes the emails of all contributors who have logged time on this project. Does not grant logging or editing.", "ru": "Просмотр записей времени. Пользователь видит все записи проекта — свои и других участников. Также видны email-адреса всех участников, логировавших время. Логировать и редактировать нельзя."}'::jsonb
WHERE name = 'timetracking_can_view';
UPDATE tv_auth.permissions
SET
description = 'Full time-tracking access: log own time + view and edit/delete entries of any project member. Also exposes emails of all contributors',
description_locales = '{"en": "Manage all time entries. Full time-tracking access on this project: user can log own time, view all entries (own and other members''), and edit/delete entries of any project member. Also exposes the emails of all contributors who have logged time on this project. Implies both view and log permissions.", "ru": "Управление всеми записями времени. Полный доступ к учёту времени на этом проекте: пользователь может вести свой таймер, видеть все записи (свои и других участников) и редактировать/удалять записи любого участника. Также видны email-адреса всех участников, логировавших время. Включает права на просмотр и ведение времени."}'::jsonb
WHERE name = 'timetracking_can_manage_all';
+15
View File
@@ -107,6 +107,8 @@ const Timezone = type('string').narrow((v, ctx) => {
}
})
const MAX_REPORT_WINDOW_MS = 2 * 365 * 24 * 60 * 60 * 1000
export const TimeReportArkTypeFilters = type({
'goalIds?': NumberListFromString,
'userId?': OptionalNumberFromString,
@@ -114,6 +116,19 @@ export const TimeReportArkTypeFilters = type({
to: DateFromString,
'billable?': BooleanFromString,
'timezone?': Timezone,
}).narrow((data, ctx) => {
const fromMs = data.from.getTime()
const toMs = data.to.getTime()
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) {
return ctx.mustBe('valid from/to dates')
}
if (fromMs >= toMs) {
return ctx.mustBe('from earlier than to')
}
if (toMs - fromMs > MAX_REPORT_WINDOW_MS) {
return ctx.mustBe('range no wider than 2 years')
}
return true
})
export type TimeReportFilters = typeof TimeReportArkTypeFilters.infer
@@ -963,6 +963,65 @@ describe('TvApi time-tracking tests', () => {
expect(status).toBe(400);
});
it('reportSummary with from >= to is rejected (400)', async () => {
const status = await $mainUser.timeTracking.reportSummary({
organizationId,
from: isoMinutesFromNow(60),
to: isoMinutesAgo(60),
}).catch((err) => err.status);
expect(status).toBe(400);
});
it('reportSummary with from == to is rejected (400)', async () => {
const sameMoment = new Date().toISOString();
const status = await $mainUser.timeTracking.reportSummary({
organizationId,
from: sameMoment,
to: sameMoment,
}).catch((err) => err.status);
expect(status).toBe(400);
});
it('reportSummary with date range wider than 2 years is rejected (400)', async () => {
const status = await $mainUser.timeTracking.reportSummary({
organizationId,
from: '2000-01-01T00:00:00Z',
to: '2099-12-31T23:59:59Z',
}).catch((err) => err.status);
expect(status).toBe(400);
});
it('reportByDay with date range wider than 2 years is rejected (400)', async () => {
const status = await $mainUser.timeTracking.reportByDay({
organizationId,
from: '2000-01-01T00:00:00Z',
to: '2099-12-31T23:59:59Z',
}).catch((err) => err.status);
expect(status).toBe(400);
});
it('reportContributors with from >= to is rejected (400)', async () => {
const status = await $mainUser.timeTracking.reportContributors({
organizationId,
from: isoMinutesFromNow(60),
to: isoMinutesAgo(60),
}).catch((err) => err.status);
expect(status).toBe(400);
});
it('report with exactly 2-year window is accepted', async () => {
const twoYearsAgo = new Date()
twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2)
twoYearsAgo.setHours(twoYearsAgo.getHours() + 1)
const result = await $mainUser.timeTracking.reportSummary({
organizationId,
from: twoYearsAgo.toISOString(),
to: new Date().toISOString(),
}).catch((err) => err.status);
expect(typeof result).toBe('object');
expect((result as { totalSeconds: number }).totalSeconds).toBeGreaterThanOrEqual(0);
});
it('deleting an active timer removes it and clears active state', async () => {
const started = await $mainUser.timeTracking.start({ taskId }).catch(console.error);
expect(started?.entry).toBeDefined();
@@ -245,6 +245,26 @@ describe('time-tracking integration', () => {
expect(result.isError).toBe(true)
})
it('rejects report with from >= to', async () => {
const result = await call(tools, 'get_time_report', {
scope: 'summary',
organizationId,
from: iso(60),
to: iso(-60),
})
expect(result.isError).toBe(true)
})
it('rejects report with date range wider than 2 years', async () => {
const result = await call(tools, 'get_time_report', {
scope: 'summary',
organizationId,
from: '2000-01-01T00:00:00Z',
to: '2099-12-31T23:59:59Z',
})
expect(result.isError).toBe(true)
})
it('deletes a time entry', async () => {
const created = await call(tools, 'log_time', {
taskId,