mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-21 19:10:41 +08:00
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type {
|
|
ActiveTabInfo,
|
|
BrowserPageCallable,
|
|
BrowserProfileInferenceCandidate,
|
|
} from '@/types/models';
|
|
import { createBrowserTransformProfileInput, pairedBrowserTransformCandidate } from './profile-draft';
|
|
import { executeTransformDirection } from './mapping';
|
|
|
|
const tab: ActiveTabInfo = {
|
|
id: 7,
|
|
windowId: 1,
|
|
title: 'Encrypted API',
|
|
url: 'https://example.test/app',
|
|
incognito: false,
|
|
};
|
|
|
|
const callable: BrowserPageCallable = {
|
|
id: 'decrypt-callable',
|
|
name: '页面 AES 解密',
|
|
kind: 'recorded-call',
|
|
operation: 'AES.decrypt',
|
|
origin: 'https://example.test',
|
|
target: { tabId: 7, frameId: 0, documentId: 'document-1' },
|
|
lifecycle: 'document',
|
|
execution: { resultMode: 'sync', timeoutMs: 8_000 },
|
|
inputSlots: [{
|
|
id: 'data', name: 'data', index: 0, role: 'data', dataType: 'string', required: true, retained: false,
|
|
}],
|
|
output: { dataType: 'string', encoding: 'utf8', shape: 'value', paths: [] },
|
|
provenance: { eventId: 'decrypt-event' },
|
|
createdAt: 1,
|
|
};
|
|
|
|
const responseCandidate = {
|
|
id: 'candidate-response',
|
|
recordingId: 'recording-1',
|
|
traceId: 'trace-1',
|
|
target: callable.target,
|
|
direction: 'response',
|
|
request: {
|
|
eventId: 'response-event', method: 'GET', url: 'https://example.test/api/profile', bodyFormat: 'json',
|
|
destination: 'body.encryptedData', serialization: 'json-field',
|
|
mappings: [{ sourceEventId: 'decrypt-event', destination: 'body.encryptedData', serialization: 'json-field' }],
|
|
},
|
|
source: {
|
|
eventId: 'decrypt-event', kind: 'crypto', operation: 'AES.decrypt', callHandleId: 'decrypt-handle',
|
|
arguments: callable.inputSlots.map((slot) => ({
|
|
index: slot.index, role: slot.role, dataType: slot.dataType, replaceable: true, retained: false,
|
|
})),
|
|
destination: 'body.encryptedData', serialization: 'json-field',
|
|
},
|
|
sources: [],
|
|
status: 'ready',
|
|
confidence: { score: 100, level: 'high' },
|
|
summary: 'ready',
|
|
flow: [],
|
|
pipeline: [],
|
|
evidence: [],
|
|
missing: [],
|
|
aiContext: {
|
|
valuePolicy: 'metadata-only',
|
|
request: { eventId: 'response-event', method: 'GET', url: 'https://example.test/api/profile' },
|
|
source: { eventId: 'decrypt-event', kind: 'crypto', operation: 'AES.decrypt', arguments: [] },
|
|
sources: [], evidenceIds: [], requiredDecision: 'none',
|
|
},
|
|
} satisfies BrowserProfileInferenceCandidate;
|
|
|
|
describe('browser transform profile draft', () => {
|
|
it('reads only the captured form field for a single string input and rejects ambiguous fields', async () => {
|
|
const candidate = { ...responseCandidate, direction: 'request' as const,
|
|
request: { ...responseCandidate.request, bodyFormat: 'form' as const, serialization: 'form-field' as const } };
|
|
const packet = { method: 'POST', url: candidate.request.url,
|
|
headers: [{ name: 'Content-Type', value: 'application/x-www-form-urlencoded; charset=utf-8' }],
|
|
bodyBase64: btoa('encryptedData={"username":"admin","password":"admin123"}') };
|
|
const profile = createBrowserTransformProfileInput(tab, undefined, callable, candidate, packet);
|
|
expect(profile.request.nodes.filter((node) => node.kind === 'context.read')).toMatchObject([{ path: 'body.encryptedData' }]);
|
|
let received: unknown[] = [];
|
|
await executeTransformDirection('test', 'request', profile.request, packet, async (callableId, args) => {
|
|
received = args;
|
|
return { callableId, type: 'string', preview: 'cipher', value: 'cipher', durationMs: 1 };
|
|
});
|
|
expect(received).toEqual(['{"username":"admin","password":"admin123"}']);
|
|
for (const body of ['username=admin', 'encryptedData=a&encryptedData=b']) {
|
|
expect(() => createBrowserTransformProfileInput(tab, undefined, callable, candidate, { ...packet, bodyBase64: btoa(body) })).toThrow(/input_paths/);
|
|
}
|
|
const jsonPacket = { ...packet, headers: [{ name: 'Content-Type', value: 'application/json' }], bodyBase64: btoa('{"username":"admin"}') };
|
|
expect(createBrowserTransformProfileInput(tab, undefined, callable, candidate, jsonPacket).request.nodes[0]).toMatchObject({ path: 'body' });
|
|
});
|
|
it('compiles an inferred response decryptor into the response direction', () => {
|
|
const profile = createBrowserTransformProfileInput(tab, undefined, callable, responseCandidate);
|
|
|
|
expect(profile.request).toEqual({ enabled: false, nodes: [] });
|
|
expect(profile.response.enabled).toBe(true);
|
|
expect(profile.response.nodes).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ kind: 'context.read', path: 'body.encryptedData' }),
|
|
expect.objectContaining({ kind: 'page.call', callableId: callable.id }),
|
|
expect.objectContaining({ kind: 'output.write', destination: 'body' }),
|
|
]));
|
|
expect(profile.match).toEqual({ methods: ['GET'], urlPattern: '*/api/profile' });
|
|
expect(profile.name).toContain('浏览器协议网关');
|
|
});
|
|
|
|
it('maps response ciphertext, key, and iv into one decrypt callable', async () => {
|
|
const dynamicCallable: BrowserPageCallable = {
|
|
...callable,
|
|
inputSlots: [
|
|
callable.inputSlots[0],
|
|
{ id: 'key', name: 'key', index: 1, role: 'key', dataType: 'string', required: true, retained: false },
|
|
{ id: 'iv', name: 'iv', index: 2, role: 'iv', dataType: 'string', required: true, retained: false },
|
|
],
|
|
};
|
|
const dynamicCandidate: BrowserProfileInferenceCandidate = {
|
|
...responseCandidate,
|
|
request: {
|
|
...responseCandidate.request,
|
|
mappings: [
|
|
{ sourceEventId: 'decrypt-event', destination: 'body.message', serialization: 'json-field' },
|
|
{ sourceEventId: 'decrypt-event', destination: 'body.key', serialization: 'json-field' },
|
|
{ sourceEventId: 'decrypt-event', destination: 'body.iv', serialization: 'json-field' },
|
|
],
|
|
},
|
|
};
|
|
const profile = createBrowserTransformProfileInput(tab, undefined, dynamicCallable, dynamicCandidate);
|
|
const packet = {
|
|
method: 'POST', url: dynamicCandidate.request.url,
|
|
headers: [{ name: 'Content-Type', value: 'application/json' }],
|
|
bodyBase64: btoa(JSON.stringify({ message: 'cipher', key: '0011', iv: 'aabb' })),
|
|
};
|
|
let received: unknown[] = [];
|
|
|
|
await executeTransformDirection('test', 'response', profile.response, packet, async (callableId, args) => {
|
|
received = args;
|
|
return { callableId, type: 'string', preview: 'plain', value: 'plain', durationMs: 1 };
|
|
});
|
|
|
|
expect(received).toEqual(['cipher', '0011', 'aabb']);
|
|
});
|
|
|
|
it('pairs one browser transaction and compiles both directions into one profile', () => {
|
|
const requestCandidate: BrowserProfileInferenceCandidate = {
|
|
...responseCandidate,
|
|
id: 'candidate-request',
|
|
transactionId: 'fetch-1',
|
|
direction: 'request',
|
|
request: { ...responseCandidate.request, eventId: 'request-event', method: 'POST' },
|
|
};
|
|
const pairedResponse: BrowserProfileInferenceCandidate = {
|
|
...responseCandidate,
|
|
transactionId: 'fetch-1',
|
|
request: { ...responseCandidate.request, method: 'POST' },
|
|
};
|
|
const encryptCallable: BrowserPageCallable = {
|
|
...callable,
|
|
id: 'encrypt-callable',
|
|
name: '页面 AES 加密',
|
|
operation: 'AES.encrypt',
|
|
provenance: { eventId: 'encrypt-event' },
|
|
};
|
|
|
|
expect(pairedBrowserTransformCandidate([requestCandidate, pairedResponse], requestCandidate)?.id)
|
|
.toBe(pairedResponse.id);
|
|
const profile = createBrowserTransformProfileInput(
|
|
tab,
|
|
undefined,
|
|
encryptCallable,
|
|
requestCandidate,
|
|
undefined,
|
|
{ candidate: pairedResponse, callable },
|
|
);
|
|
|
|
expect(profile.request.enabled).toBe(true);
|
|
expect(profile.response.enabled).toBe(true);
|
|
expect(profile.request.nodes).toContainEqual(expect.objectContaining({ kind: 'page.call', callableId: encryptCallable.id }));
|
|
expect(profile.response.nodes).toContainEqual(expect.objectContaining({ kind: 'page.call', callableId: callable.id }));
|
|
expect(profile.name).toBe('POST */api/profile 浏览器协议网关');
|
|
});
|
|
|
|
it('does not guess when one trace contains multiple opposite candidates for the same route', () => {
|
|
const requestCandidate = { ...responseCandidate, id: 'request', direction: 'request' as const };
|
|
const responseA = { ...responseCandidate, id: 'response-a' };
|
|
const responseB = { ...responseCandidate, id: 'response-b' };
|
|
|
|
expect(pairedBrowserTransformCandidate([requestCandidate, responseA, responseB], requestCandidate)).toBeUndefined();
|
|
expect(() => pairedBrowserTransformCandidate([requestCandidate, responseA, responseB], requestCandidate, true))
|
|
.toThrow('尚未保存单向网关');
|
|
});
|
|
|
|
it('does not pair candidates from different recording sessions', () => {
|
|
const requestCandidate = { ...responseCandidate, id: 'request', transactionId: 'fetch-1', direction: 'request' as const };
|
|
const staleResponse = { ...responseCandidate, id: 'stale-response', transactionId: 'fetch-1', recordingId: 'recording-old' };
|
|
|
|
expect(pairedBrowserTransformCandidate([requestCandidate, staleResponse], requestCandidate)).toBeUndefined();
|
|
});
|
|
|
|
it('does not fall back to the route when transaction IDs disagree', () => {
|
|
const requestCandidate = { ...responseCandidate, id: 'request', transactionId: 'fetch-1', direction: 'request' as const };
|
|
const otherResponse = { ...responseCandidate, id: 'other-response', transactionId: 'fetch-2' };
|
|
|
|
expect(pairedBrowserTransformCandidate([requestCandidate, otherResponse], requestCandidate)).toBeUndefined();
|
|
});
|
|
|
|
it('serializes request-transaction profiles because they mutate one browser session', () => {
|
|
const transactionCallable: BrowserPageCallable = {
|
|
...callable,
|
|
id: 'request-transaction',
|
|
kind: 'request-transaction',
|
|
output: { dataType: 'object', encoding: 'json', shape: 'envelope', paths: ['body.encryptedData'] },
|
|
transaction: {
|
|
version: 2,
|
|
prerequisites: [],
|
|
request: {
|
|
boundary: 'fetch', method: 'POST', url: 'https://example.test/login',
|
|
expectedDestinations: ['body.encryptedData'], bodyFormat: 'json',
|
|
},
|
|
inputMode: 'auto',
|
|
},
|
|
};
|
|
|
|
expect(createBrowserTransformProfileInput(tab, undefined, transactionCallable).maxConcurrency).toBe(1);
|
|
});
|
|
});
|