diff --git a/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts b/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts index 03b70a5ac0..fc70af31a9 100644 --- a/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts @@ -305,6 +305,42 @@ describe("GET /plugins/:id", () => { }); }); +describe("GET /plugins/registry (route-shadowing regression)", () => { + let store: TaskStore; + let pluginStore: PluginStore; + + beforeEach(() => { + pluginStore = createMockPluginStore(); + store = createMockTaskStore({ + getPluginStore: vi.fn().mockReturnValue(pluginStore), + }); + }); + + function buildApp() { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store, { + pluginStore, + pluginLoader: createMockPluginLoader(), + })); + return app; + } + + // Regression: the generic "GET /plugins/:id" route is registered before the + // plugin sub-router that owns "GET /plugins/registry". Without the pass-through + // guard, "/api/plugins/registry" matched ":id" (id === "registry"), called + // pluginStore.getPlugin("registry") and failed with 'Plugin "registry" not found'. + it("serves the registry listing instead of being shadowed by /plugins/:id", async () => { + const res = await GET(buildApp(), "/api/plugins/registry"); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("plugins"); + expect(Array.isArray(res.body.plugins)).toBe(true); + // The ":id" handler must NOT have been consulted for the literal "registry". + expect(pluginStore.getPlugin as ReturnType).not.toHaveBeenCalledWith("registry"); + }); +}); + describe("GET /plugins/:id/settings", () => { let store: TaskStore; let pluginStore: PluginStore; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 262adb7a69..33f593ebbd 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -3476,7 +3476,17 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout * Get a single plugin by ID. * Query: { projectId?: string } */ - router.get("/plugins/:id", async (req: Request, res: Response) => { + router.get("/plugins/:id", async (req: Request, res: Response, next: NextFunction) => { + // "registry" is a static sub-route (GET /plugins/registry) owned by the + // plugin sub-router mounted further below. Because this generic ":id" route + // is registered first, Express would otherwise match it for the literal + // path "/plugins/registry" (id === "registry") and throw + // 'Plugin "registry" not found', shadowing the real registry handler. + // Fall through so the mounted sub-router can serve the registry listing. + if (req.params.id === "registry") { + next(); + return; + } const { store: scopedStore } = await getProjectContext(req); const pluginStore = scopedStore.getPluginStore(); const id = req.params.id as string;