mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add bounded panel bootstrap
This commit is contained in:
@@ -38,6 +38,7 @@ import type {
|
||||
LocalApiSecretPutRoute,
|
||||
} from '../secret/secretRoutes';
|
||||
import type { PanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
|
||||
import type { PanelBootstrapRoute } from '../panel-compatibility/panelBootstrapRoute';
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
export type LocalApiAdmissionOperation =
|
||||
@@ -133,6 +134,10 @@ export type LocalApiAdmissionOperation =
|
||||
page: number;
|
||||
size: number;
|
||||
maximumRows: number;
|
||||
}>
|
||||
| Readonly<{
|
||||
operationId: 'panel.user.get' | 'panel.system.config.get';
|
||||
projectId: 'default';
|
||||
}>;
|
||||
|
||||
export interface LocalApiAdmissionRequest {
|
||||
@@ -177,6 +182,7 @@ export interface LocalApiAdmissionOptions {
|
||||
readonly secretListRoute: LocalApiSecretListRoute;
|
||||
readonly secretPutRoute: LocalApiSecretPutRoute;
|
||||
readonly panelCronListRoute: PanelCronListRoute;
|
||||
readonly panelBootstrapRoute: PanelBootstrapRoute;
|
||||
readonly now?: () => number;
|
||||
readonly randomUuid?: () => string;
|
||||
}
|
||||
@@ -263,6 +269,7 @@ export function createLocalApiAdmission(
|
||||
typeof options.secretListRoute?.handle !== 'function' ||
|
||||
typeof options.secretPutRoute?.handle !== 'function' ||
|
||||
typeof options.panelCronListRoute?.handle !== 'function' ||
|
||||
typeof options.panelBootstrapRoute?.handle !== 'function' ||
|
||||
(options.now !== undefined && typeof options.now !== 'function') ||
|
||||
(options.randomUuid !== undefined &&
|
||||
typeof options.randomUuid !== 'function')
|
||||
@@ -404,7 +411,9 @@ export function createLocalApiAdmission(
|
||||
request.operation.operationId === 'task.get' ||
|
||||
request.operation.operationId === 'trigger.list' ||
|
||||
request.operation.operationId === 'trigger.get' ||
|
||||
request.operation.operationId === 'panel.cron.list'
|
||||
request.operation.operationId === 'panel.cron.list' ||
|
||||
request.operation.operationId === 'panel.user.get' ||
|
||||
request.operation.operationId === 'panel.system.config.get'
|
||||
? 'task.read'
|
||||
: request.operation.operationId === 'secret.list'
|
||||
? 'secret.manage'
|
||||
@@ -572,6 +581,13 @@ export function createLocalApiAdmission(
|
||||
size: request.operation.size,
|
||||
maximumRows: request.operation.maximumRows,
|
||||
});
|
||||
case 'panel.user.get':
|
||||
case 'panel.system.config.get':
|
||||
if (body !== null) return response(400, 'invalid_request_body');
|
||||
return options.panelBootstrapRoute.handle({
|
||||
operationId: request.operation.operationId,
|
||||
principal: authenticated.principal,
|
||||
});
|
||||
case 'task.put':
|
||||
case 'task.authoring':
|
||||
case 'trigger.put':
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
createLocalApiSecretPutRoute,
|
||||
} from '../secret/secretRoutes';
|
||||
import { createPanelCronListRoute } from '../panel-compatibility/panelCronListRoute';
|
||||
import { createPanelBootstrapRoute } from '../panel-compatibility/panelBootstrapRoute';
|
||||
import { startLocalApiHttpSurface } from '../transport/httpSurface';
|
||||
|
||||
export interface LocalApiProductSurfaceEvent {
|
||||
@@ -226,6 +227,7 @@ export function createLocalApiProductSurface(
|
||||
tasks: authority.taskDefinitions,
|
||||
triggers: authority.triggers,
|
||||
});
|
||||
const panelBootstrapRoute = createPanelBootstrapRoute(authority.profile);
|
||||
const admission = createLocalApiAdmission({
|
||||
authenticator,
|
||||
policy,
|
||||
@@ -247,6 +249,7 @@ export function createLocalApiProductSurface(
|
||||
secretListRoute,
|
||||
secretPutRoute,
|
||||
panelCronListRoute,
|
||||
panelBootstrapRoute,
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.randomUuid === undefined
|
||||
? {}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { LocalApplicationProfile } from '@qinglong/local-application';
|
||||
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
|
||||
import type { LocalApiResponse } from '../transport/contract';
|
||||
|
||||
const PRODUCT_VERSION = '3.0.0-alpha.2';
|
||||
|
||||
export type PanelBootstrapOperation =
|
||||
| 'panel.user.get'
|
||||
| 'panel.system.config.get';
|
||||
|
||||
export interface PanelBootstrapRequest {
|
||||
readonly operationId: PanelBootstrapOperation;
|
||||
readonly principal: Readonly<SecurityPrincipal>;
|
||||
}
|
||||
|
||||
export interface PanelBootstrapRoute {
|
||||
handle(request: Readonly<PanelBootstrapRequest>): Promise<LocalApiResponse>;
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): Readonly<LocalApiResponse> {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function maximumRows(profile: LocalApplicationProfile): number {
|
||||
return profile === 'edge' ? 64 : 256;
|
||||
}
|
||||
|
||||
export function panelCapabilities(
|
||||
profile: LocalApplicationProfile,
|
||||
): Readonly<Record<string, unknown>> {
|
||||
if (profile !== 'edge' && profile !== 'standalone') {
|
||||
throw new TypeError('Panel capability profile is invalid');
|
||||
}
|
||||
const logChunkBytes = profile === 'edge' ? 16 * 1_024 : 32 * 1_024;
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
product: 'qinglong3',
|
||||
version: PRODUCT_VERSION,
|
||||
deployment: Object.freeze({ mode: 'local', profile }),
|
||||
authentication: Object.freeze({
|
||||
kind: 'api_credential',
|
||||
transport: 'bearer',
|
||||
persistence: 'memory_only',
|
||||
loginEndpoint: null,
|
||||
}),
|
||||
project: Object.freeze({ selection: 'explicit', defaultId: 'default' }),
|
||||
panel: Object.freeze({
|
||||
bootstrap: true,
|
||||
cronList: 'bounded_read_only',
|
||||
taskRead: true,
|
||||
triggerRead: true,
|
||||
runRead: true,
|
||||
runLogRead: true,
|
||||
legacyMutations: false,
|
||||
legacyLogin: false,
|
||||
subscriptions: false,
|
||||
scripts: false,
|
||||
environmentVariables: false,
|
||||
webSocket: false,
|
||||
}),
|
||||
limits: Object.freeze({
|
||||
cronRows: maximumRows(profile),
|
||||
cronPageSize: 64,
|
||||
logChunkBytes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function panelPublicResponse(
|
||||
operation: 'capabilities' | 'health' | 'system',
|
||||
profile: LocalApplicationProfile,
|
||||
): Readonly<LocalApiResponse> {
|
||||
const capabilities = panelCapabilities(profile);
|
||||
if (operation === 'capabilities') {
|
||||
return response(200, { capabilities });
|
||||
}
|
||||
if (operation === 'health') {
|
||||
return response(200, {
|
||||
code: 200,
|
||||
data: Object.freeze({
|
||||
status: 'ok',
|
||||
ql3: Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiVersion: 'v3',
|
||||
capabilitiesPath: '/api/v3/capabilities',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return response(200, {
|
||||
code: 200,
|
||||
data: Object.freeze({
|
||||
branch: 'develop',
|
||||
isInitialized: true,
|
||||
publishTime: 0,
|
||||
version: PRODUCT_VERSION,
|
||||
changeLog: '',
|
||||
changeLogLink: '',
|
||||
ql3: Object.freeze({
|
||||
schemaVersion: 1,
|
||||
mode: 'local',
|
||||
profile,
|
||||
capabilitiesPath: '/api/v3/capabilities',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createPanelBootstrapRoute(
|
||||
profile: LocalApplicationProfile,
|
||||
): Readonly<PanelBootstrapRoute> {
|
||||
if (profile !== 'edge' && profile !== 'standalone') {
|
||||
throw new TypeError('Panel bootstrap profile is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
async handle(request: Readonly<PanelBootstrapRequest>) {
|
||||
const principal = request?.principal;
|
||||
if (
|
||||
!principal ||
|
||||
typeof principal !== 'object' ||
|
||||
Array.isArray(principal) ||
|
||||
principal.subject?.type !== 'user' ||
|
||||
typeof principal.subject?.id !== 'string' ||
|
||||
principal.subject.id.length < 1
|
||||
) {
|
||||
return response(503, {
|
||||
code: 503,
|
||||
message: 'QL3 面板身份暂不可用',
|
||||
});
|
||||
}
|
||||
if (request.operationId === 'panel.user.get') {
|
||||
return response(200, {
|
||||
code: 200,
|
||||
data: Object.freeze({
|
||||
username: principal.subject.id,
|
||||
ql3: Object.freeze({
|
||||
schemaVersion: 1,
|
||||
subjectType: principal.subject.type,
|
||||
assurance: principal.assurance,
|
||||
expiresAtMs: principal.expiresAtMs,
|
||||
credentialPersistence: 'memory_only',
|
||||
panelHome: '/crontab',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (request.operationId !== 'panel.system.config.get') {
|
||||
return response(503, {
|
||||
code: 503,
|
||||
message: 'QL3 面板启动入口暂不可用',
|
||||
});
|
||||
}
|
||||
return response(200, {
|
||||
code: 200,
|
||||
data: Object.freeze({
|
||||
info: Object.freeze({
|
||||
panelTitle: 'QingLong 3.0',
|
||||
lang: 'zh-cn',
|
||||
}),
|
||||
ql3: panelCapabilities(profile),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -49,7 +49,7 @@ function validRequest(request: Readonly<PanelCronListRequest>): boolean {
|
||||
Number.isSafeInteger(request.maximumRows) &&
|
||||
request.maximumRows >= 1 &&
|
||||
request.maximumRows <= 256 &&
|
||||
request.page * request.size <= request.maximumRows
|
||||
(request.page - 1) * request.size < request.maximumRows
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,10 @@ export function createPanelCronListRoute(
|
||||
return response(400, { code: 400, message: '参数错误' });
|
||||
}
|
||||
try {
|
||||
const scanLimit = request.page * request.size;
|
||||
const scanLimit = Math.min(
|
||||
request.page * request.size,
|
||||
request.maximumRows,
|
||||
);
|
||||
const page = await sources.triggers.listTriggers({
|
||||
projectId: request.projectId,
|
||||
limit: scanLimit,
|
||||
@@ -149,7 +152,10 @@ export function createPanelCronListRoute(
|
||||
code: 200,
|
||||
data: Object.freeze({
|
||||
data: Object.freeze(data),
|
||||
total: page.triggers.length + (page.truncated ? 1 : 0),
|
||||
total: Math.min(
|
||||
request.maximumRows,
|
||||
page.triggers.length + (page.truncated ? 1 : 0),
|
||||
),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
loadLocalConsoleAssets,
|
||||
type LocalConsoleAsset,
|
||||
} from '../console/localConsoleAssets';
|
||||
import { panelPublicResponse } from '../panel-compatibility/panelBootstrapRoute';
|
||||
import type { LocalApiResponse } from './contract';
|
||||
|
||||
const MAX_HEADER_BYTES = 8 * 1_024;
|
||||
@@ -65,9 +66,12 @@ type LocalApiRouteResolution =
|
||||
| 'invalid_task_list_query'
|
||||
| 'invalid_trigger_list_query'
|
||||
| 'invalid_secret_list_query'
|
||||
| 'invalid_panel_cron_list_query';
|
||||
| 'invalid_panel_cron_list_query'
|
||||
| 'invalid_panel_bootstrap_query';
|
||||
}>;
|
||||
|
||||
type PanelPublicOperation = 'capabilities' | 'health' | 'system';
|
||||
|
||||
export interface LocalApiHttpSurfaceOptions {
|
||||
readonly profile: LocalApplicationProfile;
|
||||
readonly host: '127.0.0.1' | '::1';
|
||||
@@ -589,7 +593,7 @@ function parsePanelCronListRoute(
|
||||
size < 1 ||
|
||||
size > 64 ||
|
||||
String(size) !== rawSize ||
|
||||
page * size > maximumRows ||
|
||||
(page - 1) * size >= maximumRows ||
|
||||
(timestamp !== null && !/^\d{1,20}$/u.test(timestamp))
|
||||
) {
|
||||
throw new TypeError();
|
||||
@@ -606,6 +610,53 @@ function parsePanelCronListRoute(
|
||||
}
|
||||
}
|
||||
|
||||
function panelExactGetPath(
|
||||
rawUrl: string,
|
||||
expectedPath: string,
|
||||
): 'invalid' | 'match' | 'unrelated' {
|
||||
const separator = rawUrl.indexOf('?');
|
||||
const path = separator < 0 ? rawUrl : rawUrl.slice(0, separator);
|
||||
if (path !== expectedPath) return 'unrelated';
|
||||
if (separator !== rawUrl.lastIndexOf('?')) return 'invalid';
|
||||
if (separator < 0) return 'match';
|
||||
try {
|
||||
const query = new URLSearchParams(rawUrl.slice(separator + 1));
|
||||
if (
|
||||
[...query.keys()].some(
|
||||
(key) => key !== 't' || query.getAll(key).length !== 1,
|
||||
)
|
||||
) {
|
||||
return 'invalid';
|
||||
}
|
||||
const timestamp = query.get('t');
|
||||
return timestamp !== null && /^\d{1,20}$/u.test(timestamp)
|
||||
? 'match'
|
||||
: 'invalid';
|
||||
} catch {
|
||||
return 'invalid';
|
||||
}
|
||||
}
|
||||
|
||||
function panelPublicOperation(
|
||||
request: IncomingMessage,
|
||||
): PanelPublicOperation | 'invalid' | null {
|
||||
if (request.method !== 'GET' || typeof request.url !== 'string') return null;
|
||||
const paths = Object.freeze([
|
||||
Object.freeze({ path: '/api/health', operation: 'health' as const }),
|
||||
Object.freeze({ path: '/api/system', operation: 'system' as const }),
|
||||
Object.freeze({
|
||||
path: '/api/v3/capabilities',
|
||||
operation: 'capabilities' as const,
|
||||
}),
|
||||
]);
|
||||
for (const candidate of paths) {
|
||||
const result = panelExactGetPath(request.url, candidate.path);
|
||||
if (result === 'invalid') return 'invalid';
|
||||
if (result === 'match') return candidate.operation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function route(
|
||||
request: IncomingMessage,
|
||||
profile: LocalApplicationProfile,
|
||||
@@ -622,6 +673,24 @@ function route(
|
||||
if (request.method === 'GET') {
|
||||
const panelCronList = parsePanelCronListRoute(rawUrl, profile);
|
||||
if (panelCronList) return panelCronList;
|
||||
const panelUser = panelExactGetPath(rawUrl, '/api/user');
|
||||
if (panelUser !== 'unrelated') {
|
||||
return panelUser === 'match'
|
||||
? Object.freeze({
|
||||
operationId: 'panel.user.get',
|
||||
projectId: 'default',
|
||||
})
|
||||
: Object.freeze({ errorCode: 'invalid_panel_bootstrap_query' });
|
||||
}
|
||||
const panelSystemConfig = panelExactGetPath(rawUrl, '/api/system/config');
|
||||
if (panelSystemConfig !== 'unrelated') {
|
||||
return panelSystemConfig === 'match'
|
||||
? Object.freeze({
|
||||
operationId: 'panel.system.config.get',
|
||||
projectId: 'default',
|
||||
})
|
||||
: Object.freeze({ errorCode: 'invalid_panel_bootstrap_query' });
|
||||
}
|
||||
}
|
||||
if (rawUrl.includes('%')) return null;
|
||||
const separator = rawUrl.indexOf('?');
|
||||
@@ -940,6 +1009,22 @@ export async function startLocalApiHttpSurface(
|
||||
sendConsoleFavicon(response, requestId);
|
||||
return;
|
||||
}
|
||||
const publicOperation = panelPublicOperation(request);
|
||||
if (publicOperation) {
|
||||
if (hasRequestBody(request)) {
|
||||
send(response, requestId, errorResponse(400, 'invalid_request_body'));
|
||||
request.resume();
|
||||
return;
|
||||
}
|
||||
send(
|
||||
response,
|
||||
requestId,
|
||||
publicOperation === 'invalid'
|
||||
? errorResponse(400, 'invalid_panel_bootstrap_query')
|
||||
: panelPublicResponse(publicOperation, options.profile),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const resolvedRoute = route(request, options.profile);
|
||||
if (!resolvedRoute) {
|
||||
send(response, requestId, errorResponse(404, 'route_not_found'));
|
||||
|
||||
@@ -314,6 +314,17 @@ function fixture(overrides = {}) {
|
||||
};
|
||||
},
|
||||
},
|
||||
panelBootstrapRoute: {
|
||||
async handle(value) {
|
||||
events.push(
|
||||
`panel-bootstrap:${value.operationId}:${value.principal.subject.id}`,
|
||||
);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { code: 200, data: { username: value.principal.subject.id } },
|
||||
};
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000002',
|
||||
...overrides,
|
||||
@@ -548,6 +559,27 @@ test('uses task.read and the exact panel Cron audit identity for the compatibili
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses the same authenticated task.read chain for the panel bootstrap identity', async () => {
|
||||
for (const operationId of ['panel.user.get', 'panel.system.config.get']) {
|
||||
const { admission, events } = fixture();
|
||||
const result = await execute(
|
||||
admission,
|
||||
request({
|
||||
operation: Object.freeze({ operationId, projectId: 'default' }),
|
||||
}),
|
||||
);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(result.body.data.username, 'usr_local');
|
||||
assert.deepEqual(events, [
|
||||
'authenticate',
|
||||
'authorize:task.read:default',
|
||||
`audit:allowed:${operationId}`,
|
||||
'confirm',
|
||||
`panel-bootstrap:${operationId}:usr_local`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('authorizes and audits run.stop before exposing the cancellation body handler', async () => {
|
||||
const { admission, events } = fixture();
|
||||
const prepared = await admission.prepare(
|
||||
|
||||
@@ -277,6 +277,14 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
});
|
||||
assert.deepEqual(observed[6].operation, panelCrons.body.input);
|
||||
|
||||
const finalEdgePage = await request(
|
||||
port,
|
||||
'/api/crons?searchValue=&page=4&size=20&filters=%7B%7D',
|
||||
);
|
||||
assert.equal(finalEdgePage.statusCode, 200);
|
||||
assert.equal(finalEdgePage.body.input.maximumRows, 64);
|
||||
assert.equal(finalEdgePage.body.input.page, 4);
|
||||
|
||||
const unsupportedPanelQuery = await request(
|
||||
port,
|
||||
'/api/crons?searchValue=private&page=1&size=20&filters=%7B%7D',
|
||||
@@ -285,7 +293,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
code: 'invalid_panel_cron_list_query',
|
||||
});
|
||||
assert.equal(unsupportedPanelQuery.statusCode, 400);
|
||||
assert.equal(observed.length, 7);
|
||||
assert.equal(observed.length, 8);
|
||||
|
||||
const cancellationBody = JSON.stringify({
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
@@ -306,7 +314,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
);
|
||||
assert.equal(cancellation.statusCode, 202);
|
||||
assert.deepEqual(cancellation.body.accepted, JSON.parse(cancellationBody));
|
||||
assert.deepEqual(observed[7].operation, {
|
||||
assert.deepEqual(observed[8].operation, {
|
||||
operationId: 'run.cancel',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
@@ -333,7 +341,7 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
);
|
||||
assert.equal(taskStart.statusCode, 202);
|
||||
assert.deepEqual(taskStart.body.accepted, JSON.parse(taskStartBody));
|
||||
assert.deepEqual(observed[8].operation, {
|
||||
assert.deepEqual(observed[9].operation, {
|
||||
operationId: 'task.start',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
@@ -357,20 +365,20 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
);
|
||||
assert.equal(taskPut.statusCode, 202);
|
||||
assert.deepEqual(taskPut.body.accepted, JSON.parse(taskPutBody));
|
||||
assert.deepEqual(observed[9].operation, {
|
||||
assert.deepEqual(observed[10].operation, {
|
||||
operationId: 'task.put',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
});
|
||||
assert.equal(observed[9].localPresence, 'ql3p_request_bound_proof');
|
||||
assert.equal(observed[9].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
|
||||
assert.equal(observed[10].localPresence, 'ql3p_request_bound_proof');
|
||||
assert.equal(observed[10].taskAuthoringLease, 'ql3a_exact_snapshot_lease');
|
||||
|
||||
const log = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/runs/run_123/attempts/attempt_1/log?offset=4&length=32',
|
||||
);
|
||||
assert.deepEqual(log.body, { range: { offset: 4, length: 32 } });
|
||||
assert.deepEqual(observed[10].operation, {
|
||||
assert.deepEqual(observed[11].operation, {
|
||||
operationId: 'run.log.read',
|
||||
projectId: 'prj_default',
|
||||
runId: 'run_123',
|
||||
@@ -399,19 +407,19 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
},
|
||||
);
|
||||
assert.equal(authoring.statusCode, 200);
|
||||
assert.deepEqual(observed[12].operation, {
|
||||
assert.deepEqual(observed[13].operation, {
|
||||
operationId: 'task.authoring',
|
||||
projectId: 'prj_default',
|
||||
taskId: 'task_1',
|
||||
});
|
||||
assert.equal(observed[12].localPresence, 'ql3p_authoring_read_proof');
|
||||
assert.equal(observed[13].localPresence, 'ql3p_authoring_read_proof');
|
||||
|
||||
const secrets = await request(
|
||||
port,
|
||||
'/api/v3/projects/prj_default/secrets?limit=8&after=YWxwaGE',
|
||||
);
|
||||
assert.deepEqual(secrets.body, { secrets: [], truncated: false });
|
||||
assert.deepEqual(observed[13].operation, {
|
||||
assert.deepEqual(observed[14].operation, {
|
||||
operationId: 'secret.list',
|
||||
projectId: 'prj_default',
|
||||
limit: 8,
|
||||
@@ -435,11 +443,11 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
);
|
||||
assert.equal(secretPut.statusCode, 202);
|
||||
assert.deepEqual(secretPut.body.accepted, JSON.parse(secretPutBody));
|
||||
assert.deepEqual(observed[14].operation, {
|
||||
assert.deepEqual(observed[15].operation, {
|
||||
operationId: 'secret.put',
|
||||
projectId: 'prj_default',
|
||||
});
|
||||
assert.equal(observed[14].localPresence, 'ql3p_secret_bound_proof');
|
||||
assert.equal(observed[15].localPresence, 'ql3p_secret_bound_proof');
|
||||
|
||||
for (const invalidPath of [
|
||||
'/api/v3/projects/prj_default/runs/run_123?expanded=true',
|
||||
@@ -521,13 +529,86 @@ test('serves only the fixed canonical loopback Run route and drains idempotently
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_run_step_list_query' });
|
||||
}
|
||||
assert.equal(observed.length, 15);
|
||||
assert.equal(observed.length, 16);
|
||||
assert.deepEqual(
|
||||
await Promise.all([surface.stopAndDrain(), surface.stopAndDrain()]),
|
||||
['stopped', 'stopped'],
|
||||
);
|
||||
});
|
||||
|
||||
test('serves the public capability shell and authenticates private panel bootstrap reads', async (t) => {
|
||||
const port = await reservePort();
|
||||
const observed = [];
|
||||
const surface = await startLocalApiHttpSurface({
|
||||
profile: 'edge',
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
admission: preparedAdmission(async (value) => {
|
||||
observed.push(value);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
code: 200,
|
||||
data: { operationId: value.operation.operationId },
|
||||
},
|
||||
};
|
||||
}),
|
||||
randomUuid: () => '019f70c0-0000-4000-8000-000000000013',
|
||||
});
|
||||
t.after(() => surface.stopAndDrain());
|
||||
|
||||
const health = await request(port, '/api/health?t=100', { headers: {} });
|
||||
assert.equal(health.statusCode, 200);
|
||||
assert.equal(health.body.data.status, 'ok');
|
||||
assert.equal(health.body.data.ql3.capabilitiesPath, '/api/v3/capabilities');
|
||||
|
||||
const system = await request(port, '/api/system', { headers: {} });
|
||||
assert.equal(system.statusCode, 200);
|
||||
assert.equal(system.body.data.isInitialized, true);
|
||||
assert.equal(system.body.data.ql3.profile, 'edge');
|
||||
|
||||
const capabilities = await request(port, '/api/v3/capabilities?t=101', {
|
||||
headers: {},
|
||||
});
|
||||
assert.equal(capabilities.statusCode, 200);
|
||||
assert.equal(
|
||||
capabilities.body.capabilities.authentication.loginEndpoint,
|
||||
null,
|
||||
);
|
||||
assert.equal(capabilities.body.capabilities.panel.legacyLogin, false);
|
||||
assert.equal(capabilities.body.capabilities.limits.cronRows, 64);
|
||||
assert.equal(observed.length, 0);
|
||||
|
||||
const user = await request(port, '/api/user?t=102');
|
||||
assert.deepEqual(user.body, {
|
||||
code: 200,
|
||||
data: { operationId: 'panel.user.get' },
|
||||
});
|
||||
assert.deepEqual(observed[0].operation, {
|
||||
operationId: 'panel.user.get',
|
||||
projectId: 'default',
|
||||
});
|
||||
|
||||
const config = await request(port, '/api/system/config?t=103');
|
||||
assert.deepEqual(config.body, {
|
||||
code: 200,
|
||||
data: { operationId: 'panel.system.config.get' },
|
||||
});
|
||||
assert.deepEqual(observed[1].operation, {
|
||||
operationId: 'panel.system.config.get',
|
||||
projectId: 'default',
|
||||
});
|
||||
|
||||
const invalid = await request(port, '/api/system?search=wide', {
|
||||
headers: {},
|
||||
});
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
assert.deepEqual(invalid.body, { code: 'invalid_panel_bootstrap_query' });
|
||||
const encoded = await request(port, '/api/%73ystem', { headers: {} });
|
||||
assert.equal(encoded.statusCode, 404);
|
||||
assert.equal(observed.length, 2);
|
||||
});
|
||||
|
||||
test('rejects GET bodies without invoking the prepared route handler', async (t) => {
|
||||
const port = await reservePort();
|
||||
let handlers = 0;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
createPanelBootstrapRoute,
|
||||
panelCapabilities,
|
||||
panelPublicResponse,
|
||||
} = require('../dist/panel-compatibility/panelBootstrapRoute.js');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner' }),
|
||||
authenticationId: 'auth:owner',
|
||||
authenticatedAtMs: 1_787_200_000_000,
|
||||
expiresAtMs: 1_787_200_060_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
test('publishes an exact profile-aware capability contract', () => {
|
||||
const edge = panelCapabilities('edge');
|
||||
const standalone = panelCapabilities('standalone');
|
||||
assert.deepEqual(edge, {
|
||||
schemaVersion: 1,
|
||||
product: 'qinglong3',
|
||||
version: '3.0.0-alpha.2',
|
||||
deployment: { mode: 'local', profile: 'edge' },
|
||||
authentication: {
|
||||
kind: 'api_credential',
|
||||
transport: 'bearer',
|
||||
persistence: 'memory_only',
|
||||
loginEndpoint: null,
|
||||
},
|
||||
project: { selection: 'explicit', defaultId: 'default' },
|
||||
panel: {
|
||||
bootstrap: true,
|
||||
cronList: 'bounded_read_only',
|
||||
taskRead: true,
|
||||
triggerRead: true,
|
||||
runRead: true,
|
||||
runLogRead: true,
|
||||
legacyMutations: false,
|
||||
legacyLogin: false,
|
||||
subscriptions: false,
|
||||
scripts: false,
|
||||
environmentVariables: false,
|
||||
webSocket: false,
|
||||
},
|
||||
limits: {
|
||||
cronRows: 64,
|
||||
cronPageSize: 64,
|
||||
logChunkBytes: 16 * 1_024,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(standalone.limits, {
|
||||
cronRows: 256,
|
||||
cronPageSize: 64,
|
||||
logChunkBytes: 32 * 1_024,
|
||||
});
|
||||
assert.throws(() => panelCapabilities('cluster'));
|
||||
});
|
||||
|
||||
test('serves public health, system and native capability envelopes', () => {
|
||||
assert.deepEqual(panelPublicResponse('health', 'edge'), {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
code: 200,
|
||||
data: {
|
||||
status: 'ok',
|
||||
ql3: {
|
||||
schemaVersion: 1,
|
||||
apiVersion: 'v3',
|
||||
capabilitiesPath: '/api/v3/capabilities',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const system = panelPublicResponse('system', 'standalone');
|
||||
assert.equal(system.body.code, 200);
|
||||
assert.equal(system.body.data.isInitialized, true);
|
||||
assert.equal(system.body.data.version, '3.0.0-alpha.2');
|
||||
assert.equal(system.body.data.ql3.profile, 'standalone');
|
||||
const capabilities = panelPublicResponse('capabilities', 'edge');
|
||||
assert.equal(capabilities.body.capabilities.limits.cronRows, 64);
|
||||
});
|
||||
|
||||
test('projects the authenticated principal and bounded panel configuration', async () => {
|
||||
const route = createPanelBootstrapRoute('edge');
|
||||
const user = await route.handle({
|
||||
operationId: 'panel.user.get',
|
||||
principal: PRINCIPAL,
|
||||
});
|
||||
assert.deepEqual(user, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
code: 200,
|
||||
data: {
|
||||
username: 'owner',
|
||||
ql3: {
|
||||
schemaVersion: 1,
|
||||
subjectType: 'user',
|
||||
assurance: 'local_console',
|
||||
expiresAtMs: 1_787_200_060_000,
|
||||
credentialPersistence: 'memory_only',
|
||||
panelHome: '/crontab',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const config = await route.handle({
|
||||
operationId: 'panel.system.config.get',
|
||||
principal: PRINCIPAL,
|
||||
});
|
||||
assert.equal(config.statusCode, 200);
|
||||
assert.deepEqual(config.body.data.info, {
|
||||
panelTitle: 'QingLong 3.0',
|
||||
lang: 'zh-cn',
|
||||
});
|
||||
assert.equal(config.body.data.ql3.limits.cronRows, 64);
|
||||
});
|
||||
|
||||
test('fails closed for invalid profile, principal and operation', async () => {
|
||||
assert.throws(() => createPanelBootstrapRoute('cluster'));
|
||||
const route = createPanelBootstrapRoute('standalone');
|
||||
assert.equal(
|
||||
(await route.handle({ operationId: 'panel.user.get', principal: null }))
|
||||
.statusCode,
|
||||
503,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await route.handle({
|
||||
operationId: 'panel.user.get',
|
||||
principal: {
|
||||
...PRINCIPAL,
|
||||
subject: { type: 'api_app', id: 'service' },
|
||||
assurance: 'service',
|
||||
},
|
||||
})
|
||||
).statusCode,
|
||||
503,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await route.handle({
|
||||
operationId: 'panel.unknown',
|
||||
principal: PRINCIPAL,
|
||||
})
|
||||
).statusCode,
|
||||
503,
|
||||
);
|
||||
});
|
||||
@@ -230,6 +230,18 @@ test('fails closed for detached pins, unsupported triggers, invalid budgets and
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await route.handle({
|
||||
projectId: 'default',
|
||||
page: 4,
|
||||
size: 20,
|
||||
maximumRows: 64,
|
||||
}),
|
||||
{
|
||||
statusCode: 200,
|
||||
body: { code: 200, data: { data: [], total: 0 } },
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await route.handle({
|
||||
|
||||
@@ -462,6 +462,34 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
});
|
||||
t.after(() => active.stopAndDrain());
|
||||
|
||||
const health = await request(
|
||||
port,
|
||||
'Bearer ignored-public-credential',
|
||||
'/api/health?t=100',
|
||||
);
|
||||
assert.equal(health.statusCode, 200);
|
||||
assert.equal(health.body.data.status, 'ok');
|
||||
const capabilities = await request(
|
||||
port,
|
||||
'Bearer ignored-public-credential',
|
||||
'/api/v3/capabilities',
|
||||
);
|
||||
assert.equal(capabilities.statusCode, 200);
|
||||
assert.equal(capabilities.body.capabilities.deployment.profile, 'edge');
|
||||
assert.equal(capabilities.body.capabilities.panel.legacyMutations, false);
|
||||
const panelUser = await request(port, `Bearer ${TOKEN}`, '/api/user?t=101');
|
||||
assert.equal(panelUser.statusCode, 200);
|
||||
assert.equal(panelUser.body.data.username, 'local-api-user');
|
||||
assert.equal(panelUser.body.data.ql3.credentialPersistence, 'memory_only');
|
||||
const panelConfig = await request(
|
||||
port,
|
||||
`Bearer ${TOKEN}`,
|
||||
'/api/system/config?t=102',
|
||||
);
|
||||
assert.equal(panelConfig.statusCode, 200);
|
||||
assert.equal(panelConfig.body.data.info.panelTitle, 'QingLong 3.0');
|
||||
assert.equal(panelConfig.body.data.ql3.limits.cronRows, 64);
|
||||
|
||||
const accepted = await request(port, `Bearer ${TOKEN}`);
|
||||
assert.equal(accepted.statusCode, 200);
|
||||
assert.equal(accepted.body.run.id, RUN_ID);
|
||||
@@ -1132,6 +1160,7 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
'run.get', 'run.list', 'run.events.list', 'run.steps.list',
|
||||
'run.cancel', 'task.authoring.read', 'task.create', 'task.get',
|
||||
'task.list', 'task.start', 'task.update', 'run.log.read',
|
||||
'panel.system.config.get', 'panel.user.get',
|
||||
'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update',
|
||||
'secret.create', 'secret.list'
|
||||
)
|
||||
@@ -1140,6 +1169,8 @@ test('serves an authenticated Run through one real SQLite authority and durable
|
||||
.all()
|
||||
.map(({ operation_id, outcome }) => `${operation_id}:${outcome}`),
|
||||
[
|
||||
'panel.system.config.get:allowed',
|
||||
'panel.user.get:allowed',
|
||||
'run.cancel:allowed',
|
||||
'run.cancel:allowed',
|
||||
'run.events.list:allowed',
|
||||
|
||||
Reference in New Issue
Block a user