mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 04:31:52 +08:00
feat: advance browser agent integration workflows
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
BrowserAuthorizationBaseline,
|
||||
BrowserTransformExecution,
|
||||
BrowserTransformProfile,
|
||||
} from '@/types/models';
|
||||
import type { BrowserTransformReplayDraft } from '@/features/browser-transform/replay-draft';
|
||||
import {
|
||||
assertAuthorizationLogicalProtocol,
|
||||
assertAuthorizationLogicalPacketStructure,
|
||||
authorizationTransformOutputDestinations,
|
||||
buildAuthorizationLogicalRequestBinding,
|
||||
replaceAuthorizationLogicalResource,
|
||||
} from './logical-binding';
|
||||
|
||||
const executeBrowserTransform = vi.fn();
|
||||
|
||||
vi.mock('wxt/browser', () => {
|
||||
const event = { addListener: vi.fn() };
|
||||
return {
|
||||
browser: {
|
||||
tabs: { onRemoved: event, onCreated: event },
|
||||
webNavigation: {
|
||||
onBeforeNavigate: event,
|
||||
onCommitted: event,
|
||||
onDOMContentLoaded: event,
|
||||
onCompleted: event,
|
||||
onHistoryStateUpdated: event,
|
||||
onReferenceFragmentUpdated: event,
|
||||
onErrorOccurred: event,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/features/browser-transform/service', () => ({
|
||||
executeBrowserTransform: (...args: unknown[]) => executeBrowserTransform(...args),
|
||||
getBrowserTransformProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
function base64(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
function comparisonKey(): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(32).fill(23)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function profile(outputs = ['body.encryptedData', 'header.Content-Type']): BrowserTransformProfile {
|
||||
return {
|
||||
id: 'profile-left',
|
||||
name: '登录请求加密',
|
||||
enabled: true,
|
||||
target: { tabId: 11, frameId: 0, documentId: 'document-left' },
|
||||
isolationContextId: 'browser-profile:store-left',
|
||||
cookieStoreId: 'store-left',
|
||||
origin: 'https://example.test',
|
||||
match: { methods: ['POST'], urlPattern: '*/api/login' },
|
||||
request: {
|
||||
enabled: true,
|
||||
nodes: outputs.map((destination, index) => ({
|
||||
id: `output-${index}`,
|
||||
name: destination,
|
||||
kind: 'output.write' as const,
|
||||
destination,
|
||||
source: { nodeId: 'callable' },
|
||||
encoding: 'text' as const,
|
||||
})),
|
||||
},
|
||||
response: { enabled: false, nodes: [] },
|
||||
failMode: 'closed',
|
||||
maxConcurrency: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function baseline(): BrowserAuthorizationBaseline {
|
||||
return {
|
||||
version: 1,
|
||||
id: 'baseline-left',
|
||||
deviceId: 'device-left',
|
||||
installationId: 'installation-left',
|
||||
isolationContextId: 'browser-profile:store-left',
|
||||
cookieStoreId: 'store-left',
|
||||
origin: 'https://example.test',
|
||||
grantId: 'grant-left',
|
||||
target: { tabId: 11, frameId: 0, documentId: 'document-left' },
|
||||
authContextReference: { kind: 'handle', id: 'auth-left' },
|
||||
networkRequestId: 'request-left',
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: 'https://example.test/api/login',
|
||||
path: '/api/login',
|
||||
contentType: 'application/x-www-form-urlencoded',
|
||||
actionFingerprint: `sha256:${'a'.repeat(64)}`,
|
||||
headerNames: ['Host', 'Content-Type', 'Cookie'],
|
||||
fields: [{
|
||||
location: 'body',
|
||||
path: 'body.encryptedData',
|
||||
valueType: 'string',
|
||||
byteLength: 32,
|
||||
valueFingerprint: `workspace-hmac-sha256:${'b'.repeat(64)}`,
|
||||
category: 'unknown',
|
||||
}],
|
||||
},
|
||||
createdAt: 1,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
function draft(): BrowserTransformReplayDraft {
|
||||
return {
|
||||
version: 1,
|
||||
profileId: 'profile-left',
|
||||
direction: 'request',
|
||||
origin: 'https://example.test',
|
||||
method: 'POST',
|
||||
url: 'https://example.test/api/login',
|
||||
headers: '{"Content-Type":"application/json"}',
|
||||
body: '{"username":"alice","orderId":"order-a"}',
|
||||
updatedAt: 3,
|
||||
};
|
||||
}
|
||||
|
||||
describe('authorization logical plaintext binding', () => {
|
||||
beforeEach(() => {
|
||||
executeBrowserTransform.mockReset();
|
||||
});
|
||||
|
||||
it('rejects a logical replay that changes the observed GraphQL operation', () => {
|
||||
const observed = baseline().request;
|
||||
observed.protocol = 'graphql';
|
||||
observed.operationFingerprint = `sha256:${'1'.repeat(64)}`;
|
||||
observed.operationNames = ['Order'];
|
||||
const logical = {
|
||||
...observed,
|
||||
operationFingerprint: `sha256:${'2'.repeat(64)}`,
|
||||
operationNames: ['CancelOrder'],
|
||||
};
|
||||
|
||||
expect(() => assertAuthorizationLogicalProtocol(observed, logical)).toThrow(
|
||||
'GraphQL operation 与线上基线不一致',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows a logical GraphQL envelope when the encrypted wire baseline has no protocol metadata', () => {
|
||||
const observed = baseline().request;
|
||||
const logical = {
|
||||
...observed,
|
||||
protocol: 'graphql' as const,
|
||||
operationFingerprint: `sha256:${'1'.repeat(64)}`,
|
||||
operationNames: ['Order'],
|
||||
};
|
||||
|
||||
expect(() => assertAuthorizationLogicalProtocol(observed, logical)).not.toThrow();
|
||||
});
|
||||
|
||||
it('binds private plaintext field metadata only after the generated wire shape matches', async () => {
|
||||
executeBrowserTransform.mockResolvedValue({
|
||||
profileId: 'profile-left',
|
||||
direction: 'request',
|
||||
url: 'https://example.test/api/login',
|
||||
bodyBase64: base64('encryptedData=ciphertext'),
|
||||
setHeaders: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }],
|
||||
removeHeaders: [],
|
||||
logicalInput: {},
|
||||
logicalOutput: {},
|
||||
nodeDurations: [],
|
||||
nodeTrace: [],
|
||||
fieldChanges: [],
|
||||
durationMs: 1,
|
||||
} satisfies BrowserTransformExecution);
|
||||
const raw = base64([
|
||||
'POST /api/login HTTP/1.1',
|
||||
'Host: example.test',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Cookie: session=identity-a',
|
||||
'',
|
||||
'encryptedData=observed-ciphertext',
|
||||
].join('\r\n'));
|
||||
|
||||
const binding = await buildAuthorizationLogicalRequestBinding({
|
||||
baseline: baseline(),
|
||||
rawRequestBase64: raw,
|
||||
profile: profile(),
|
||||
draft: draft(),
|
||||
comparisonKey: comparisonKey(),
|
||||
});
|
||||
|
||||
expect(binding.request.fields).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
location: 'body',
|
||||
path: 'body.orderId',
|
||||
valueType: 'string',
|
||||
category: 'resource',
|
||||
}),
|
||||
]));
|
||||
expect(binding.outputDestinations).toEqual(['body.encryptedData', 'header.content-type']);
|
||||
expect(binding.bindingFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
expect(JSON.stringify(binding)).not.toContain('order-a');
|
||||
expect(JSON.stringify(binding)).not.toContain('alice');
|
||||
});
|
||||
|
||||
it('keeps a multi-output AES plus RSA envelope tied to one logical business object', async () => {
|
||||
executeBrowserTransform.mockResolvedValue({
|
||||
profileId: 'profile-left',
|
||||
direction: 'request',
|
||||
url: 'https://example.test/api/login',
|
||||
bodyBase64: base64([
|
||||
'encryptedData=aes-ciphertext',
|
||||
'encryptedKey=rsa-wrapped-key',
|
||||
'encryptedIv=rsa-wrapped-iv',
|
||||
].join('&')),
|
||||
setHeaders: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }],
|
||||
removeHeaders: [],
|
||||
logicalInput: {},
|
||||
logicalOutput: {},
|
||||
nodeDurations: [],
|
||||
nodeTrace: [],
|
||||
fieldChanges: [],
|
||||
durationMs: 1,
|
||||
} satisfies BrowserTransformExecution);
|
||||
const raw = base64([
|
||||
'POST /api/login HTTP/1.1',
|
||||
'Host: example.test',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Cookie: session=identity-a',
|
||||
'',
|
||||
[
|
||||
'encryptedData=observed-aes-ciphertext',
|
||||
'encryptedKey=observed-rsa-key',
|
||||
'encryptedIv=observed-rsa-iv',
|
||||
].join('&'),
|
||||
].join('\r\n'));
|
||||
|
||||
const binding = await buildAuthorizationLogicalRequestBinding({
|
||||
baseline: baseline(),
|
||||
rawRequestBase64: raw,
|
||||
profile: profile([
|
||||
'body.encryptedData',
|
||||
'body.encryptedKey',
|
||||
'body.encryptedIv',
|
||||
'header.Content-Type',
|
||||
]),
|
||||
draft: draft(),
|
||||
comparisonKey: comparisonKey(),
|
||||
});
|
||||
|
||||
expect(binding.outputDestinations).toEqual([
|
||||
'body.encryptedData',
|
||||
'body.encryptedIv',
|
||||
'body.encryptedKey',
|
||||
'header.content-type',
|
||||
]);
|
||||
expect(binding.request.fields).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ path: 'body.orderId', category: 'resource' }),
|
||||
expect.objectContaining({ path: 'body.username' }),
|
||||
]));
|
||||
expect(binding.validation.proofLevel).toBe('structure');
|
||||
});
|
||||
|
||||
it('rejects a gateway whose generated serialization does not match the captured request', async () => {
|
||||
executeBrowserTransform.mockResolvedValue({
|
||||
profileId: 'profile-left',
|
||||
direction: 'request',
|
||||
url: 'https://example.test/api/login',
|
||||
bodyBase64: base64('{"encryptedData":"ciphertext"}'),
|
||||
setHeaders: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
removeHeaders: [],
|
||||
logicalInput: {},
|
||||
logicalOutput: {},
|
||||
nodeDurations: [],
|
||||
nodeTrace: [],
|
||||
fieldChanges: [],
|
||||
durationMs: 1,
|
||||
} satisfies BrowserTransformExecution);
|
||||
const raw = base64([
|
||||
'POST /api/login HTTP/1.1',
|
||||
'Host: example.test',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'',
|
||||
'encryptedData=observed-ciphertext',
|
||||
].join('\r\n'));
|
||||
|
||||
await expect(buildAuthorizationLogicalRequestBinding({
|
||||
baseline: baseline(),
|
||||
rawRequestBase64: raw,
|
||||
profile: profile(),
|
||||
draft: draft(),
|
||||
comparisonKey: comparisonKey(),
|
||||
})).rejects.toThrow('结构不一致');
|
||||
});
|
||||
|
||||
it('rejects compressed request bodies because their logical structure cannot be proven', async () => {
|
||||
executeBrowserTransform.mockResolvedValue({
|
||||
profileId: 'profile-left',
|
||||
direction: 'request',
|
||||
url: 'https://example.test/api/login',
|
||||
bodyBase64: base64('encryptedData=ciphertext'),
|
||||
setHeaders: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }],
|
||||
removeHeaders: [],
|
||||
logicalInput: {},
|
||||
logicalOutput: {},
|
||||
nodeDurations: [],
|
||||
nodeTrace: [],
|
||||
fieldChanges: [],
|
||||
durationMs: 1,
|
||||
} satisfies BrowserTransformExecution);
|
||||
const raw = base64([
|
||||
'POST /api/login HTTP/1.1',
|
||||
'Host: example.test',
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Content-Encoding: gzip',
|
||||
'',
|
||||
'encryptedData=observed-ciphertext',
|
||||
].join('\r\n'));
|
||||
|
||||
await expect(buildAuthorizationLogicalRequestBinding({
|
||||
baseline: baseline(),
|
||||
rawRequestBase64: raw,
|
||||
profile: profile(),
|
||||
draft: draft(),
|
||||
comparisonKey: comparisonKey(),
|
||||
})).rejects.toThrow('压缩或编码后的请求 Body');
|
||||
});
|
||||
|
||||
it('rejects a conditionally changed output envelope during later matrix compilation', () => {
|
||||
const observed = {
|
||||
method: 'POST',
|
||||
url: 'https://example.test/api/login',
|
||||
headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' }],
|
||||
bodyBase64: base64('encryptedData=observed-ciphertext'),
|
||||
};
|
||||
const generated = {
|
||||
...observed,
|
||||
bodyBase64: base64('encryptedData=generated-ciphertext&unexpected=side-channel'),
|
||||
};
|
||||
|
||||
expect(() => assertAuthorizationLogicalPacketStructure(
|
||||
generated,
|
||||
observed,
|
||||
)).toThrow('Body 字段与类型结构');
|
||||
});
|
||||
|
||||
it('replaces one explicit JSON plaintext field without touching its siblings', () => {
|
||||
const packet = {
|
||||
method: 'POST',
|
||||
url: 'https://example.test/api/orders',
|
||||
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
bodyBase64: base64('{"orderId":"order-a","note":"keep"}'),
|
||||
};
|
||||
const replaced = replaceAuthorizationLogicalResource({
|
||||
packet,
|
||||
selector: { source: 'logical', location: 'body', path: 'body.orderId' },
|
||||
replacement: 'order-b',
|
||||
});
|
||||
|
||||
expect(JSON.parse(new TextDecoder().decode(
|
||||
Uint8Array.from(atob(replaced.bodyBase64), (character) => character.charCodeAt(0)),
|
||||
))).toEqual({ orderId: 'order-b', note: 'keep' });
|
||||
});
|
||||
|
||||
it('preserves the primitive type of a numeric logical resource', () => {
|
||||
const packet = {
|
||||
method: 'POST',
|
||||
url: 'https://example.test/graphql',
|
||||
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
||||
bodyBase64: base64('{"variables":{"orderId":42},"query":"query Order { order { id } }"}'),
|
||||
};
|
||||
const replaced = replaceAuthorizationLogicalResource({
|
||||
packet,
|
||||
selector: {
|
||||
source: 'logical',
|
||||
location: 'body',
|
||||
path: 'body.variables.orderId',
|
||||
},
|
||||
replacement: 84,
|
||||
});
|
||||
|
||||
expect(JSON.parse(new TextDecoder().decode(
|
||||
Uint8Array.from(atob(replaced.bodyBase64), (character) => character.charCodeAt(0)),
|
||||
)).variables.orderId).toBe(84);
|
||||
expect(() => replaceAuthorizationLogicalResource({
|
||||
packet,
|
||||
selector: {
|
||||
source: 'logical',
|
||||
location: 'body',
|
||||
path: 'body.variables.orderId',
|
||||
},
|
||||
replacement: '84',
|
||||
})).toThrow('不能改变字段类型');
|
||||
});
|
||||
|
||||
it('refuses profiles that attempt to synthesize authentication headers', () => {
|
||||
expect(() => authorizationTransformOutputDestinations(
|
||||
profile(['header.Authorization']),
|
||||
)).toThrow('认证 Header');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user