397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
328 lines
13 KiB
JavaScript
328 lines
13 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
||
import { verifyReceipt as receiptVerify } from './receipt-sign.mjs';
|
||
|
||
describe('R-31: расписка одобрения подписана в своём домене (approval)', () => {
|
||
it('домен-агностичная проверка отвергает, approval-проверка принимает', () => {
|
||
const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 1 });
|
||
const signed = signApprovalRecord(rec, 'receipt-key-xyz');
|
||
expect(receiptVerify(signed, 'receipt-key-xyz')).toBe(false);
|
||
expect(verifyApprovalRecord(signed, 'receipt-key-xyz')).toBe(true);
|
||
});
|
||
});
|
||
|
||
import {
|
||
stripInvisible,
|
||
normalizeAnswer,
|
||
normalizeCommand,
|
||
STOP_KEYWORDS,
|
||
isStopAnswer,
|
||
detectStopWithFallback,
|
||
parseAskUserResult,
|
||
matchesApproval,
|
||
detectOtherSocialEng,
|
||
buildApprovalRecord,
|
||
toApprovalRecord,
|
||
} from './askuser-answer-parser.mjs';
|
||
import { signApprovalRecord, verifyApprovalRecord } from './askuser-answer-parser.mjs';
|
||
|
||
describe('signed approval records (P10-c)', () => {
|
||
const KEY = 'receipt-key-xyz';
|
||
it('signApprovalRecord attaches a valid sig', () => {
|
||
const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 100 });
|
||
const signed = signApprovalRecord(rec, KEY);
|
||
expect(signed.sig).toMatch(/^[0-9a-f]{64}$/);
|
||
expect(verifyApprovalRecord(signed, KEY)).toBe(true);
|
||
});
|
||
it('verifyApprovalRecord false when the approved pattern is tampered', () => {
|
||
const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 100 });
|
||
const signed = signApprovalRecord(rec, KEY);
|
||
expect(verifyApprovalRecord({ ...signed, approved_action_pattern: 'git push --force' }, KEY)).toBe(false);
|
||
});
|
||
it('verifyApprovalRecord false for an unsigned record (forged by controller)', () => {
|
||
const rec = buildApprovalRecord({ kind: 'approve_git_operation', pattern: 'git commit -m x', sessionId: 'S', nowMs: 100 });
|
||
expect(verifyApprovalRecord(rec, KEY)).toBe(false);
|
||
});
|
||
it('signApprovalRecord returns sig:null when no key (fail-closed downstream)', () => {
|
||
const rec = buildApprovalRecord({ kind: 'x', pattern: 'git status', sessionId: 'S', nowMs: 1 });
|
||
const signed = signApprovalRecord(rec, null);
|
||
expect(signed.sig).toBe(null);
|
||
expect(verifyApprovalRecord(signed, KEY)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / stripInvisible (E33)', () => {
|
||
it('strips ZWSP inside a word', () => {
|
||
// "вы<ZWSP>полнение" → "выполнение"
|
||
expect(stripInvisible('выполнение')).toBe('выполнение');
|
||
});
|
||
|
||
it('strips ZWNJ, ZWJ, RTL override, BOM, soft hyphen', () => {
|
||
expect(stripInvisible('abcd')).toBe('abcd');
|
||
});
|
||
|
||
it('leaves normal text untouched', () => {
|
||
expect(stripInvisible('обычный текст')).toBe('обычный текст');
|
||
});
|
||
|
||
it('handles non-string by returning empty string', () => {
|
||
expect(stripInvisible(null)).toBe('');
|
||
expect(stripInvisible(undefined)).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / normalizeAnswer', () => {
|
||
it('lowercases, strips invisible, collapses whitespace, trims', () => {
|
||
expect(normalizeAnswer(' СТОП сейчас ')).toBe('стоп сейчас');
|
||
});
|
||
|
||
it('returns empty string for non-string', () => {
|
||
expect(normalizeAnswer(42)).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / normalizeCommand (E34)', () => {
|
||
it('collapses internal whitespace runs to single space', () => {
|
||
expect(normalizeCommand('git rebase main')).toBe('git rebase main');
|
||
});
|
||
|
||
it('trims leading/trailing whitespace, keeps case', () => {
|
||
expect(normalizeCommand(' git Rebase main ')).toBe('git Rebase main');
|
||
});
|
||
|
||
it('returns empty string for non-string', () => {
|
||
expect(normalizeCommand(null)).toBe('');
|
||
});
|
||
});
|
||
|
||
|
||
describe('askuser-answer-parser / STOP_KEYWORDS (S27)', () => {
|
||
it('includes core Russian + English stop tokens', () => {
|
||
for (const kw of ['стоп', 'отмена', 'хватит', 'не надо', 'cancel', 'abort', 'stop', 'halt', 'quit']) {
|
||
expect(STOP_KEYWORDS).toContain(kw);
|
||
}
|
||
});
|
||
|
||
it('has at least 40 entries (S27 +25 variants)', () => {
|
||
expect(STOP_KEYWORDS.length).toBeGreaterThanOrEqual(40);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / isStopAnswer', () => {
|
||
it('matches exact single-word stop', () => {
|
||
expect(isStopAnswer('стоп')).toBe(true);
|
||
expect(isStopAnswer('Отмена')).toBe(true);
|
||
});
|
||
|
||
it('matches stop word surrounded by other tokens', () => {
|
||
expect(isStopAnswer('нет, стоп пожалуйста')).toBe(true);
|
||
});
|
||
|
||
it('matches multi-word stop phrase', () => {
|
||
expect(isStopAnswer('на этом всё')).toBe(true);
|
||
expect(isStopAnswer('всё, поехали назад')).toBe(true);
|
||
});
|
||
|
||
it('matches even with invisible Unicode injected', () => {
|
||
expect(isStopAnswer('стоп')).toBe(true);
|
||
});
|
||
|
||
it('does not match a normal approval answer', () => {
|
||
expect(isStopAnswer('да, выполняй вариант A')).toBe(false);
|
||
});
|
||
|
||
it('does not false-match substring inside unrelated word', () => {
|
||
// "нетворкинг" contains "нет" as substring but not as token
|
||
expect(isStopAnswer('нетворкинг событие')).toBe(false);
|
||
});
|
||
|
||
it('returns false for non-string', () => {
|
||
expect(isStopAnswer(null)).toBe(false);
|
||
});
|
||
|
||
it('matches a stop token with a trailing comma', () => {
|
||
expect(isStopAnswer('нет, это лишнее')).toBe(true);
|
||
expect(isStopAnswer('стоп.')).toBe(true);
|
||
});
|
||
|
||
it('still matches multi-word phrase without the comma', () => {
|
||
expect(isStopAnswer('всё поехали назад')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / detectStopWithFallback', () => {
|
||
it('returns true on keyword match without calling LLM', async () => {
|
||
let called = false;
|
||
const judge = async () => { called = true; return true; };
|
||
const r = await detectStopWithFallback('отмена', { llmJudge: judge });
|
||
expect(r).toBe(true);
|
||
expect(called).toBe(false);
|
||
});
|
||
|
||
it('default stub returns false for ambiguous text', async () => {
|
||
const r = await detectStopWithFallback('может не сейчас');
|
||
expect(r).toBe(false);
|
||
});
|
||
|
||
it('uses injected llmJudge for ambiguous text', async () => {
|
||
const judge = async (text) => text.includes('не сейчас');
|
||
const r = await detectStopWithFallback('может не сейчас', { llmJudge: judge });
|
||
expect(r).toBe(true);
|
||
});
|
||
|
||
it('fails closed-safe (false) if llmJudge throws', async () => {
|
||
const judge = async () => { throw new Error('llm down'); };
|
||
const r = await detectStopWithFallback('что-то непонятное', { llmJudge: judge });
|
||
expect(r).toBe(false);
|
||
});
|
||
});
|
||
|
||
|
||
describe('askuser-answer-parser / parseAskUserResult', () => {
|
||
it('extracts a single selected answer label', () => {
|
||
const r = parseAskUserResult({
|
||
answers: { 'Какой вариант?': 'Вариант A' },
|
||
});
|
||
expect(r.selections).toEqual(['Вариант A']);
|
||
expect(r.stop).toBe(false);
|
||
});
|
||
|
||
it('handles multiSelect (array of selections) and flattens all text', () => {
|
||
const r = parseAskUserResult({
|
||
answers: { 'Что включить?': ['Фича 1', 'Фича 2'] },
|
||
});
|
||
expect(r.selections).toEqual(['Фича 1', 'Фича 2']);
|
||
});
|
||
|
||
it('pulls annotations notes into allText (approval source S15)', () => {
|
||
const r = parseAskUserResult({
|
||
answers: { Q: 'Other' },
|
||
annotations: { Q: { notes: 'git rebase main' } },
|
||
});
|
||
expect(r.allText).toContain('git rebase main');
|
||
});
|
||
|
||
it('flags stop when a selection is a stop keyword', () => {
|
||
const r = parseAskUserResult({ answers: { Q: 'стоп' } });
|
||
expect(r.stop).toBe(true);
|
||
});
|
||
|
||
it('returns empty structure for malformed input', () => {
|
||
const r = parseAskUserResult(null);
|
||
expect(r.selections).toEqual([]);
|
||
expect(r.allText).toEqual([]);
|
||
expect(r.stop).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / matchesApproval (E34)', () => {
|
||
it('matches identical commands', () => {
|
||
expect(matchesApproval('git rebase main', 'git rebase main')).toBe(true);
|
||
});
|
||
|
||
it('matches across whitespace differences', () => {
|
||
expect(matchesApproval('git rebase main', 'git rebase main')).toBe(true);
|
||
});
|
||
|
||
it('does not match different commands', () => {
|
||
expect(matchesApproval('git rebase main', 'git reset --hard main')).toBe(false);
|
||
});
|
||
|
||
it('is case-sensitive (commands differ by case are different)', () => {
|
||
expect(matchesApproval('git rebase Main', 'git rebase main')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / detectOtherSocialEng (E29 + v4.0 RU)', () => {
|
||
it('flags English "type in Other" suggestion', () => {
|
||
expect(detectOtherSocialEng('please type in Other: recovery').flagged).toBe(true);
|
||
});
|
||
|
||
it('flags Russian "впиши в Другое" variants', () => {
|
||
expect(detectOtherSocialEng('впиши в "Другое" recovery').flagged).toBe(true);
|
||
expect(detectOtherSocialEng('нажми "Другое" и впиши команду').flagged).toBe(true);
|
||
expect(detectOtherSocialEng('укажи в графе Другое').flagged).toBe(true);
|
||
});
|
||
|
||
it('does not flag innocent text', () => {
|
||
expect(detectOtherSocialEng('выбери подходящий вариант').flagged).toBe(false);
|
||
});
|
||
|
||
it('handles non-string', () => {
|
||
expect(detectOtherSocialEng(null).flagged).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('askuser-answer-parser / buildApprovalRecord', () => {
|
||
it('builds a pure record with normalized pattern', () => {
|
||
const rec = buildApprovalRecord({
|
||
kind: 'approve_git_operation',
|
||
pattern: 'git rebase main',
|
||
sessionId: 'sess-1',
|
||
nowMs: 1000,
|
||
});
|
||
expect(rec.kind).toBe('approve_git_operation');
|
||
expect(rec.approved_action_pattern).toBe('git rebase main');
|
||
expect(rec.session_id).toBe('sess-1');
|
||
expect(rec.approved_at_ms).toBe(1000);
|
||
});
|
||
});
|
||
|
||
describe('toApprovalRecord (Stream H Task 6 — schema sync)', () => {
|
||
it('returns null for non-git-pattern answer', () => {
|
||
expect(toApprovalRecord('cancel', { question: 'continue?' })).toBeNull();
|
||
});
|
||
it('returns {type, command, ts} for approved git push pattern', () => {
|
||
const r = toApprovalRecord('подтверди git push origin main', {
|
||
question: 'разрешить git push?',
|
||
nowMs: 1700000000000,
|
||
});
|
||
expect(r).toMatchObject({ type: 'approve_git_operation', command: 'git push origin main', ts: 1700000000000 });
|
||
});
|
||
it('returns {type, command, ts} for approved git commit pattern', () => {
|
||
const r = toApprovalRecord('git commit -m "fix: x"', {
|
||
question: 'разрешить коммит?',
|
||
nowMs: 1700000000000,
|
||
});
|
||
expect(r).toMatchObject({ type: 'approve_git_operation', command: 'git commit -m "fix: x"', ts: 1700000000000 });
|
||
});
|
||
it('uses current ms when nowMs not provided', () => {
|
||
const before = Date.now();
|
||
const r = toApprovalRecord('git add tools/x.mjs', { question: 'разрешить add?' });
|
||
const after = Date.now();
|
||
expect(r).not.toBeNull();
|
||
expect(r.ts).toBeGreaterThanOrEqual(before);
|
||
expect(r.ts).toBeLessThanOrEqual(after);
|
||
});
|
||
it('returns null for non-string answer', () => {
|
||
expect(toApprovalRecord(null)).toBeNull();
|
||
expect(toApprovalRecord(undefined)).toBeNull();
|
||
expect(toApprovalRecord(42)).toBeNull();
|
||
});
|
||
});
|
||
|
||
import { toFloorEscapeRecord } from './askuser-answer-parser.mjs';
|
||
describe('askuser-answer-parser toFloorEscapeRecord', () => {
|
||
it('распознаёт FLOOR-ESCAPE токен → запись floor_escape', () => {
|
||
const r = toFloorEscapeRecord('Разрешаю FLOOR-ESCAPE: bash:git push --force', { nowMs: 5 });
|
||
expect(r).toEqual({ type: 'floor_escape', action: 'bash:git push --force', ts: 5 });
|
||
});
|
||
it('без токена → null', () => {
|
||
expect(toFloorEscapeRecord('просто да', { nowMs: 5 })).toBe(null);
|
||
});
|
||
it('stop-намерение → null', () => {
|
||
expect(toFloorEscapeRecord('отмена FLOOR-ESCAPE: bash:x', { nowMs: 5 })).toBe(null);
|
||
});
|
||
});
|
||
|
||
// sub-plan E Task 2 (✅O13): skill-канон lowercase — обе стороны совпадают
|
||
describe('toFloorEscapeRecord — skill-канон (✅O13)', () => {
|
||
it('skill:-действие приводится к нижнему регистру (совпадает с canonicalAction)', () => {
|
||
const r = toFloorEscapeRecord('FLOOR-ESCAPE: skill:Audit-Context-Building:Audit-Context-Building', { nowMs: 1 });
|
||
expect(r.action).toBe('skill:audit-context-building:audit-context-building');
|
||
});
|
||
it('write:-действие НЕ меняет регистр (поведение как раньше)', () => {
|
||
const r = toFloorEscapeRecord('FLOOR-ESCAPE: write:c:/Моя/Path.md', { nowMs: 1 });
|
||
expect(r.action).toBe('write:c:/Моя/Path.md');
|
||
});
|
||
});
|