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
74 changes: 74 additions & 0 deletions backend/src/__tests__/tasks-by-key.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 9 additions & 0 deletions backend/src/modules/tasks/tasks.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createTaskDto>;
export type UpdateTaskDto = z.infer<typeof updateTaskDto>;
export type BulkUpdateDto = z.infer<typeof bulkUpdateDto>;
export type TaskFiltersDto = z.infer<typeof taskFiltersDto>;
export type MyTasksFiltersDto = z.infer<typeof myTasksFiltersDto>;
export type IssueKeyParamDto = z.infer<typeof issueKeyParamDto>;
13 changes: 13 additions & 0 deletions backend/src/modules/tasks/tasks.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
myTasksFiltersDto,
bulkUpdateDto,
bulkDeleteDto,
issueKeyParamDto,
type BulkUpdateDto,
} from './tasks.dto.js';
import * as tasks from './tasks.service.js';
Expand Down Expand Up @@ -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) => {
Expand Down
17 changes: 17 additions & 0 deletions backend/src/modules/tasks/tasks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading