Added tests for Video & Audio

feat: video/audio metadata scanning, profile rename fix, route tests

  - Scan .mp4/.mov/.avi/.mkv and .mp3/.flac/.ogg/.m4a/.wma (+ 7 more)
    for GPS coordinates, artist/author, title, comment — metadata only,
    no frame or audio analysis. Uses mutagen (added to requirements.txt).
    GPS-tagged phone recordings now flag with gps_location like photos.

  - Fix _extract_audio_metadata silently returning empty results:
    mutagen.File() first positional arg is `filename`, not `fileobj` —
    was passing BytesIO as the filename. Fixed to keyword args.

  - Fix profile copy rename not reflected in left column until modal
    reopen: _pmgmtSaveFullEdit called loadProfiles() but never
    _renderProfileMgmt(). Added re-render and active-row highlight.

  - Add TestProfileRoutes (10 tests) covering all profile API endpoints
    including a rename regression test. Total: 182 tests.

  - generate_fixtures.py now produces 6 audio/video fixtures (14–19):
    2 MP3, 2 FLAC, 2 MP4 — 4 flagged, 2 negative cases.
This commit is contained in:
StyxX65 committed 2026-04-21 21:26:58 +02:00
1 parent 2a2d79de90
commit d42518dc81
16 files changed
+476 -21

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+193
View File
@@ -4,7 +4,26 @@ Generate binary fixture files for the local-file GDPR scan test suite.
Run from repo root:
source venv/bin/activate
python tests/fixtures/local_files/generate_fixtures.py
Fixtures produced
─────────────────
Document fixtures (require python-docx + openpyxl):
09_cpr_in_docx.docx — Word document with 2 CPR numbers → Flag
13_cpr_in_xlsx.xlsx — Excel workbook with CPR numbers → Flag
Audio fixtures (require mutagen):
14_audio_artist_pii.mp3 — MP3 with artist/title tags (personal name) → Flag
15_audio_artist_pii.flac — FLAC with artist/title Vorbis comments → Flag
16_audio_no_pii.mp3 — MP3 with no metadata tags → No flag
17_audio_no_pii.flac — FLAC with no metadata → No flag
Video fixtures (require mutagen):
18_video_gps.mp4 — MP4 with GPS coordinates + artist tag → Flag
19_video_no_pii.mp4 — MP4 with no metadata tags → No flag
"""
import struct
import tempfile
import os
from pathlib import Path
import sys
@@ -19,6 +38,7 @@ def _require(pkg):
openpyxl = _require("openpyxl")
docx = _require("docx")
_require("mutagen")
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
@@ -148,7 +168,180 @@ def make_xlsx():
print(f"Written: {out.name}")
# ── Audio / video helpers ─────────────────────────────────────────────────────
# Two silent MPEG1 Layer3 frames (128 kbps / 44100 Hz / mono).
# mutagen needs at least 2 consecutive frame headers to confirm sync.
# 4-byte header + 413 bytes frame body = 417 bytes × 2 = 834 bytes total.
_MPEG_FRAMES = (b'\xff\xfb\x90\x00' + b'\x00' * 413) * 2
def _flac_block_header(block_type: int, data_len: int, last: bool = False) -> bytes:
first = (0x80 if last else 0x00) | block_type
return bytes([first, (data_len >> 16) & 0xFF, (data_len >> 8) & 0xFF, data_len & 0xFF])
def _vorbis_comment_block(comments: dict) -> bytes:
vendor = b'GDPRScanner fixture'
data = struct.pack('<I', len(vendor)) + vendor
data += struct.pack('<I', len(comments))
for key, value in comments.items():
entry = f'{key}={value}'.encode('utf-8')
data += struct.pack('<I', len(entry)) + entry
return data
def _minimal_flac(comments: dict) -> bytes:
"""Return bytes for a valid minimal FLAC file with Vorbis comments."""
# STREAMINFO (34 bytes): 44100 Hz, mono, 16-bit, 0 samples, zero MD5.
si = bytearray(34)
si[0:2] = struct.pack('>H', 4096) # min block size
si[2:4] = struct.pack('>H', 4096) # max block size
# bytes 4-9: min/max frame sizes = 0 (unknown)
# Bits 80-99: sample_rate=44100 (0xAC44 in 20-bit field)
# Bits 100-102: channels-1 = 0 (mono)
# Bits 103-107: bits_per_sample-1 = 15 (16-bit)
# Bits 108-143: total_samples = 0; bytes 14-17 remain zero
si[10] = 0x0A # 0000_1010 — top 8 of 44100 in 20-bit field
si[11] = 0xC4 # 1100_0100
si[12] = 0x40 # bottom 4 of sample_rate | channels(000) | bps_msb(0)
si[13] = 0xF0 # bps remaining 4 bits (1111) | top 4 of total_samples (0)
vc = _vorbis_comment_block(comments)
return (
b'fLaC'
+ _flac_block_header(0, 34, last=not comments) # STREAMINFO
+ bytes(si)
+ (_flac_block_header(4, len(vc), last=True) + vc if comments else b'')
)
def _mp4_atom(name: bytes, data: bytes) -> bytes:
return struct.pack('>I', 8 + len(data)) + name + data
def _minimal_mp4_base() -> bytes:
"""Return bytes for the smallest valid MPEG-4 container mutagen can tag."""
# ftyp — identifies the file as M4A
ftyp = _mp4_atom(
b'ftyp',
b'M4A ' + struct.pack('>I', 0) + b'M4A ' + b'mp42' + b'isom',
)
# mvhd version 0 — 100 bytes of content (ISO 14496-12 §8.2.2)
mvhd = bytearray(100)
mvhd[0:4] = b'\x00\x00\x00\x00' # version + flags
struct.pack_into('>IIII', mvhd, 4, 0, 0, 1000, 0) # creation, modification, timescale, duration
struct.pack_into('>I', mvhd, 16, 0x00010000) # rate = 1.0
struct.pack_into('>H', mvhd, 20, 0x0100) # volume = 1.0
# bytes 22-31: reserved (10 bytes, already zero)
struct.pack_into('>9i', mvhd, 32, # unity matrix
0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000)
# bytes 68-91: pre-defined (24 bytes, already zero)
struct.pack_into('>I', mvhd, 96, 0xFFFFFFFF) # next_track_ID
return ftyp + _mp4_atom(b'moov', _mp4_atom(b'mvhd', bytes(mvhd)))
def _mp4_with_tags(tags: dict) -> bytes:
"""Return bytes for a minimal MP4 with the given mutagen tag dict."""
import mutagen.mp4
tmp = tempfile.mktemp(suffix='.mp4')
try:
with open(tmp, 'wb') as fh:
fh.write(_minimal_mp4_base())
f = mutagen.mp4.MP4(tmp)
f.add_tags()
for key, value in tags.items():
f.tags[key] = [value]
f.save()
with open(tmp, 'rb') as fh:
return fh.read()
finally:
if os.path.exists(tmp):
os.unlink(tmp)
# ── 14_audio_artist_pii.mp3 ───────────────────────────────────────────────────
def make_mp3_pii():
from mutagen.easyid3 import EasyID3
tmp = tempfile.mktemp(suffix='.mp3')
try:
t = EasyID3()
t['artist'] = ['Emma Slot Henriksen']
t['title'] = ['Fortrolig optagelse — personalemøde']
t['date'] = ['2026-04-21']
t.save(tmp)
with open(tmp, 'rb') as fh:
id3_bytes = fh.read()
finally:
if os.path.exists(tmp):
os.unlink(tmp)
out = HERE / '14_audio_artist_pii.mp3'
out.write_bytes(id3_bytes + _MPEG_FRAMES)
print(f"Written: {out.name}")
# ── 15_audio_artist_pii.flac ──────────────────────────────────────────────────
def make_flac_pii():
out = HERE / '15_audio_artist_pii.flac'
out.write_bytes(_minimal_flac({
'ARTIST': 'Emma Slot Henriksen',
'TITLE': 'Fortrolig optagelse — personalemøde',
'DATE': '2026-04-21',
}))
print(f"Written: {out.name}")
# ── 16_audio_no_pii.mp3 ───────────────────────────────────────────────────────
def make_mp3_no_pii():
from mutagen.easyid3 import EasyID3
tmp = tempfile.mktemp(suffix='.mp3')
try:
EasyID3().save(tmp) # empty ID3 header, no tags
with open(tmp, 'rb') as fh:
id3_bytes = fh.read()
finally:
if os.path.exists(tmp):
os.unlink(tmp)
out = HERE / '16_audio_no_pii.mp3'
out.write_bytes(id3_bytes + _MPEG_FRAMES)
print(f"Written: {out.name}")
# ── 17_audio_no_pii.flac ──────────────────────────────────────────────────────
def make_flac_no_pii():
out = HERE / '17_audio_no_pii.flac'
out.write_bytes(_minimal_flac({})) # no Vorbis comment block
print(f"Written: {out.name}")
# ── 18_video_gps.mp4 ─────────────────────────────────────────────────────────
def make_mp4_gps():
out = HERE / '18_video_gps.mp4'
out.write_bytes(_mp4_with_tags({
'©xyz': '+55.6761+012.5683+000.000/', # Copenhagen
'©ART': 'Emma Slot Henriksen',
'©nam': 'Optagelse fra skolegården',
}))
print(f"Written: {out.name}")
# ── 19_video_no_pii.mp4 ──────────────────────────────────────────────────────
def make_mp4_no_pii():
out = HERE / '19_video_no_pii.mp4'
out.write_bytes(_minimal_mp4_base()) # no moov/udta/meta/ilst — no tags
print(f"Written: {out.name}")
if __name__ == "__main__":
make_docx()
make_xlsx()
make_mp3_pii()
make_flac_pii()
make_mp3_no_pii()
make_flac_no_pii()
make_mp4_gps()
make_mp4_no_pii()
print("Done.")