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', () => { // "выполнение" → "выполнение" expect(stripInvisible('вы​полнение')).toBe('выполнение'); }); it('strips ZWNJ, ZWJ, RTL override, BOM, soft hyphen', () => { expect(stripInvisible('a‌b‍c‮­d')).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'); }); });