mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
979181875d
The right-click Schedule task menu item and its keyboard shortcut were gated
only on isPaid, but the backend write routes under /api/scheduled-tasks
enforce requireAdmin + requirePaid on every action. Non-admin Skipper or
Admiral users would see the menu item and hit a 403 on click. The frontend
now mirrors the backend by gating Schedule task on isPaid && isAdmin so the
affordance only renders for users whose action will actually succeed.
Also adds a 120ms keystroke debounce to the sidebar search input. The
useStackListState filter rebuild was previously running on every keystroke
because <Command shouldFilter={false}> disables cmdk's own filter and the
existing 250ms timer only debounces state-invalidate events. Visible input
stays immediate via local state; the debounced emit drives the filter pass.
Adds a regression guard that /api/stacks/statuses is short-circuited by the
remote-node proxy (covers the sidebar status poll path) and updates the
sidebar feature docs to reflect the admin role requirement on Schedule task.
107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
/**
|
|
* Regression guard for `createRemoteProxyMiddleware` mount order.
|
|
*
|
|
* `index.ts` mounts every local `/api/<group>` router before the remote proxy
|
|
* at step 15 of the canonical middleware order. A remote-`nodeId` request
|
|
* must short-circuit into the proxy rather than match a local router. If the
|
|
* order were reversed, a GET `/api/stacks` for a remote node would return the
|
|
* control instance's local stack list instead of the remote's.
|
|
*
|
|
* The test seeds a remote node whose `api_url` points at a closed loopback
|
|
* port. The proxy forwards, the connection fails, and the middleware
|
|
* responds with a 502. A local handler on the same path would return 200
|
|
* (or 404 / 400) with a JSON body, never 502. That distinguishing response
|
|
* proves the proxy intercepted first.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import request from 'supertest';
|
|
import jwt from 'jsonwebtoken';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
|
|
describe('remote proxy mount order', () => {
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authHeader: string;
|
|
let remoteNodeId: number;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
|
|
const { DatabaseService } = await import('../services/DatabaseService');
|
|
// 127.0.0.1:1 is a reserved port that no process binds; TCP connect
|
|
// fails immediately with ECONNREFUSED. The proxy surfaces that as 502.
|
|
remoteNodeId = DatabaseService.getInstance().addNode({
|
|
name: 'mount-order-remote',
|
|
type: 'remote',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: 'http://127.0.0.1:1',
|
|
api_token: 'mount-order-token',
|
|
});
|
|
|
|
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
authHeader = `Bearer ${token}`;
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
it('short-circuits remote-nodeId requests into the proxy before local routers match', async () => {
|
|
const res = await request(app)
|
|
.get('/api/stacks')
|
|
.set('Authorization', authHeader)
|
|
.set('x-node-id', String(remoteNodeId));
|
|
|
|
// 502: the proxy caught the request and the unreachable upstream reported
|
|
// the connection refusal. If a local router had matched first, we would
|
|
// have seen a 200 with a JSON array of local stacks.
|
|
expect(res.status).toBe(502);
|
|
// `x-sencho-proxy` is attached by the proxy's proxyRes callback, which
|
|
// only fires when the upstream produced a response. The unreachable
|
|
// upstream never does; absence here is consistent with a proxy error.
|
|
expect(res.headers['x-sencho-proxy']).toBeUndefined();
|
|
expect(res.body?.error).toMatch(/unreachable/i);
|
|
});
|
|
|
|
it('routes local requests (no x-node-id) to the local handler', async () => {
|
|
const res = await request(app)
|
|
.get('/api/stacks')
|
|
.set('Authorization', authHeader);
|
|
|
|
// Local handler responds with a stack list (200) or, in CI without docker,
|
|
// a 500 from DockerController. Anything other than 502 proves the local
|
|
// router matched instead of the proxy.
|
|
expect(res.status).not.toBe(502);
|
|
});
|
|
|
|
it('short-circuits /api/stacks/statuses (the sidebar status poll) for remote nodes', async () => {
|
|
// The sidebar polls /api/stacks/statuses every few seconds; if a future
|
|
// refactor accidentally mounted a local fast-path before the proxy, every
|
|
// operator viewing a remote node would silently see the central instance's
|
|
// own statuses. Asserts the same 502 short-circuit as /api/stacks.
|
|
const res = await request(app)
|
|
.get('/api/stacks/statuses')
|
|
.set('Authorization', authHeader)
|
|
.set('x-node-id', String(remoteNodeId));
|
|
|
|
expect(res.status).toBe(502);
|
|
expect(res.headers['x-sencho-proxy']).toBeUndefined();
|
|
expect(res.body?.error).toMatch(/unreachable/i);
|
|
});
|
|
|
|
it('handles proxy-exempt paths locally even when x-node-id targets a remote', async () => {
|
|
// /api/nodes/:id is in PROXY_EXEMPT_PREFIXES. The proxy must never catch
|
|
// gateway-level concerns; otherwise a user whose default node is remote
|
|
// could never manage the node registry itself.
|
|
const res = await request(app)
|
|
.get(`/api/nodes/${remoteNodeId}`)
|
|
.set('Authorization', authHeader)
|
|
.set('x-node-id', String(remoteNodeId));
|
|
|
|
expect(res.status).not.toBe(502);
|
|
expect([200, 404]).toContain(res.status);
|
|
});
|
|
});
|