fix(skills): resolve slug references in skillId route param (#19808)

The skillId route param handler called getById with the raw path segment.
A slug such as inbox-triage then reached the uuid id column, Postgres
rejected the cast, and the request returned 500. The handler now resolves
the reference with getByRouteRef (UUID, unique slug, or key) and rewrites
req.params.skillId to the skill's UUID. An unknown reference returns 404.

Fixes PAPERCLIP-BACKEND-6H

Co-Authored-By: Devin Foley <devinfoley@users.noreply.github.com>
This commit is contained in:
sentry[bot]andDevin Foley authored and GitHub committed 2026-11-08 02:43:33 -08:00
1 parent 2175b8db93
commit a8ab1d3d94
2 files changed
+34 -1

No files matched your search

@@ -45,6 +45,7 @@ const mockCompanySkillService = vi.hoisted(() => ({
deleteSkill: vi.fn(),
auditSkill: vi.fn(),
getById: vi.fn(),
getByRouteRef: vi.fn(),
installUpdate: vi.fn(),
resetSkill: vi.fn(),
listTestInputs: vi.fn(),
@@ -314,6 +315,10 @@ describe("company skill mutation permissions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCompanySkillService.getByRouteRef.mockImplementation(async (companyId: string, ref: string) => {
const skill = await mockCompanySkillService.getById(companyId, ref);
return skill ? { ...skill, id: ref } : skill;
});
mockHeartbeatFactory.mockReturnValue(mockHeartbeatService);
mockCaptureRunIdentity.mockResolvedValue({ run: { responsibleUserId: "validated-owner" } });
mockSkillSourceService.sourceForSkill.mockResolvedValue(null);
@@ -826,6 +831,32 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.scanProjectWorkspaces).not.toHaveBeenCalled();
});
it("resolves skill slugs in the skillId param to the canonical skill id", async () => {
const skillId = "22222222-2222-4222-8222-222222222222";
mockCompanySkillService.getByRouteRef.mockResolvedValue({ id: skillId, slug: "inbox-triage" });
const app = await createApp(boardActor("board-user", "session"));
await request(app)
.post("/api/companies/company-1/skills/inbox-triage/audit")
.send({})
.expect(200);
expect(mockCompanySkillService.getByRouteRef).toHaveBeenCalledWith("company-1", "inbox-triage");
expect(mockCompanySkillService.auditSkill).toHaveBeenCalledWith("company-1", skillId);
});
it("returns 404 when the skillId param does not match any skill", async () => {
mockCompanySkillService.getByRouteRef.mockResolvedValue(null);
const app = await createApp(boardActor("board-user", "session"));
await request(app)
.post("/api/companies/company-1/skills/missing-skill/audit")
.send({})
.expect(404);
expect(mockCompanySkillService.auditSkill).not.toHaveBeenCalled();
});
it("allows board users with skills:create to create, import, install, update, delete, audit, and reset company skills", async () => {
const app = await createApp(boardActor("board-user", "session"));
+3 -1
View File
@@ -109,7 +109,9 @@ export function companySkillRoutes(db: Db, options: { pluginWorkerManager?: Plug
try {
if (req.actor.type === "none") throw unauthorized("Authentication required");
assertCompanyAccess(req, String(req.params.companyId));
if (!await svc.getById(String(req.params.companyId), id)) throw notFound("Skill not found");
const skill = await svc.getByRouteRef(String(req.params.companyId), id);
if (!skill) throw notFound("Skill not found");
req.params.skillId = skill.id;
next();
} catch (error) { next(error); }
});