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:
co-authored by
Claude Fable 5
parent
1195b4e0dd
commit
7422f20a63
@@ -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,))
|
||||
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user