diff --git a/backend/src/__tests__/tasks-by-key.test.ts b/backend/src/__tests__/tasks-by-key.test.ts new file mode 100644 index 0000000..f9e7238 --- /dev/null +++ b/backend/src/__tests__/tasks-by-key.test.ts @@ -0,0 +1,74 @@ +/** + * GET /api/tasks/by-key/:key — issue-key lookup. + * + * 1. Existing key returns the same payload as GET /api/tasks/:id + * 2. Unknown key → 404 + * 3. Invalid key format → 400 + * 4. Cross-workspace lookup → 404 (no information leak) + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { auth, registerUser, createWorkspace, createBoard, cleanupTestData, api } from './helpers.js'; + +describe('GET /api/tasks/by-key/:key', () => { + let ownerToken: string; + let strangerToken: string; + let issueKey: string; + let taskId: string; + + beforeAll(async () => { + const owner = await registerUser(); + ownerToken = owner.token; + const ws = await createWorkspace(ownerToken); + const board = await createBoard(ownerToken, ws.id); + + const created = await api + .post(`/api/boards/${board.id}/tasks`) + .set(auth(ownerToken)) + .send({ title: 'Look me up' }); + expect(created.status).toBe(201); + taskId = created.body.id; + issueKey = created.body.issueKey; + + const stranger = await registerUser(); + strangerToken = stranger.token; + }); + + afterAll(cleanupTestData); + + it('returns the task by issueKey with same shape as /api/tasks/:id', async () => { + const byKey = await api.get(`/api/tasks/by-key/${issueKey}`).set(auth(ownerToken)); + const byId = await api.get(`/api/tasks/${taskId}`).set(auth(ownerToken)); + expect(byKey.status).toBe(200); + expect(byId.status).toBe(200); + expect(byKey.body.id).toBe(taskId); + expect(byKey.body.issueKey).toBe(issueKey); + expect(byKey.body.title).toBe('Look me up'); + expect(byKey.body.title).toBe(byId.body.title); + }); + + it('returns 404 for an unknown issueKey', async () => { + const res = await api.get('/api/tasks/by-key/DEV-99999').set(auth(ownerToken)); + expect(res.status).toBe(404); + }); + + it('rejects malformed issueKey with 400', async () => { + const lowercase = await api.get('/api/tasks/by-key/dev-1').set(auth(ownerToken)); + expect(lowercase.status).toBe(400); + + const noDash = await api.get('/api/tasks/by-key/DEV1').set(auth(ownerToken)); + expect(noDash.status).toBe(400); + + const tooShort = await api.get('/api/tasks/by-key/D-1').set(auth(ownerToken)); + expect(tooShort.status).toBe(400); + }); + + it('returns 404 (not 403) for cross-workspace lookup to avoid information leak', async () => { + const res = await api.get(`/api/tasks/by-key/${issueKey}`).set(auth(strangerToken)); + expect(res.status).toBe(404); + }); + + it('requires authentication', async () => { + const res = await api.get(`/api/tasks/by-key/${issueKey}`); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/modules/tasks/tasks.dto.ts b/backend/src/modules/tasks/tasks.dto.ts index 0c0812a..45f778b 100644 --- a/backend/src/modules/tasks/tasks.dto.ts +++ b/backend/src/modules/tasks/tasks.dto.ts @@ -73,8 +73,17 @@ export const myTasksFiltersDto = z.object({ offset: z.coerce.number().int().min(0).default(0).optional(), }); +export const issueKeyParamDto = z.object({ + // Board prefix is alphanumeric (letters + digits), 2-10 chars, must start + // with a letter to avoid clashes with UUID-like ids. Number is unsigned int. + key: z + .string() + .regex(/^[A-Z][A-Z0-9]{1,9}-\d+$/, 'Issue key должен быть формата PREFIX-NUMBER (например, DEV-42)'), +}); + export type CreateTaskDto = z.infer; export type UpdateTaskDto = z.infer; export type BulkUpdateDto = z.infer; export type TaskFiltersDto = z.infer; export type MyTasksFiltersDto = z.infer; +export type IssueKeyParamDto = z.infer; diff --git a/backend/src/modules/tasks/tasks.router.ts b/backend/src/modules/tasks/tasks.router.ts index a53bd17..332eb28 100644 --- a/backend/src/modules/tasks/tasks.router.ts +++ b/backend/src/modules/tasks/tasks.router.ts @@ -12,6 +12,7 @@ import { myTasksFiltersDto, bulkUpdateDto, bulkDeleteDto, + issueKeyParamDto, type BulkUpdateDto, } from './tasks.dto.js'; import * as tasks from './tasks.service.js'; @@ -56,6 +57,18 @@ boardTasksRouter.post('/bulk-delete', bulkLimit, validate(bulkDeleteDto), authHa // ─── /tasks/:id ─────────────────────────────────────────────────────────────── const router = Router(); router.use(authenticate); + +// Registered BEFORE /:id route + taskMfaGuard so "by-key" doesn't get +// caught as id="by-key". The handler resolves issueKey → id and then +// delegates to getTask which enforces workspace access. +router.get( + '/by-key/:key', + validate(issueKeyParamDto, 'params'), + authHandler(async (req, res) => { + res.json(await tasks.getTaskByIssueKey(String(req.params.key), req.user!.userId)); + }), +); + router.use('/:id', asyncHandler(taskMfaGuard())); router.get('/:id', authHandler(async (req, res) => { diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 5704eaf..05e590d 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -244,6 +244,23 @@ export async function createTask(boardId: string, userId: string, dto: CreateTas return task; } +// ─── Get task by issue key ──────────────────────────────────────────────────── + +/** + * Look up a task by its human-readable issue key (e.g. "DEV-42") and return + * the same shape as {@link getTask}. Authorization is delegated to getTask: + * if the caller doesn't have access to the underlying board's workspace, + * they get a 404 (consistent with not revealing existence cross-workspace). + */ +export async function getTaskByIssueKey(issueKey: string, userId: string) { + const found = await prisma.task.findUnique({ + where: { issueKey }, + select: { id: true }, + }); + if (!found) throw new AppError(404, 'Task not found or access denied'); + return getTask(found.id, userId); +} + // ─── Get task detail ────────────────────────────────────────────────────────── export async function getTask(taskId: string, userId: string) {