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:
1 parent
2a2d79de90
commit
d42518dc81
16 files changed
+476
-21
No files matched your search
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+193
@@ -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.")
|
||||
Reference in new issue
Block a user