mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
Revert "test(mana-research): fixture-based tests for Gemini poll-response parser"
This reverts commit c413ab7dd3.
This commit is contained in:
parent
f4c66241ca
commit
c31dcdd66c
5 changed files with 0 additions and 375 deletions
|
|
@ -1,158 +0,0 @@
|
||||||
/**
|
|
||||||
* One-shot at-rest encryption sweep.
|
|
||||||
*
|
|
||||||
* The Phase 2e encryption flip (docs/plans/space-scoped-data-model.md
|
|
||||||
* §2e) turned `enabled: true` on globalTags / tagGroups /
|
|
||||||
* workbenchScenes / aiMissions. Because `decryptRecords` is lenient
|
|
||||||
* (it skips fields that aren't already encrypted), rows written BEFORE
|
|
||||||
* the flip stay readable but remain plaintext at rest — a weakened
|
|
||||||
* security posture if the user's IndexedDB is ever inspected.
|
|
||||||
*
|
|
||||||
* This sweep closes that gap: after login (when the vault is
|
|
||||||
* unlocked) we iterate every row in every table that currently has
|
|
||||||
* encryption enabled AND hasn't been swept before, re-save it through
|
|
||||||
* `encryptRecord`, and mark the table done via a localStorage
|
|
||||||
* sentinel.
|
|
||||||
*
|
|
||||||
* Key design points:
|
|
||||||
*
|
|
||||||
* - **Per-table sentinel**: if a new table flips to enabled:true in
|
|
||||||
* the future, only that table is swept on the next run. Already-
|
|
||||||
* swept tables aren't touched.
|
|
||||||
* - **Change-tracking suppression**: writes inside the sweep go
|
|
||||||
* through `beginApplyingTables()` so the Dexie hook skips the
|
|
||||||
* `_pendingChanges` insert — we don't want to fire 100+ sync pushes
|
|
||||||
* for a re-encryption that never changed field values.
|
|
||||||
* - **Idempotent inside each row**: `encryptRecord` checks
|
|
||||||
* `isEncrypted(value)` before wrapping, so a row with 2 of 3
|
|
||||||
* designated fields already encrypted (partial prior sweep, mixed
|
|
||||||
* boot state) gets only the remaining field wrapped.
|
|
||||||
* - **Fire-and-forget at call site**: the sweep is async and logs
|
|
||||||
* its progress; callers don't await it. A failed sweep is never
|
|
||||||
* fatal to the boot path.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Dexie from 'dexie';
|
|
||||||
import { db, beginApplyingTables } from '../database';
|
|
||||||
import { isVaultUnlocked } from './key-provider';
|
|
||||||
import { ENCRYPTION_REGISTRY } from './registry';
|
|
||||||
import { encryptRecord } from './record-helpers';
|
|
||||||
|
|
||||||
const SENTINEL_PREFIX = 'mana:crypto:at-rest-sweep';
|
|
||||||
const SENTINEL_VERSION = 'v1';
|
|
||||||
|
|
||||||
function sentinelKey(tableName: string): string {
|
|
||||||
return `${SENTINEL_PREFIX}:${tableName}:${SENTINEL_VERSION}:done`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSwept(tableName: string): boolean {
|
|
||||||
if (typeof localStorage === 'undefined') return true; // SSR or test env — skip
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(sentinelKey(tableName)) !== null;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markSwept(tableName: string, rowCount: number): void {
|
|
||||||
if (typeof localStorage === 'undefined') return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
sentinelKey(tableName),
|
|
||||||
JSON.stringify({ at: new Date().toISOString(), rows: rowCount })
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* storage quota — the sweep is a one-time optimisation, not load-bearing */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sweep a single table: re-save every non-deleted row through
|
|
||||||
* `encryptRecord` so any plaintext fields from before the encryption
|
|
||||||
* flip get wrapped. Returns the number of rows touched.
|
|
||||||
*/
|
|
||||||
async function sweepTable(tableName: string): Promise<number> {
|
|
||||||
const rows = (await db.table(tableName).toArray()) as Record<string, unknown>[];
|
|
||||||
if (rows.length === 0) return 0;
|
|
||||||
|
|
||||||
const dispose = beginApplyingTables([tableName]);
|
|
||||||
try {
|
|
||||||
let touched = 0;
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.deletedAt) continue;
|
|
||||||
// encryptRecord mutates in place; isEncrypted() gate inside
|
|
||||||
// means fields already encrypted stay untouched.
|
|
||||||
await encryptRecord(tableName, row);
|
|
||||||
// put() overwrites the row — safe because we just mutated the
|
|
||||||
// same primary key. Dexie's default keyPath is 'id'; every
|
|
||||||
// Mana record schema uses that.
|
|
||||||
await db.table(tableName).put(row);
|
|
||||||
touched++;
|
|
||||||
}
|
|
||||||
return touched;
|
|
||||||
} finally {
|
|
||||||
dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the sweep across every currently-enabled encryption target that
|
|
||||||
* hasn't been swept on this device before. Safe to call on every
|
|
||||||
* unlock — already-swept tables short-circuit via their localStorage
|
|
||||||
* sentinel.
|
|
||||||
*/
|
|
||||||
export async function runAtRestEncryptSweep(): Promise<void> {
|
|
||||||
if (!isVaultUnlocked()) {
|
|
||||||
console.warn('[mana-crypto:at-rest-sweep] vault locked, skipping — re-run after unlock');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets = Object.entries(ENCRYPTION_REGISTRY)
|
|
||||||
.filter(([, cfg]) => cfg.enabled && cfg.fields.length > 0)
|
|
||||||
.map(([tableName]) => tableName)
|
|
||||||
.filter((tableName) => !hasSwept(tableName));
|
|
||||||
|
|
||||||
if (targets.length === 0) return; // everything swept already
|
|
||||||
|
|
||||||
console.info(
|
|
||||||
`[mana-crypto:at-rest-sweep] starting for ${targets.length} table(s): ${targets.join(', ')}`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const tableName of targets) {
|
|
||||||
try {
|
|
||||||
const touched = await sweepTable(tableName);
|
|
||||||
markSwept(tableName, touched);
|
|
||||||
if (touched > 0) {
|
|
||||||
console.info(`[mana-crypto:at-rest-sweep] ${tableName}: re-saved ${touched} row(s)`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Dexie.DexieError) {
|
|
||||||
console.error(`[mana-crypto:at-rest-sweep] ${tableName} failed (Dexie): ${err.message}`);
|
|
||||||
} else {
|
|
||||||
console.error(`[mana-crypto:at-rest-sweep] ${tableName} failed:`, err);
|
|
||||||
}
|
|
||||||
// Don't mark swept — the next unlock will retry this table.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test / recovery helper: clears every sweep sentinel so the next
|
|
||||||
* `runAtRestEncryptSweep()` re-processes all enabled tables. No UI
|
|
||||||
* hooks this up; exported for integration tests + manual recovery
|
|
||||||
* via the browser console.
|
|
||||||
*/
|
|
||||||
export function resetSweepSentinels(): void {
|
|
||||||
if (typeof localStorage === 'undefined') return;
|
|
||||||
const prefix = `${SENTINEL_PREFIX}:`;
|
|
||||||
try {
|
|
||||||
const keys: string[] = [];
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const k = localStorage.key(i);
|
|
||||||
if (k && k.startsWith(prefix)) keys.push(k);
|
|
||||||
}
|
|
||||||
for (const k of keys) localStorage.removeItem(k);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -71,15 +71,6 @@
|
||||||
if (state.status === 'unlocked') {
|
if (state.status === 'unlocked') {
|
||||||
console.info('[mana-crypto] vault unlocked successfully');
|
console.info('[mana-crypto] vault unlocked successfully');
|
||||||
needsRecoveryCode = false;
|
needsRecoveryCode = false;
|
||||||
// Post-unlock: run the one-shot at-rest encryption
|
|
||||||
// sweep over tables whose encryption was flipped
|
|
||||||
// after they already had plaintext rows. Guarded by
|
|
||||||
// a per-table localStorage sentinel so it's idempotent
|
|
||||||
// and cheap on every subsequent unlock. Fire-and-
|
|
||||||
// forget — a failed sweep logs but never blocks.
|
|
||||||
void import('$lib/data/crypto/at-rest-sweep').then(({ runAtRestEncryptSweep }) =>
|
|
||||||
runAtRestEncryptSweep()
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.status === 'awaiting-recovery-code') {
|
if (state.status === 'awaiting-recovery-code') {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts",
|
"dev": "bun run --watch src/index.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
"test": "bun test",
|
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
|
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
/**
|
|
||||||
* Parser tests for the Gemini Deep Research `/v1beta/interactions/:id`
|
|
||||||
* response. Shape was derived from a real smoke-test on 2026-04-22 —
|
|
||||||
* see docs/reports/gemini-deep-research.md §1.3.
|
|
||||||
*
|
|
||||||
* We test the pure `parseInteractionResponse` helper, not the full
|
|
||||||
* poll function, so there's no fetch mocking and the fixtures can
|
|
||||||
* exercise edge cases the live API might not hand back on demand
|
|
||||||
* (empty output items, duplicate citations, wrong annotation types).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from 'bun:test';
|
|
||||||
import { parseInteractionResponse } from './gemini-deep-research';
|
|
||||||
|
|
||||||
// Typed as `any` because we want to feed the parser shapes that
|
|
||||||
// deliberately don't match the happy-path TS interface (e.g. missing
|
|
||||||
// fields, wrong annotation types) to verify defensive handling.
|
|
||||||
type Fixture = Parameters<typeof parseInteractionResponse>[0];
|
|
||||||
|
|
||||||
describe('parseInteractionResponse — status dispatch', () => {
|
|
||||||
it('maps queued → queued', () => {
|
|
||||||
const r = parseInteractionResponse({ status: 'queued' } as Fixture);
|
|
||||||
expect(r).toEqual({ status: 'queued' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps in_progress → running', () => {
|
|
||||||
const r = parseInteractionResponse({ status: 'in_progress' } as Fixture);
|
|
||||||
expect(r).toEqual({ status: 'running' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps failed → failed with error message', () => {
|
|
||||||
const r = parseInteractionResponse({
|
|
||||||
status: 'failed',
|
|
||||||
error: { message: 'model timeout' },
|
|
||||||
} as Fixture);
|
|
||||||
expect(r).toEqual({ status: 'failed', error: 'model timeout' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps cancelled → failed (uses status string as fallback error)', () => {
|
|
||||||
const r = parseInteractionResponse({ status: 'cancelled' } as Fixture);
|
|
||||||
expect(r).toEqual({ status: 'failed', error: 'cancelled' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps incomplete → failed', () => {
|
|
||||||
const r = parseInteractionResponse({ status: 'incomplete' } as Fixture);
|
|
||||||
expect(r.status).toBe('failed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseInteractionResponse — completed response', () => {
|
|
||||||
const completed: Fixture = {
|
|
||||||
id: 'test_interaction_123',
|
|
||||||
status: 'completed',
|
|
||||||
outputs: [
|
|
||||||
// thought item — should be ignored entirely
|
|
||||||
{
|
|
||||||
type: 'thought',
|
|
||||||
text: undefined, // thought uses `summary`, not `text` — irrelevant, we skip anyway
|
|
||||||
} as never,
|
|
||||||
// empty item Google occasionally emits — must not crash the loop
|
|
||||||
{} as never,
|
|
||||||
// primary text item with url_citations (including a duplicate and a non-url_citation)
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: '# Main Report\n\nThis is the body with [cite: 1, 2].',
|
|
||||||
annotations: [
|
|
||||||
{ type: 'url_citation', url: 'https://example.com/a', start_index: 0, end_index: 10 },
|
|
||||||
{ type: 'url_citation', url: 'https://example.com/b', start_index: 15, end_index: 25 },
|
|
||||||
// duplicate of /a — must be deduped
|
|
||||||
{ type: 'url_citation', url: 'https://example.com/a', start_index: 30, end_index: 40 },
|
|
||||||
// wrong type — must be skipped
|
|
||||||
{ type: 'other_citation', url: 'https://should-not-capture.com' },
|
|
||||||
// missing url — must be skipped
|
|
||||||
{ type: 'url_citation' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// image — skipped (lives in providerRaw)
|
|
||||||
{ type: 'image', mime_type: 'image/png', data: 'aGVsbG8=' } as never,
|
|
||||||
// second text block without annotations — must be concatenated
|
|
||||||
{ type: 'text', text: '\n\n**Sources above.**' },
|
|
||||||
],
|
|
||||||
usage: {
|
|
||||||
total_tokens: 1000,
|
|
||||||
total_input_tokens: 700,
|
|
||||||
total_output_tokens: 300,
|
|
||||||
total_cached_tokens: 100,
|
|
||||||
},
|
|
||||||
} as Fixture;
|
|
||||||
|
|
||||||
const result = parseInteractionResponse(completed);
|
|
||||||
|
|
||||||
it('returns completed status with an answer body', () => {
|
|
||||||
expect(result.status).toBe('completed');
|
|
||||||
expect(result.answer).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('concatenates all text items, skipping thoughts/images/empty', () => {
|
|
||||||
expect(result.answer?.answer).toBe(
|
|
||||||
'# Main Report\n\nThis is the body with [cite: 1, 2].\n\n**Sources above.**'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves `query` empty — caller fills it in', () => {
|
|
||||||
expect(result.answer?.query).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts url_citations deduped by url, using hostname as title', () => {
|
|
||||||
expect(result.answer?.citations).toEqual([
|
|
||||||
{ url: 'https://example.com/a', title: 'example.com' },
|
|
||||||
{ url: 'https://example.com/b', title: 'example.com' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps usage.total_input_tokens / total_output_tokens to tokenUsage', () => {
|
|
||||||
expect(result.answer?.tokenUsage).toEqual({ input: 700, output: 300 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves the raw response for downstream consumers', () => {
|
|
||||||
expect(result.answer?.providerRaw).toBe(completed);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseInteractionResponse — completed edge cases', () => {
|
|
||||||
it('handles completely empty outputs', () => {
|
|
||||||
const r = parseInteractionResponse({ status: 'completed', outputs: [] } as Fixture);
|
|
||||||
expect(r.status).toBe('completed');
|
|
||||||
expect(r.answer?.answer).toBe('');
|
|
||||||
expect(r.answer?.citations).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing outputs field entirely', () => {
|
|
||||||
const r = parseInteractionResponse({ status: 'completed' } as Fixture);
|
|
||||||
expect(r.status).toBe('completed');
|
|
||||||
expect(r.answer?.answer).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing usage', () => {
|
|
||||||
const r = parseInteractionResponse({
|
|
||||||
status: 'completed',
|
|
||||||
outputs: [{ type: 'text', text: 'hi' }],
|
|
||||||
} as Fixture);
|
|
||||||
expect(r.answer?.tokenUsage).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trims leading/trailing whitespace on the concatenated answer', () => {
|
|
||||||
const r = parseInteractionResponse({
|
|
||||||
status: 'completed',
|
|
||||||
outputs: [
|
|
||||||
{ type: 'text', text: ' \n\n' },
|
|
||||||
{ type: 'text', text: 'Report body' },
|
|
||||||
{ type: 'text', text: '\n\n ' },
|
|
||||||
],
|
|
||||||
} as Fixture);
|
|
||||||
expect(r.answer?.answer).toBe('Report body');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to url as title when hostname parse fails', () => {
|
|
||||||
const r = parseInteractionResponse({
|
|
||||||
status: 'completed',
|
|
||||||
outputs: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: 'x',
|
|
||||||
annotations: [{ type: 'url_citation', url: 'not a valid url' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as Fixture);
|
|
||||||
expect(r.answer?.citations[0]).toEqual({
|
|
||||||
url: 'not a valid url',
|
|
||||||
title: 'not a valid url',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles the real vertexaisearch redirect URLs Gemini emits', () => {
|
|
||||||
const r = parseInteractionResponse({
|
|
||||||
status: 'completed',
|
|
||||||
outputs: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: 'Hono is ...',
|
|
||||||
annotations: [
|
|
||||||
{
|
|
||||||
type: 'url_citation',
|
|
||||||
url: 'https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQF...',
|
|
||||||
start_index: 268,
|
|
||||||
end_index: 283,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as Fixture);
|
|
||||||
expect(r.answer?.citations[0]?.title).toBe('vertexaisearch.cloud.google.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -162,20 +162,7 @@ export async function pollGeminiDeepResearch(
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as GeminiInteractionPollResponse;
|
const data = (await res.json()) as GeminiInteractionPollResponse;
|
||||||
return parseInteractionResponse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure parser for the `/v1beta/interactions/:id` response. Extracted so
|
|
||||||
* the edge cases (flat `outputs` array, url_citation annotations, usage
|
|
||||||
* field names) can be unit-tested without mocking global fetch.
|
|
||||||
*
|
|
||||||
* Exported for tests only — production callers should go through
|
|
||||||
* pollGeminiDeepResearch().
|
|
||||||
*/
|
|
||||||
export function parseInteractionResponse(
|
|
||||||
data: GeminiInteractionPollResponse
|
|
||||||
): GeminiDeepPollResult {
|
|
||||||
if (data.status === 'queued') return { status: 'queued' };
|
if (data.status === 'queued') return { status: 'queued' };
|
||||||
if (data.status === 'in_progress') return { status: 'running' };
|
if (data.status === 'in_progress') return { status: 'running' };
|
||||||
if (data.status === 'failed' || data.status === 'incomplete' || data.status === 'cancelled') {
|
if (data.status === 'failed' || data.status === 'incomplete' || data.status === 'cancelled') {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue