feat(profiles): consent-locked voice profiles — verified_own_voice + spoken consent flow (Wave 0.2) (#354)

* feat(profiles): consent-locked voice profiles — verified_own_voice + spoken consent flow (Wave 0.2)

A profile becomes 'verified own voice' when its owner records themselves
reading a consent statement (spoken attestation, not a checkbox). Agentic
features and gallery sharing will gate on the flag; plain local synthesis
never does.

- alembic 0003 (additive, PRAGMA-guarded, downgrade supported) +
  _BASE_SCHEMA columns: verified_own_voice, consent_text,
  consent_audio_path, consent_recorded_at
- POST/DELETE /profiles/{id}/consent — stores the recording as provenance
  in VOICES_DIR ({id}_consent.*), replaces on re-record, cleans up on
  revoke and on profile delete; 422 on empty statement / too-short audio
- VoiceProfile page: Verified badge + Voice ownership panel (record via
  the existing useRecording denoise flow, revoke with confirm); en.json
  keys only (other locales fall back per the advisory i18n parity policy)

Spec: docs/competitive-analysis.md Action 22 / parity program Wave 0.2.
Prerequisite for agentic v2/v3 and the persona gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(profiles): harden consent paths against py/path-injection; drop lifespan in tests

- _voices_path(): resolve DB-stored filenames strictly inside VOICES_DIR
  (bare-filename check + realpath containment); extension whitelist on the
  uploaded consent filename (fallback .wav) so a crafted filename can never
  steer the on-disk path. Applied to write, re-record cleanup, revoke, and
  profile-delete cleanup. New test: malicious upload filename falls back.
- Test fixture no longer runs the app lifespan: startup/shutdown touched
  module-level asyncio primitives bound to another module's event loop,
  making the suite order-dependent in full-suite CI. init_db() is called
  directly; endpoints under test need only the schema.

Fixes the CodeQL (3x py/path-injection high) and full-suite event-loop
failures on PR #354.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-11 22:10:30 +05:30
committed by GitHub
co-authored by Claude Fable 5
parent 1195b4e0dd
commit 7422f20a63
8 changed files with 551 additions and 5 deletions
+112 -4
View File
@@ -1,4 +1,5 @@
import os
import re
import uuid
import time
import shutil
@@ -234,15 +235,122 @@ async def unlock_profile(profile_id: str):
event_bus.emit("profiles", {"action": "unlocked", "id": profile_id})
return {"unlocked": True, "profile_id": profile_id}
# ── Consent lock (parity program Wave 0.2) ─────────────────────────────────
#
# A profile becomes "verified own voice" when its owner records themselves
# reading a consent statement. The recording is provenance, not a voiceprint
# check — agentic features and gallery sharing gate on the flag; plain local
# synthesis never does. Spec: docs/competitive-analysis.md Action 22.
_MIN_CONSENT_AUDIO_BYTES = 1000 # same floor as the frontend recorder
# Upload filename extension whitelist — anything else falls back to .wav so a
# crafted filename can never influence the on-disk path (py/path-injection).
_CONSENT_EXT_RE = re.compile(r"^\.[A-Za-z0-9]{1,8}$")
def _voices_path(filename: str) -> Optional[str]:
"""Resolve a DB-stored audio filename strictly inside VOICES_DIR.
Rejects anything that isn't a bare filename or that escapes the voices
directory after symlink resolution. Returns None instead of raising so
cleanup paths can simply skip bad values.
"""
if not filename or os.path.basename(filename) != filename:
return None
root = os.path.realpath(VOICES_DIR)
path = os.path.realpath(os.path.join(root, filename))
if not path.startswith(root + os.sep):
return None
return path
@router.post("/profiles/{profile_id}/consent")
async def record_consent(
profile_id: str,
consent_audio: UploadFile = File(...),
consent_text: str = Form(...),
):
if not consent_text.strip():
raise HTTPException(status_code=422, detail="consent_text must not be empty")
data = await consent_audio.read()
if len(data) < _MIN_CONSENT_AUDIO_BYTES:
raise HTTPException(status_code=422, detail="consent recording is too short")
with db_conn() as conn:
row = conn.execute(
"SELECT id, consent_audio_path FROM voice_profiles WHERE id=?", (profile_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profile not found")
ext = os.path.splitext(consent_audio.filename or "")[1]
if not _CONSENT_EXT_RE.match(ext):
ext = ".wav"
audio_filename = f"{profile_id}_consent{ext}"
audio_path = _voices_path(audio_filename)
if audio_path is None: # profile_id is server-generated; this is belt+braces
raise HTTPException(status_code=400, detail="Invalid profile id")
with open(audio_path, "wb") as f:
f.write(data)
# A re-record may change the extension; drop the superseded file.
old = row["consent_audio_path"]
if old and old != audio_filename:
old_path = _voices_path(old)
if old_path and os.path.exists(old_path):
os.remove(old_path)
recorded_at = time.time()
try:
with db_conn() as conn:
conn.execute(
"UPDATE voice_profiles SET verified_own_voice=1, consent_text=?, "
"consent_audio_path=?, consent_recorded_at=? WHERE id=?",
(consent_text.strip(), audio_filename, recorded_at, profile_id),
)
except Exception:
if os.path.exists(audio_path):
os.remove(audio_path)
raise
event_bus.emit("profiles", {"action": "consent_recorded", "id": profile_id})
return {
"id": profile_id,
"verified_own_voice": True,
"consent_recorded_at": recorded_at,
}
@router.delete("/profiles/{profile_id}/consent")
def revoke_consent(profile_id: str):
with db_conn() as conn:
row = conn.execute(
"SELECT consent_audio_path FROM voice_profiles WHERE id=?", (profile_id,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profile not found")
conn.execute(
"UPDATE voice_profiles SET verified_own_voice=0, consent_text='', "
"consent_audio_path='', consent_recorded_at=NULL WHERE id=?",
(profile_id,),
)
if row["consent_audio_path"]:
path = _voices_path(row["consent_audio_path"])
if path and os.path.exists(path):
os.remove(path)
event_bus.emit("profiles", {"action": "consent_revoked", "id": profile_id})
return {"id": profile_id, "verified_own_voice": False}
@router.delete("/profiles/{profile_id}")
def delete_profile(profile_id: str):
with db_conn() as conn:
row = conn.execute("SELECT ref_audio_path, locked_audio_path FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
row = conn.execute("SELECT ref_audio_path, locked_audio_path, consent_audio_path FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
if row:
for col in ["ref_audio_path", "locked_audio_path"]:
for col in ["ref_audio_path", "locked_audio_path", "consent_audio_path"]:
if row[col]:
path = os.path.join(VOICES_DIR, row[col])
if os.path.exists(path):
path = _voices_path(row[col])
if path and os.path.exists(path):
os.remove(path)
# Prevent FOREIGN KEY constraint failure
conn.execute("UPDATE generation_history SET profile_id = NULL WHERE profile_id=?", (profile_id,))
+4
View File
@@ -49,6 +49,10 @@ _BASE_SCHEMA = """
personality TEXT DEFAULT '',
description TEXT DEFAULT '',
is_demo INTEGER DEFAULT 0,
verified_own_voice INTEGER DEFAULT 0,
consent_text TEXT DEFAULT '',
consent_audio_path TEXT DEFAULT '',
consent_recorded_at REAL DEFAULT NULL,
created_at REAL
);
CREATE TABLE IF NOT EXISTS generation_history (
@@ -0,0 +1,58 @@
"""Parity program Wave 0.2: consent-locked voice profiles
Revision ID: 0003_voice_profile_consent
Revises: 0002_voice_profile_demo_fields
Create Date: 2026-06-12 00:00:00.000000
Adds four additive columns to ``voice_profiles`` backing the
``verified_own_voice`` consent lock (docs/competitive-analysis.md Action 22 /
parity program Wave 0.2). A profile becomes "verified" when its owner records
a spoken consent statement; agentic features and gallery sharing will require
the flag — plain local synthesis never does.
* ``verified_own_voice INTEGER DEFAULT 0`` — the consent lock itself.
* ``consent_text TEXT DEFAULT ''`` — the statement that was read aloud.
* ``consent_audio_path TEXT DEFAULT ''`` — filename of the recorded
statement in VOICES_DIR (kept as provenance, deletable via revoke).
* ``consent_recorded_at REAL DEFAULT NULL`` — UNIX timestamp.
Behavior mirrors 0002: ``_has_column`` PRAGMA guards make upgrade a no-op on
fresh installs (where _BASE_SCHEMA already has the columns), satisfying the
"Backward-compatible project data" constraint; downgrade drops the columns
(SQLite >= 3.35).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0003_voice_profile_consent"
down_revision: Union[str, None] = "0002_voice_profile_demo_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_COLUMNS = (
("verified_own_voice", sa.Column("verified_own_voice", sa.Integer(), nullable=False, server_default="0")),
("consent_text", sa.Column("consent_text", sa.Text(), nullable=False, server_default="")),
("consent_audio_path", sa.Column("consent_audio_path", sa.Text(), nullable=False, server_default="")),
("consent_recorded_at", sa.Column("consent_recorded_at", sa.Float(), nullable=True)),
)
def _has_column(table: str, column: str) -> bool:
bind = op.get_bind()
rows = bind.execute(sa.text(f"PRAGMA table_info({table})")).fetchall()
return any(r[1] == column for r in rows)
def upgrade() -> None:
for name, column in _COLUMNS:
if not _has_column("voice_profiles", name):
op.add_column("voice_profiles", column)
def downgrade() -> None:
for name, _ in reversed(_COLUMNS):
if _has_column("voice_profiles", name):
op.drop_column("voice_profiles", name)
+8
View File
@@ -30,6 +30,14 @@ export async function deleteProfile(id: string): Promise<Response> {
return apiFetch(`/profiles/${id}`, { method: 'DELETE' });
}
export async function recordConsent(id: string, formData: FormData): Promise<unknown> {
return apiPost(`/profiles/${id}/consent`, formData);
}
export async function revokeConsent(id: string): Promise<Response> {
return apiFetch(`/profiles/${id}/consent`, { method: 'DELETE' });
}
export async function lockProfile(id: string, formData: FormData): Promise<unknown> {
return apiPost(`/profiles/${id}/lock`, formData);
}
+4
View File
@@ -116,6 +116,10 @@ export interface Profile {
description?: string;
created_at?: string;
is_locked?: boolean;
/** Consent lock (Wave 0.2): owner recorded a spoken consent statement. */
verified_own_voice?: boolean | number;
consent_text?: string;
consent_recorded_at?: number | null;
}
export interface ProfileUsage {
+12
View File
@@ -389,6 +389,18 @@
"transcription_failed": "Transcription failed: {{message}}"
},
"voice_profile": {
"verified": "Verified",
"consent_title": "Voice ownership",
"consent_explain": "Record yourself reading the statement below to verify this is your own voice. Verification will be required for agentic features and community sharing — never for local synthesis.",
"consent_statement": "I confirm that this voice profile is my own voice, and I consent to OmniVoice Studio cloning it on my behalf.",
"consent_record": "Record consent statement",
"consent_stop": "Stop recording",
"consent_saved": "Voice ownership verified",
"consent_failed": "Verification failed: {{message}}",
"consent_verified_explain": "You recorded a consent statement on {{date}}. Revoking removes the recording and the verified status.",
"consent_revoke": "Revoke",
"consent_revoke_confirm": "Revoke voice-ownership verification? The consent recording will be deleted and agentic features will no longer be able to use this voice.",
"consent_revoked": "Verification revoked",
"test_text": "Hello — this is a test of this voice.",
"not_found": "Voice not found.",
"needs_name": "Voice profile needs a name.",
+83 -1
View File
@@ -4,12 +4,14 @@ import { toast } from 'react-hot-toast';
import { toastErrorWithReport } from '../utils/errorToast';
import {
ArrowLeft, Fingerprint, Wand2, Lock, Unlock, Trash2, Play, Save,
FolderOpen, Volume2, Clock, Pencil, Check, X, Sparkles,
FolderOpen, Volume2, Clock, Pencil, Check, X, Sparkles, ShieldCheck, Mic, Square,
} from 'lucide-react';
import { Panel, Button, Input, Textarea, Field, Badge, Segmented, Progress } from '../ui';
import {
getProfile, getProfileUsage, updateProfile, deleteProfile, unlockProfile,
recordConsent, revokeConsent,
} from '../api/profiles';
import useRecording from '../hooks/useRecording';
import { generateSpeech } from '../api/generate';
import { API } from '../api/client';
import './VoiceProfile.css';
@@ -42,6 +44,38 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
const [testAudioUrl, setTestAudioUrl] = useState(null);
const testAudioRef = useRef(null);
// Consent lock (Wave 0.2): record a spoken consent statement to mark the
// profile as the owner's own voice. Agentic features and gallery sharing
// gate on this flag; local synthesis never does.
const [consentSubmitting, setConsentSubmitting] = useState(false);
const consentStatement = t('voice_profile.consent_statement');
const submitConsent = async (audioFile) => {
setConsentSubmitting(true);
try {
const fd = new FormData();
fd.append('consent_audio', audioFile);
fd.append('consent_text', consentStatement);
await recordConsent(voiceId, fd);
toast.success(t('voice_profile.consent_saved'));
await reload();
} catch (e) {
toastErrorWithReport(t('voice_profile.consent_failed', { message: e.message }), e);
} finally {
setConsentSubmitting(false);
}
};
const consentRec = useRecording(submitConsent);
const onRevokeConsent = async () => {
if (!(await askConfirm(t('voice_profile.consent_revoke_confirm')))) return;
try {
await revokeConsent(voiceId);
toast.success(t('voice_profile.consent_revoked'));
await reload();
} catch (e) {
toastErrorWithReport(e.message, e);
}
};
const reload = useCallback(async () => {
if (!voiceId) return;
setLoading(true);
@@ -210,6 +244,9 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
<h1>{profile.name}</h1>
)}
<div className="voice-profile__badges">
{!!profile.verified_own_voice && (
<Badge tone="success" dot><ShieldCheck size={10} /> {t('voice_profile.verified')}</Badge>
)}
{profile.is_locked
? <Badge tone="warn" dot><Lock size={10} /> {t('voice_profile.locked')}</Badge>
: <Badge tone="neutral">{t('voice_profile.free')}</Badge>}
@@ -300,6 +337,51 @@ export default function VoiceProfile({ voiceId, onBack, onOpenProject, onDeleted
)}
</Panel>
{/* Consent lock (Wave 0.2) — verify this is your own voice */}
<Panel
variant="flat"
padding="md"
title={<><ShieldCheck size={12} /> {t('voice_profile.consent_title')}</>}
>
{profile.verified_own_voice ? (
<div className="voice-profile__lock-row">
<Badge tone="success" dot><ShieldCheck size={10} /> {t('voice_profile.verified')}</Badge>
<span className="voice-profile__lock-hint">
{t('voice_profile.consent_verified_explain', {
date: profile.consent_recorded_at
? new Date(profile.consent_recorded_at * 1000).toLocaleDateString()
: '',
})}
</span>
<Button variant="subtle" size="sm" onClick={onRevokeConsent} leading={<X size={12} />}>
{t('voice_profile.consent_revoke')}
</Button>
</div>
) : (
<>
<p className="voice-profile__readonly">{t('voice_profile.consent_explain')}</p>
<blockquote className="voice-profile__readonly voice-profile__readonly--transcript">
{consentStatement}
</blockquote>
{consentRec.isRecording ? (
<Button variant="danger" size="sm" onClick={consentRec.stopRecording} leading={<Square size={12} />}>
{t('voice_profile.consent_stop')} ({consentRec.recordingTime}s)
</Button>
) : (
<Button
variant="primary"
size="sm"
onClick={consentRec.startRecording}
loading={consentSubmitting || consentRec.isCleaning}
leading={!(consentSubmitting || consentRec.isCleaning) && <Mic size={12} />}
>
{t('voice_profile.consent_record')}
</Button>
)}
</>
)}
</Panel>
{/* Try-it */}
<Panel
variant="flat"
+270
View File
@@ -0,0 +1,270 @@
"""Consent-locked voice profiles (parity program Wave 0.2, Action 22).
Endpoint tests run against an isolated tmp data dir (pattern from
tests/test_dub_transcribe.py); the migration test drives alembic
programmatically against a fixture DB (pattern from
tests/backend/services/test_settings_store.py).
"""
import os
import sqlite3
import sys
import pytest
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
_FAKE_AUDIO = b"RIFF" + b"\x00" * 2000 # > _MIN_CONSENT_AUDIO_BYTES floor
_CONSENT_TEXT = "I confirm this is my own voice and I consent to cloning it in OmniVoice Studio."
@pytest.fixture(scope="module")
def app_client(tmp_path_factory):
"""TestClient with an isolated data dir so profile/consent files land in tmp.
Deliberately does NOT run the app lifespan (no ``with TestClient(...)``):
startup/shutdown touch module-level asyncio primitives (event bus, job
queues) that other test modules may have bound to a different event loop,
which made this suite order-dependent in full-suite CI runs. The consent
endpoints only need the DB schema, so init_db() is called directly.
"""
mp = pytest.MonkeyPatch()
tmp_path = tmp_path_factory.mktemp("consent-data")
mp.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
import importlib
import core.config as _cfg
importlib.reload(_cfg)
import core.db as _db
importlib.reload(_db)
from api.routers import profiles as _profiles
importlib.reload(_profiles)
import main as _main
importlib.reload(_main)
_db.init_db()
from fastapi.testclient import TestClient
try:
yield TestClient(_main.app, client=("127.0.0.1", 50000)), _cfg
finally:
mp.undo()
def _create_profile(client) -> str:
r = client.post(
"/profiles",
data={"name": "Me"},
files={"ref_audio": ("me.wav", _FAKE_AUDIO, "audio/wav")},
)
assert r.status_code == 200, r.text
return r.json()["id"]
def test_new_profile_is_unverified(app_client):
client, _ = app_client
pid = _create_profile(client)
profile = client.get(f"/profiles/{pid}").json()
assert profile["verified_own_voice"] == 0
assert profile["consent_text"] == ""
assert profile["consent_recorded_at"] is None
def test_record_consent_sets_flag_and_stores_audio(app_client):
client, cfg = app_client
pid = _create_profile(client)
r = client.post(
f"/profiles/{pid}/consent",
data={"consent_text": _CONSENT_TEXT},
files={"consent_audio": ("consent.wav", _FAKE_AUDIO, "audio/wav")},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["verified_own_voice"] is True
assert body["consent_recorded_at"] is not None
profile = client.get(f"/profiles/{pid}").json()
assert profile["verified_own_voice"] == 1
assert profile["consent_text"] == _CONSENT_TEXT
assert profile["consent_audio_path"] == f"{pid}_consent.wav"
assert os.path.exists(os.path.join(cfg.VOICES_DIR, f"{pid}_consent.wav"))
def test_rerecord_replaces_previous_consent_file(app_client):
client, cfg = app_client
pid = _create_profile(client)
for ext, mime in (("wav", "audio/wav"), ("webm", "audio/webm")):
r = client.post(
f"/profiles/{pid}/consent",
data={"consent_text": _CONSENT_TEXT},
files={"consent_audio": (f"consent.{ext}", _FAKE_AUDIO, mime)},
)
assert r.status_code == 200, r.text
assert not os.path.exists(os.path.join(cfg.VOICES_DIR, f"{pid}_consent.wav"))
assert os.path.exists(os.path.join(cfg.VOICES_DIR, f"{pid}_consent.webm"))
def test_consent_validation(app_client):
client, _ = app_client
pid = _create_profile(client)
# Empty statement.
r = client.post(
f"/profiles/{pid}/consent",
data={"consent_text": " "},
files={"consent_audio": ("c.wav", _FAKE_AUDIO, "audio/wav")},
)
assert r.status_code == 422
# Recording below the size floor.
r = client.post(
f"/profiles/{pid}/consent",
data={"consent_text": _CONSENT_TEXT},
files={"consent_audio": ("c.wav", b"tiny", "audio/wav")},
)
assert r.status_code == 422
# Unknown profile.
r = client.post(
"/profiles/nope1234/consent",
data={"consent_text": _CONSENT_TEXT},
files={"consent_audio": ("c.wav", _FAKE_AUDIO, "audio/wav")},
)
assert r.status_code == 404
# Failed attempts must not flip the flag.
assert client.get(f"/profiles/{pid}").json()["verified_own_voice"] == 0
def test_malicious_upload_filename_cannot_steer_path(app_client):
"""py/path-injection hardening: extension whitelist + VOICES_DIR containment."""
client, cfg = app_client
pid = _create_profile(client)
r = client.post(
f"/profiles/{pid}/consent",
data={"consent_text": _CONSENT_TEXT},
files={"consent_audio": ("../../evil.sh/x.....", _FAKE_AUDIO, "audio/wav")},
)
assert r.status_code == 200, r.text
profile = client.get(f"/profiles/{pid}").json()
assert profile["consent_audio_path"] == f"{pid}_consent.wav" # fell back
assert os.path.exists(os.path.join(cfg.VOICES_DIR, f"{pid}_consent.wav"))
def test_revoke_consent_clears_flag_and_file(app_client):
client, cfg = app_client
pid = _create_profile(client)
client.post(
f"/profiles/{pid}/consent",
data={"consent_text": _CONSENT_TEXT},
files={"consent_audio": ("consent.wav", _FAKE_AUDIO, "audio/wav")},
)
r = client.delete(f"/profiles/{pid}/consent")
assert r.status_code == 200
assert r.json()["verified_own_voice"] is False
profile = client.get(f"/profiles/{pid}").json()
assert profile["verified_own_voice"] == 0
assert profile["consent_text"] == ""
assert profile["consent_recorded_at"] is None
assert not os.path.exists(os.path.join(cfg.VOICES_DIR, f"{pid}_consent.wav"))
assert client.delete("/profiles/nope1234/consent").status_code == 404
def test_delete_profile_removes_consent_audio(app_client):
client, cfg = app_client
pid = _create_profile(client)
client.post(
f"/profiles/{pid}/consent",
data={"consent_text": _CONSENT_TEXT},
files={"consent_audio": ("consent.wav", _FAKE_AUDIO, "audio/wav")},
)
consent_path = os.path.join(cfg.VOICES_DIR, f"{pid}_consent.wav")
assert os.path.exists(consent_path)
assert client.delete(f"/profiles/{pid}").status_code == 200
assert not os.path.exists(consent_path)
# ── Migration ───────────────────────────────────────────────────────────────
_PRE_CONSENT_PROFILES = """
CREATE TABLE voice_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
ref_audio_path TEXT,
ref_text TEXT DEFAULT '',
instruct TEXT DEFAULT '',
language TEXT DEFAULT 'Auto',
locked_audio_path TEXT DEFAULT '',
seed INTEGER DEFAULT NULL,
is_locked INTEGER DEFAULT 0,
personality TEXT DEFAULT '',
description TEXT DEFAULT '',
is_demo INTEGER DEFAULT 0,
created_at REAL
);
"""
def _run_alembic(direction: str, db_path: str, target: str = "head"):
from alembic import command
from alembic.config import Config
here = os.path.abspath(os.path.dirname(__file__))
root = here
while root and root != "/" and not os.path.isfile(os.path.join(root, "alembic.ini")):
root = os.path.dirname(root)
assert os.path.isfile(os.path.join(root, "alembic.ini")), "alembic.ini not found"
cfg = Config(os.path.join(root, "alembic.ini"))
cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}")
if direction == "upgrade":
command.upgrade(cfg, target)
else:
command.downgrade(cfg, target)
def _columns(db, table):
with sqlite3.connect(str(db)) as conn:
return {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
def test_migration_0003_adds_consent_columns(tmp_path):
db = tmp_path / "pre.db"
with sqlite3.connect(str(db)) as conn:
conn.executescript(_PRE_CONSENT_PROFILES)
conn.execute("INSERT INTO voice_profiles(id, name) VALUES ('vp-1', 'Alice')")
conn.commit()
_run_alembic("upgrade", str(db))
cols = _columns(db, "voice_profiles")
for col in ("verified_own_voice", "consent_text", "consent_audio_path", "consent_recorded_at"):
assert col in cols, f"missing column {col}"
with sqlite3.connect(str(db)) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM voice_profiles WHERE id='vp-1'").fetchone()
assert row["name"] == "Alice" # no data loss
assert row["verified_own_voice"] == 0 # legacy rows default unverified
assert row["consent_text"] == ""
assert row["consent_recorded_at"] is None
def test_migration_0003_downgrade_drops_columns(tmp_path):
db = tmp_path / "pre.db"
with sqlite3.connect(str(db)) as conn:
conn.executescript(_PRE_CONSENT_PROFILES)
conn.commit()
_run_alembic("upgrade", str(db))
_run_alembic("downgrade", str(db), target="0002_voice_profile_demo_fields")
cols = _columns(db, "voice_profiles")
for col in ("verified_own_voice", "consent_text", "consent_audio_path", "consent_recorded_at"):
assert col not in cols
assert "is_demo" in cols # 0002 still applied