371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import { CaddyReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/caddy-readonly.adapter';
|
|
import { JenkinsReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/jenkins-readonly.adapter';
|
|
import { KubernetesReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/kubernetes-readonly.adapter';
|
|
import { MihomoReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/mihomo-readonly.adapter';
|
|
import { TencentCloudReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/tencent-cloud-readonly.adapter';
|
|
import { WireguardReadonlyAdapter } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/wireguard-readonly.adapter';
|
|
import type {
|
|
EnvironmentReadonlyHttpClient,
|
|
EnvironmentReadonlyHttpResponse,
|
|
} from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/adapters/environment-readonly-http.client';
|
|
import { EnvironmentDashboardConfigService } from '../../../../src/modules/admin/platform-config/environment-dashboard/infrastructure/environment-dashboard-config.service';
|
|
|
|
type ReadonlyHttpMock = jest.Mocked<EnvironmentReadonlyHttpClient>;
|
|
|
|
function httpResponse(
|
|
body: unknown,
|
|
status = 200,
|
|
): EnvironmentReadonlyHttpResponse {
|
|
return {
|
|
bodyPreview: typeof body === 'string' ? body : JSON.stringify(body),
|
|
headers: { 'content-type': 'application/json' },
|
|
observedAt: '2026-06-18T01:00:00.000Z',
|
|
status,
|
|
statusText: status >= 200 && status < 400 ? 'OK' : 'ERROR',
|
|
};
|
|
}
|
|
|
|
function createHttpMock(): ReadonlyHttpMock {
|
|
return {
|
|
get: jest.fn(),
|
|
head: jest.fn(),
|
|
request: jest.fn(),
|
|
} as unknown as ReadonlyHttpMock;
|
|
}
|
|
|
|
describe('environment remote readonly adapters', () => {
|
|
const config = new EnvironmentDashboardConfigService({});
|
|
|
|
it.each([
|
|
['Jenkins', new JenkinsReadonlyAdapter(config)],
|
|
['K8s', new KubernetesReadonlyAdapter(config)],
|
|
['Tencent Cloud', new TencentCloudReadonlyAdapter(config)],
|
|
['Caddy', new CaddyReadonlyAdapter(config)],
|
|
['WireGuard', new WireguardReadonlyAdapter(config)],
|
|
['Mihomo', new MihomoReadonlyAdapter(config)],
|
|
])(
|
|
'%s returns unwired evidence when config is missing',
|
|
async (_, adapter) => {
|
|
const signal = await adapter.inspect();
|
|
|
|
expect(signal.status).toBe('unwired');
|
|
expect(signal.sourceKind).toBe('unwired');
|
|
expect(signal.evidence[0].summary).toContain('缺少只读观测配置');
|
|
},
|
|
);
|
|
|
|
it('keeps credential keys visible as missing config without exposing values', async () => {
|
|
const tencentSignal = await new TencentCloudReadonlyAdapter(
|
|
config,
|
|
).inspect();
|
|
const mihomoSignal = await new MihomoReadonlyAdapter(config).inspect();
|
|
|
|
expect(tencentSignal.evidence[0].metadata?.missingConfigKeys).toContain(
|
|
'ENV_DASHBOARD_TENCENT_SECRET_KEY',
|
|
);
|
|
expect(mihomoSignal.evidence[0].metadata?.missingConfigKeys).toContain(
|
|
'ENV_DASHBOARD_R4SE_MIHOMO_SECRET',
|
|
);
|
|
});
|
|
|
|
it('Jenkins reads the last build API and returns sanitized live evidence', async () => {
|
|
const http = createHttpMock();
|
|
http.get.mockResolvedValue(
|
|
httpResponse({
|
|
building: false,
|
|
duration: 16000,
|
|
number: 42,
|
|
result: 'SUCCESS',
|
|
url: 'https://jenkins.example/job/main/42/',
|
|
}),
|
|
);
|
|
const adapter = new JenkinsReadonlyAdapter(
|
|
new EnvironmentDashboardConfigService({
|
|
ENV_DASHBOARD_JENKINS_JOB: 'KT-Template/KT-Template-API/main',
|
|
ENV_DASHBOARD_JENKINS_TOKEN: 'jenkins-token',
|
|
ENV_DASHBOARD_JENKINS_URL: 'https://jenkins.example',
|
|
ENV_DASHBOARD_JENKINS_USERNAME: 'codex',
|
|
}),
|
|
http,
|
|
);
|
|
|
|
const signal = await adapter.inspect();
|
|
|
|
expect(http.get).toHaveBeenCalledWith(
|
|
'https://jenkins.example/job/KT-Template/job/KT-Template-API/job/main/lastBuild/api/json',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: expect.stringMatching(/^Basic /),
|
|
}),
|
|
}),
|
|
);
|
|
expect(signal.status).toBe('ok');
|
|
expect(signal.sourceKind).toBe('live');
|
|
expect(signal.evidence[0]).toEqual(
|
|
expect.objectContaining({
|
|
sourceKind: 'live',
|
|
summary: expect.stringContaining('SUCCESS'),
|
|
}),
|
|
);
|
|
expect(JSON.stringify(signal)).not.toContain('jenkins-token');
|
|
});
|
|
|
|
it('Kubernetes reads deployment and pod APIs with readonly bearer auth', async () => {
|
|
const http = createHttpMock();
|
|
http.get
|
|
.mockResolvedValueOnce(
|
|
httpResponse({
|
|
spec: { replicas: 2 },
|
|
status: {
|
|
availableReplicas: 2,
|
|
readyReplicas: 2,
|
|
replicas: 2,
|
|
updatedReplicas: 2,
|
|
},
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
httpResponse({
|
|
items: [
|
|
{
|
|
status: {
|
|
conditions: [{ status: 'True', type: 'Ready' }],
|
|
phase: 'Running',
|
|
},
|
|
},
|
|
{
|
|
status: {
|
|
conditions: [{ status: 'True', type: 'Ready' }],
|
|
phase: 'Running',
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const adapter = new KubernetesReadonlyAdapter(
|
|
new EnvironmentDashboardConfigService({
|
|
ENV_DASHBOARD_K8S_API_SERVER: 'https://k8s.example',
|
|
ENV_DASHBOARD_K8S_BEARER_TOKEN: 'k8s-token',
|
|
ENV_DASHBOARD_K8S_DEPLOYMENT: 'kt-template-online-api',
|
|
ENV_DASHBOARD_K8S_LABEL_SELECTOR: 'app=kt-template-online-api',
|
|
ENV_DASHBOARD_K8S_NAMESPACE: 'kt-prod',
|
|
}),
|
|
http,
|
|
);
|
|
|
|
const signal = await adapter.inspect();
|
|
|
|
expect(http.get).toHaveBeenNthCalledWith(
|
|
1,
|
|
'https://k8s.example/apis/apps/v1/namespaces/kt-prod/deployments/kt-template-online-api',
|
|
expect.objectContaining({
|
|
headers: { Authorization: 'Bearer k8s-token' },
|
|
}),
|
|
);
|
|
expect(http.get).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://k8s.example/api/v1/namespaces/kt-prod/pods',
|
|
expect.objectContaining({
|
|
headers: { Authorization: 'Bearer k8s-token' },
|
|
params: { labelSelector: 'app=kt-template-online-api' },
|
|
}),
|
|
);
|
|
expect(signal.status).toBe('ok');
|
|
expect(signal.evidence[0].metadata).toEqual(
|
|
expect.objectContaining({
|
|
podReadyCount: 2,
|
|
readyReplicas: 2,
|
|
}),
|
|
);
|
|
expect(JSON.stringify(signal)).not.toContain('k8s-token');
|
|
});
|
|
|
|
it('Tencent Cloud reads CVM DescribeInstances through the official SDK', async () => {
|
|
const describeInstances = jest.fn().mockResolvedValue({
|
|
InstanceSet: [
|
|
{
|
|
CPU: 2,
|
|
InstanceId: 'ins-test',
|
|
InstanceName: 'api-prod',
|
|
InstanceState: 'RUNNING',
|
|
Memory: 4096,
|
|
},
|
|
],
|
|
RequestId: 'req-1',
|
|
TotalCount: 1,
|
|
});
|
|
const createClient = jest.fn(() => ({
|
|
DescribeInstances: describeInstances,
|
|
}));
|
|
const adapter = new TencentCloudReadonlyAdapter(
|
|
new EnvironmentDashboardConfigService({
|
|
ENV_DASHBOARD_TENCENT_CLOUD_ENABLED: 'true',
|
|
ENV_DASHBOARD_TENCENT_INSTANCE_ID: 'ins-test',
|
|
ENV_DASHBOARD_TENCENT_REGION: 'ap-guangzhou',
|
|
ENV_DASHBOARD_TENCENT_SECRET_ID: 'secret-id',
|
|
ENV_DASHBOARD_TENCENT_SECRET_KEY: 'secret-key',
|
|
}),
|
|
createClient,
|
|
);
|
|
|
|
const signal = await adapter.inspect();
|
|
|
|
expect(createClient).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
credential: {
|
|
secretId: 'secret-id',
|
|
secretKey: 'secret-key',
|
|
},
|
|
region: 'ap-guangzhou',
|
|
}),
|
|
);
|
|
expect(describeInstances).toHaveBeenCalledWith({
|
|
InstanceIds: ['ins-test'],
|
|
Limit: 1,
|
|
});
|
|
expect(signal.status).toBe('ok');
|
|
expect(signal.evidence[0].metadata).toEqual(
|
|
expect.objectContaining({
|
|
instanceState: 'RUNNING',
|
|
totalCount: 1,
|
|
}),
|
|
);
|
|
expect(JSON.stringify(signal)).not.toContain('secret-key');
|
|
});
|
|
|
|
it('Caddy checks public HEAD and admin GET without unsafe methods', async () => {
|
|
const http = createHttpMock();
|
|
http.head.mockResolvedValue(httpResponse('', 200));
|
|
http.get.mockResolvedValue(httpResponse({ apps: { http: {} } }, 200));
|
|
const adapter = new CaddyReadonlyAdapter(
|
|
new EnvironmentDashboardConfigService({
|
|
ENV_DASHBOARD_CADDY_ADMIN_URL: 'http://caddy-admin.example',
|
|
ENV_DASHBOARD_CADDY_PUBLIC_URL: 'https://kt.example',
|
|
}),
|
|
http,
|
|
);
|
|
|
|
const signal = await adapter.inspect();
|
|
|
|
expect(http.head).toHaveBeenCalledWith('https://kt.example');
|
|
expect(http.get).toHaveBeenCalledWith(
|
|
'http://caddy-admin.example/config/',
|
|
);
|
|
expect(signal.status).toBe('ok');
|
|
expect(signal.evidence[0].metadata).toEqual(
|
|
expect.objectContaining({
|
|
adminConfigured: true,
|
|
publicStatus: 200,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('WireGuard checks only configured readonly health endpoints', async () => {
|
|
const http = createHttpMock();
|
|
http.get
|
|
.mockResolvedValueOnce(httpResponse({ status: 'ok' }, 200))
|
|
.mockResolvedValueOnce(httpResponse({ status: 'ok' }, 200));
|
|
const adapter = new WireguardReadonlyAdapter(
|
|
new EnvironmentDashboardConfigService({
|
|
ENV_DASHBOARD_R4SE_WIREGUARD_HEALTH_URL:
|
|
'https://r4se.example/wg/health',
|
|
ENV_DASHBOARD_TENCENT_WIREGUARD_HEALTH_URL:
|
|
'https://tencent.example/wg/health',
|
|
}),
|
|
http,
|
|
);
|
|
|
|
const signal = await adapter.inspect();
|
|
|
|
expect(http.get).toHaveBeenNthCalledWith(
|
|
1,
|
|
'https://tencent.example/wg/health',
|
|
);
|
|
expect(http.get).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://r4se.example/wg/health',
|
|
);
|
|
expect(signal.status).toBe('ok');
|
|
expect(signal.evidence[0].metadata).toEqual(
|
|
expect.objectContaining({
|
|
endpointCount: 2,
|
|
reachableCount: 2,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('Mihomo reads version, configs, and proxies without mutating selectors', async () => {
|
|
const http = createHttpMock();
|
|
http.get
|
|
.mockResolvedValueOnce(httpResponse({ version: '1.18.0' }))
|
|
.mockResolvedValueOnce(httpResponse({ mode: 'rule' }))
|
|
.mockResolvedValueOnce(
|
|
httpResponse({ proxies: { DIRECT: {}, Proxy: {} } }),
|
|
);
|
|
const adapter = new MihomoReadonlyAdapter(
|
|
new EnvironmentDashboardConfigService({
|
|
ENV_DASHBOARD_R4SE_MIHOMO_SECRET: 'mihomo-secret',
|
|
ENV_DASHBOARD_R4SE_MIHOMO_URL: 'http://mihomo.example',
|
|
}),
|
|
http,
|
|
);
|
|
|
|
const signal = await adapter.inspect();
|
|
|
|
expect(http.get).toHaveBeenNthCalledWith(
|
|
1,
|
|
'http://mihomo.example/version',
|
|
expect.objectContaining({
|
|
headers: { Authorization: 'Bearer mihomo-secret' },
|
|
}),
|
|
);
|
|
expect(http.get).toHaveBeenNthCalledWith(
|
|
2,
|
|
'http://mihomo.example/configs',
|
|
expect.objectContaining({
|
|
headers: { Authorization: 'Bearer mihomo-secret' },
|
|
}),
|
|
);
|
|
expect(http.get).toHaveBeenNthCalledWith(
|
|
3,
|
|
'http://mihomo.example/proxies',
|
|
expect.objectContaining({
|
|
headers: { Authorization: 'Bearer mihomo-secret' },
|
|
}),
|
|
);
|
|
expect(http.head).not.toHaveBeenCalled();
|
|
expect(signal.status).toBe('ok');
|
|
expect(signal.evidence[0].metadata).toEqual(
|
|
expect.objectContaining({
|
|
mode: 'rule',
|
|
proxyCount: 2,
|
|
version: '1.18.0',
|
|
}),
|
|
);
|
|
expect(JSON.stringify(signal)).not.toContain('mihomo-secret');
|
|
});
|
|
|
|
it('returns degraded error evidence when a configured readonly probe fails', async () => {
|
|
const http = createHttpMock();
|
|
http.get.mockRejectedValue(new Error('connect ECONNREFUSED'));
|
|
const adapter = new JenkinsReadonlyAdapter(
|
|
new EnvironmentDashboardConfigService({
|
|
ENV_DASHBOARD_JENKINS_JOB: 'main',
|
|
ENV_DASHBOARD_JENKINS_URL: 'https://jenkins.example',
|
|
}),
|
|
http,
|
|
);
|
|
|
|
const signal = await adapter.inspect();
|
|
|
|
expect(signal.status).toBe('degraded');
|
|
expect(signal.sourceKind).toBe('derived');
|
|
expect(signal.evidence[0]).toEqual(
|
|
expect.objectContaining({
|
|
sourceKind: 'derived',
|
|
summary: 'connect ECONNREFUSED',
|
|
}),
|
|
);
|
|
});
|
|
});
|