Files
NianToB/tests/unit/yinian-control-plane.test.ts

673 lines
23 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { MockYinianControlPlane } from '@electron/yinian/mock-control-plane';
import { createMemoryYinianStorage } from '@electron/yinian/storage';
import { HttpYinianControlPlane } from '@electron/yinian/http-control-plane';
import { getYinianControlPlane, resetYinianControlPlaneForTests } from '@electron/yinian/control-plane';
import { yinianContractFixtures } from '../fixtures/yinian-server-contract';
type RouteKey = `${string} ${string}`;
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), {
status: init.status ?? 200,
headers: {
'Content-Type': 'application/json',
...(init.headers ?? {}),
},
});
}
function installFetchRoutes(routes: Partial<Record<RouteKey, unknown | (() => unknown)>>) {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const requestUrl = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(requestUrl);
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
const key = `${method} ${url.pathname}` as RouteKey;
const route = routes[key];
if (!route) {
return jsonResponse({
error: {
code: 'NOT_FOUND',
message: `Unexpected request: ${key}`,
},
}, { status: 404 });
}
return jsonResponse(typeof route === 'function' ? route() : route);
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
resetYinianControlPlaneForTests();
});
describe('MockYinianControlPlane', () => {
it('persists saved login credentials in the YINIAN storage namespace', async () => {
const storage = createMemoryYinianStorage();
await storage.setSavedCredentials({
account: '15685275886-1',
password: '123456',
rememberPassword: true,
updatedAt: 1,
});
await expect(storage.getSavedCredentials()).resolves.toEqual({
account: '15685275886-1',
password: '123456',
rememberPassword: true,
updatedAt: 1,
});
await storage.clearSavedCredentials();
await expect(storage.getSavedCredentials()).resolves.toBeUndefined();
});
it('starts unauthenticated and logs in with password', async () => {
const controlPlane = new MockYinianControlPlane({ storage: createMemoryYinianStorage() });
await expect(controlPlane.getSessionState()).resolves.toEqual({ authenticated: false });
const session = await controlPlane.loginWithPassword({
account: 'admin',
password: 'demo-password',
});
expect(session.authenticated).toBe(true);
expect(session.user.name).toBe('NIANXX 实施');
expect(session.hotels).toHaveLength(2);
});
it('switches workspace and returns matching config snapshot', async () => {
const controlPlane = new MockYinianControlPlane({ storage: createMemoryYinianStorage() });
await controlPlane.loginWithPassword({ account: 'ops@example.com', password: 'secret' });
const switched = await controlPlane.switchHotel('workspace_shanghai_growth');
expect(switched.currentHotelId).toBe('workspace_shanghai_growth');
const config = await controlPlane.getConfigSnapshot();
expect(config.hotel.name).toBe('智念增长组织空间');
expect(config.entitlements.map((skill) => skill.skillId)).toContain('daily-report');
});
it('syncs entitled skills into local registry', async () => {
const controlPlane = new MockYinianControlPlane({ storage: createMemoryYinianStorage() });
await controlPlane.loginWithPassword({ account: 'ops@example.com', password: 'secret' });
const firstSync = await controlPlane.syncSkills();
expect(firstSync.skills).toHaveLength(4);
expect(firstSync.skills.every((skill) => skill.status === 'installed')).toBe(true);
const secondSync = await controlPlane.syncSkills();
expect(secondSync.skills.every((skill) => skill.status === 'skipped')).toBe(true);
const localSkills = await controlPlane.listLocalSkills();
expect(localSkills.map((skill) => skill.skillId)).toContain('data-check');
});
it('restores mock session and registry from storage', async () => {
const storage = createMemoryYinianStorage();
const firstControlPlane = new MockYinianControlPlane({ storage });
await firstControlPlane.loginWithPassword({ account: 'admin', password: 'demo-password' });
await firstControlPlane.syncSkills();
const restoredControlPlane = new MockYinianControlPlane({ storage });
const session = await restoredControlPlane.restoreSession();
expect(session.authenticated).toBe(true);
if (!session.authenticated) return;
expect(session.currentHotelId).toBe('workspace_hangzhou_ops');
const localSkills = await restoredControlPlane.listLocalSkills();
expect(localSkills).toHaveLength(4);
});
it('clears session and current workspace skill registry on logout', async () => {
const storage = createMemoryYinianStorage();
const controlPlane = new MockYinianControlPlane({ storage });
await controlPlane.loginWithSms({ phone: '13800000000', code: '123456' });
await controlPlane.syncSkills();
await controlPlane.logout();
await expect(controlPlane.restoreSession()).resolves.toEqual({ authenticated: false });
expect(await controlPlane.getSkillRegistry('workspace_hangzhou_ops')).toBeUndefined();
});
it('rejects config access before login', async () => {
const controlPlane = new MockYinianControlPlane({ storage: createMemoryYinianStorage() });
await expect(controlPlane.getConfigSnapshot()).rejects.toThrow('请先登录');
});
});
describe('HttpYinianControlPlane', () => {
it('logs in and normalizes contract v0 config responses', async () => {
const storage = createMemoryYinianStorage();
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test/',
storage,
});
const fetchMock = installFetchRoutes({
'POST /auth/oauth2/token': yinianContractFixtures.login,
'GET /config/sync': yinianContractFixtures.config,
});
const session = await controlPlane.loginWithPassword({
account: 'ops@example.com',
password: 'secret',
captchaCode: '5678nianxx',
randomStr: '333e6825-760c-4c1a-8b56-eb9539b43dbd',
});
expect(session.authenticated).toBe(true);
expect(session.user.name).toBe('王管理员');
expect(session.currentHotelId).toBe('workspace_hangzhou_ops');
expect(session.accessTokenExpiresAt).toBe(1777188600000);
const persisted = await storage.getSession();
expect(persisted?.mode).toBe('http');
expect(persisted?.refreshToken).toBe('refresh_demo');
const config = await controlPlane.getConfigSnapshot();
expect(config.serverTime).toBe(1777188000000);
expect(config.hotel.id).toBe('workspace_hangzhou_ops');
expect(config.featureFlags).toEqual({
skillsSync: true,
advancedSettings: false,
});
expect(config.uiPolicy).toEqual({
defaultPage: 'today',
showAdvancedSettings: true,
});
expect(config.entitlements[0]).toMatchObject({
skillId: 'daily-report',
category: 'reporting',
triggers: ['scheduled', 'manual'],
lastRunAt: 1777184400000,
});
expect(config.entitlements[1]).toMatchObject({
skillId: 'unknown-category-skill',
category: 'ops-automation',
});
expect(config.notificationChannels.map((channel) => channel.source)).toEqual(['nianxx', 'kernel']);
expect(fetchMock).not.toHaveBeenCalledWith('https://api.example.test/auth/me', expect.anything());
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.test/auth/oauth2/token',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic Y3VzdG9tUEM6Y3VzdG9tUEM=',
}),
body: expect.stringContaining('grant_type=password'),
}),
);
const tokenCall = fetchMock.mock.calls.find(([url]) => url === 'https://api.example.test/auth/oauth2/token');
const tokenBody = new URLSearchParams(String(tokenCall?.[1]?.body ?? ''));
expect(Object.fromEntries(tokenBody)).toMatchObject({
grant_type: 'password',
scope: 'server',
username: 'ops@example.com',
password: 'secret',
randomStr: '333e6825-760c-4c1a-8b56-eb9539b43dbd',
code: '5678nianxx',
});
expect(tokenBody.has('clientId')).toBe(false);
expect(tokenBody.has('client_id')).toBe(false);
});
it('syncs manifest into workspace registry and marks same-version sync as skipped', async () => {
const storage = createMemoryYinianStorage();
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage,
});
installFetchRoutes({
'POST /auth/oauth2/token': yinianContractFixtures.login,
'GET /auth/me': yinianContractFixtures.me,
'GET /skills/manifest': yinianContractFixtures.manifest,
});
await controlPlane.loginWithPassword({ account: 'ops@example.com', password: 'secret' });
const firstSync = await controlPlane.syncSkills();
expect(firstSync.skills.map((skill) => skill.status)).toEqual(['installed', 'disabled']);
expect(firstSync.skills[0]).toMatchObject({
skillId: 'data-check',
bundleSha256: 'sha256-demo',
source: 'nianxx',
});
const secondSync = await controlPlane.syncSkills();
expect(secondSync.skills.map((skill) => skill.status)).toEqual(['skipped', 'disabled']);
const registry = await controlPlane.getSkillRegistry('workspace_hangzhou_ops');
expect(registry && 'skills' in registry ? registry.skills : []).toHaveLength(2);
});
it('supports configurable enterprise app manifest endpoints and app-shaped fields', async () => {
vi.stubEnv('YINIAN_SKILLS_MANIFEST_PATH', '/enterprise/spaces/{workspaceId}/apps?includeBundle=true');
const storage = createMemoryYinianStorage();
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage,
});
const fetchMock = installFetchRoutes({
'POST /auth/oauth2/token': yinianContractFixtures.login,
'GET /enterprise/spaces/workspace_hangzhou_ops/apps': {
data: {
records: [
{
appId: 'daily-report',
appName: '日报生成助手',
currentVersion: '2.1.0',
status: 'enabled',
bundleHash: 'sha256-cloudclaw-demo',
},
{
app_id: 'disabled-helper',
app_name: '停用应用',
app_version: '1.0.0',
status: 'disabled',
},
],
},
},
});
await controlPlane.loginWithPassword({ account: 'ops@example.com', password: 'secret' });
const result = await controlPlane.syncSkills();
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.test/enterprise/spaces/workspace_hangzhou_ops/apps?includeBundle=true',
expect.anything(),
);
expect(result.skills).toEqual([
expect.objectContaining({
skillId: 'daily-report',
name: '日报生成助手',
version: '2.1.0',
status: 'installed',
bundleSha256: 'sha256-cloudclaw-demo',
}),
expect.objectContaining({
skillId: 'disabled-helper',
name: '停用应用',
status: 'disabled',
}),
]);
});
it('stores an empty registry when the enterprise app manifest endpoint is not implemented yet', async () => {
const storage = createMemoryYinianStorage();
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage,
});
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const requestUrl = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(requestUrl);
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
if (method === 'POST' && url.pathname === '/auth/oauth2/token') {
return jsonResponse(yinianContractFixtures.login);
}
if (method === 'GET' && url.pathname === '/skills/manifest') {
return jsonResponse({ message: 'No static resource skills/manifest.' }, { status: 404 });
}
return jsonResponse({ message: `Unexpected request: ${method} ${url.pathname}` }, { status: 404 });
}));
await controlPlane.loginWithPassword({ account: 'ops@example.com', password: 'secret' });
const result = await controlPlane.syncSkills();
expect(result.skills).toEqual([]);
const registry = await controlPlane.getSkillRegistry('workspace_hangzhou_ops');
expect(registry && 'skills' in registry ? registry.skills : []).toEqual([]);
});
it('restores an HTTP session through refresh token exchange', async () => {
const storage = createMemoryYinianStorage();
await storage.setSession({
mode: 'http',
user: { id: 'old_user', name: '旧用户' },
hotels: [],
currentHotelId: 'workspace_hangzhou_ops',
accessTokenExpiresAt: 1,
refreshToken: 'refresh_demo',
updatedAt: 1,
});
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage,
});
installFetchRoutes({
'POST /auth/oauth2/token': yinianContractFixtures.refresh,
});
const session = await controlPlane.restoreSession();
expect(session.authenticated).toBe(true);
if (!session.authenticated) return;
expect(session.user.id).toBe('user_ops_001');
expect((await storage.getSession())?.refreshToken).toBe('refresh_rotated');
});
it('clears persisted HTTP session when refresh fails', async () => {
const storage = createMemoryYinianStorage();
await storage.setSession({
mode: 'http',
user: { id: 'old_user', name: '旧用户' },
hotels: [],
currentHotelId: 'workspace_hangzhou_ops',
accessTokenExpiresAt: 1,
refreshToken: 'expired_refresh',
updatedAt: 1,
});
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage,
});
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(yinianContractFixtures.error, { status: 401 })));
await expect(controlPlane.restoreSession()).resolves.toEqual({ authenticated: false });
await expect(storage.getSession()).resolves.toBeUndefined();
});
it('surfaces server error messages', async () => {
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage: createMemoryYinianStorage(),
});
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(yinianContractFixtures.error, { status: 401 })));
await expect(controlPlane.loginWithPassword({
account: 'ops@example.com',
password: 'bad-secret',
})).rejects.toThrow('Session expired');
});
it('turns network failures into readable connection errors', async () => {
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://onefeel.brother7.cn/ingress',
storage: createMemoryYinianStorage(),
});
vi.stubGlobal('fetch', vi.fn(async () => {
throw new TypeError('fetch failed', {
cause: Object.assign(new Error('getaddrinfo ENOTFOUND onefeel.brother7.cn'), {
code: 'ENOTFOUND',
syscall: 'getaddrinfo',
hostname: 'onefeel.brother7.cn',
}),
});
}));
await expect(controlPlane.loginWithPassword({
account: 'ops@example.com',
password: 'secret',
})).rejects.toThrow('域名 onefeel.brother7.cn 解析失败');
});
it('does not send SMS login requests in HTTP mode', async () => {
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage: createMemoryYinianStorage(),
});
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(controlPlane.loginWithSms({ phone: '13800000000', code: '123456' }))
.rejects.toThrow('仅支持账号密码登录');
expect(fetchMock).not.toHaveBeenCalled();
});
it('accepts data-wrapped login responses with session payload', async () => {
const storage = createMemoryYinianStorage();
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage,
});
const fetchMock = installFetchRoutes({
'POST /auth/oauth2/token': {
success: true,
data: {
access_token: 'access_wrapped',
refresh_token: 'refresh_wrapped',
expires_in: 3600,
user: yinianContractFixtures.me.user,
workspaces: yinianContractFixtures.me.hotels,
current_workspace_id: 'workspace_shanghai_growth',
},
},
});
const session = await controlPlane.loginWithPassword({
account: 'ops@example.com',
password: 'secret',
});
expect(session.authenticated).toBe(true);
expect(session.currentHotelId).toBe('workspace_shanghai_growth');
expect(session.hotels).toHaveLength(2);
expect((await storage.getSession())?.refreshToken).toBe('refresh_wrapped');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('accepts oauth token responses with user_info and no workspace list', async () => {
const storage = createMemoryYinianStorage();
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage,
});
installFetchRoutes({
'POST /auth/oauth2/token': {
access_token: 'access_onefeel',
refresh_token: 'refresh_onefeel',
expires_in: '9999',
username: '15685275886-1',
user_info: {
id: '1965411380338970626',
username: '15685275886-1',
tenantId: '1',
name: '15685275886-1',
},
},
});
const session = await controlPlane.loginWithPassword({
account: '15685275886-1',
password: '123456',
captchaCode: '5678nianxx',
randomStr: '333e6825-760c-4c1a-8b56-eb9539b43dbd',
});
expect(session.authenticated).toBe(true);
expect(session.user.id).toBe('1965411380338970626');
expect(session.hotels).toEqual([
expect.objectContaining({
id: 'service_1',
name: '15685275886-1的组织空间',
}),
]);
expect(session.currentHotelId).toBe('service_1');
expect(session.accessTokenExpiresAt).toBeGreaterThan(Date.now());
expect((await storage.getSession())?.refreshToken).toBe('refresh_onefeel');
});
it('creates a local organization session when oauth token response has no profile payload', async () => {
const storage = createMemoryYinianStorage();
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage,
});
installFetchRoutes({
'POST /auth/oauth2/token': {
access_token: 'access_without_profile',
refresh_token: 'refresh_without_profile',
expires_in: 3600,
},
});
const session = await controlPlane.loginWithPassword({
account: 'tmt1',
password: '123456',
});
expect(session.authenticated).toBe(true);
expect(session.user).toMatchObject({
id: 'tmt1',
name: 'tmt1',
});
expect(session.hotels).toEqual([
expect.objectContaining({
id: 'service_default',
name: 'tmt1的组织空间',
}),
]);
expect(session.currentHotelId).toBe('service_default');
expect((await storage.getSession())?.refreshToken).toBe('refresh_without_profile');
});
it('uses JWT claims as a fallback profile for oauth token responses', async () => {
const storage = createMemoryYinianStorage();
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage,
});
const claims = Buffer.from(JSON.stringify({
user_id: 'jwt-user-1',
user_name: 'jwt-user',
tenant_id: 'tenant-9',
tenant_name: 'JWT 组织',
})).toString('base64url');
installFetchRoutes({
'POST /auth/oauth2/token': {
access_token: `header.${claims}.signature`,
refresh_token: 'refresh_jwt',
expires_in: 3600,
},
});
const session = await controlPlane.loginWithPassword({
account: 'fallback-account',
password: '123456',
});
expect(session.user).toMatchObject({
id: 'jwt-user-1',
name: 'jwt-user',
});
expect(session.hotels).toEqual([
expect.objectContaining({
id: 'service_tenant-9',
name: 'JWT 组织',
}),
]);
});
it('reports HTTP server status through health endpoint', async () => {
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage: createMemoryYinianStorage(),
});
installFetchRoutes({
'GET /health': {
data: {
server_time: 1777188000000,
version: '2026.04.26',
message: 'ok',
},
},
});
await expect(controlPlane.getServerStatus()).resolves.toMatchObject({
mode: 'http',
apiBaseUrl: 'https://api.example.test',
reachable: true,
serverTime: 1777188000000,
version: '2026.04.26',
message: 'ok',
});
});
it('loads image captcha with randomStr', async () => {
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage: createMemoryYinianStorage(),
});
installFetchRoutes({
'GET /auth/code/image': {
data: {
randomStr: 'captcha-key',
imageBase64: 'aW1hZ2U=',
mimeType: 'image/png',
},
},
});
await expect(controlPlane.createImageCaptcha('captcha-key')).resolves.toMatchObject({
randomStr: 'captcha-key',
imageBase64: 'aW1hZ2U=',
mimeType: 'image/png',
});
});
it('loads binary image captcha responses', async () => {
const controlPlane = new HttpYinianControlPlane({
apiBaseUrl: 'https://api.example.test',
storage: createMemoryYinianStorage(),
});
vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([137, 80, 78, 71]), {
status: 200,
headers: {
'Content-Type': 'image/png',
},
})));
await expect(controlPlane.createImageCaptcha('captcha-key')).resolves.toMatchObject({
randomStr: 'captcha-key',
imageBase64: 'iVBORw==',
mimeType: 'image/png',
});
});
});
describe('getYinianControlPlane', () => {
it('uses the configured default server instead of mock mode when env URL is missing', async () => {
vi.stubEnv('YINIAN_API_BASE_URL', '');
vi.stubEnv('YINIAN_CONTROL_PLANE_MODE', '');
vi.stubEnv('CLAWX_E2E', '');
installFetchRoutes({
'GET /ingress/health': { serverTime: 1700000000000, version: 'test' },
});
const controlPlane = getYinianControlPlane();
await expect(controlPlane.getServerStatus()).resolves.toMatchObject({
mode: 'http',
apiBaseUrl: 'https://onefeel.brother7.cn/ingress',
reachable: true,
});
});
it('uses mock mode only when explicitly requested', async () => {
vi.stubEnv('YINIAN_API_BASE_URL', '');
vi.stubEnv('YINIAN_CONTROL_PLANE_MODE', 'mock');
vi.stubEnv('CLAWX_E2E', '');
const controlPlane = getYinianControlPlane();
await expect(controlPlane.getServerStatus()).resolves.toMatchObject({
mode: 'mock',
reachable: true,
});
});
});