Files
otp-import/test_qr.py
Amal Graafstra a8a405600c Add --import-qr to run the QR->CSV step before importing
Wrap the QR decode/append step behind --import-qr on the importer so a
single command runs the whole pipeline: decode QR images, append only
new credentials to the CSV (deduped on otpauth://, existing entries
silently skipped), then validate and import. QR folder defaults to the
CSV's folder; override with --qr-images. Refactor qr_to_sheet into
reusable collect_candidates / apply_new_candidates / run_qr_import.
2026-07-08 15:31:03 -07:00

117 lines
5.0 KiB
Python

"""Hardware-free tests for the QR->CSV utility (otpauth parsing + dedup)."""
import os
import tempfile
import unittest
from otpauth import parse_otpauth, canonical_key
from qr_to_sheet import (existing_keys, dedup_new, append_csv, read_csv_rows,
apply_new_candidates)
class TestParseOtpauth(unittest.TestCase):
def test_basic_totp(self):
o = parse_otpauth("otpauth://totp/Google:amal@amal.net?secret=abcde&issuer=Google")
self.assertEqual(o.type, "totp")
self.assertEqual(o.issuer, "Google")
self.assertEqual(o.account, "amal@amal.net")
self.assertEqual(o.secret, "ABCDE") # uppercased
self.assertEqual((o.algorithm, o.digits, o.period), ("SHA1", 6, 30))
def test_percent_decoding(self):
o = parse_otpauth("otpauth://totp/Google%3Aamal%40amal.net?secret=AA&issuer=Google")
self.assertEqual(o.account, "amal@amal.net")
def test_issuer_from_label_when_no_param(self):
o = parse_otpauth("otpauth://totp/SAW:michelle@d.com?secret=AA")
self.assertEqual(o.issuer, "SAW")
self.assertEqual(o.account, "michelle@d.com")
def test_label_without_issuer_prefix(self):
o = parse_otpauth("otpauth://totp/amalg?secret=AA&issuer=Porkbun")
self.assertEqual(o.issuer, "Porkbun")
self.assertEqual(o.account, "amalg")
def test_explicit_params(self):
o = parse_otpauth("otpauth://totp/X:y?secret=AA&algorithm=SHA256&digits=8&period=60")
self.assertEqual((o.algorithm, o.digits, o.period), ("SHA256", 8, 60))
class TestCanonicalKey(unittest.TestCase):
def test_label_issuer_equals_param_issuer(self):
a = parse_otpauth("otpauth://totp/Google:amal@amal.net?secret=JBSWY3DP&issuer=Google")
b = parse_otpauth("otpauth://totp/amal@amal.net?secret=JBSWY3DP&issuer=Google")
self.assertEqual(canonical_key(a), canonical_key(b))
def test_secret_case_insensitive(self):
a = parse_otpauth("otpauth://totp/X:y?secret=jbswy3dp&issuer=X")
b = parse_otpauth("otpauth://totp/X:y?secret=JBSWY3DP&issuer=X")
self.assertEqual(canonical_key(a), canonical_key(b))
def test_different_secret_differs(self):
a = parse_otpauth("otpauth://totp/X:y?secret=AAAA&issuer=X")
b = parse_otpauth("otpauth://totp/X:y?secret=BBBB&issuer=X")
self.assertNotEqual(canonical_key(a), canonical_key(b))
A = "otpauth://totp/X:a?secret=AAAA&issuer=X"
B = "otpauth://totp/X:b?secret=BBBB&issuer=X"
class TestDedup(unittest.TestCase):
def test_dedups_against_existing_and_within_batch(self):
existing = [{"issuer": "X", "account": "a", "otpauth_uri": A, "source_file": "a.png"}]
keys = existing_keys(existing)
new_rows, dups = dedup_new([(A, "a2.png"), (B, "b.png"), (B, "b2.png")], keys)
self.assertEqual([r["otpauth_uri"] for r in new_rows], [B])
self.assertEqual(new_rows[0]["issuer"], "X")
self.assertEqual(new_rows[0]["account"], "b")
self.assertEqual(dups, 2)
class TestCsvIO(unittest.TestCase):
def test_append_creates_then_appends_preserving_rows(self):
d = tempfile.mkdtemp()
p = os.path.join(d, "otp-keys.csv")
append_csv(p, [{"issuer": "X", "account": "a", "otpauth_uri": "u1", "source_file": "a.png"}])
append_csv(p, [{"issuer": "Y", "account": "b", "otpauth_uri": "u2", "source_file": "b.png"}])
rows = read_csv_rows(p)
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["issuer"], "X")
self.assertEqual(rows[1]["account"], "b")
self.assertEqual(rows[1]["otpauth_uri"], "u2")
def test_read_missing_file_is_empty(self):
self.assertEqual(read_csv_rows(os.path.join(tempfile.mkdtemp(), "nope.csv")), [])
class TestApplyNewCandidates(unittest.TestCase):
def _csv_with_A(self):
p = os.path.join(tempfile.mkdtemp(), "otp-keys.csv")
append_csv(p, [{"issuer": "X", "account": "a", "otpauth_uri": A, "source_file": "a.png"}])
return p
def test_only_new_otpauth_is_appended(self):
p = self._csv_with_A()
summary = apply_new_candidates([(A, "a2.png"), (B, "b.png"), (B, "b2.png")], p)
self.assertEqual(summary["added"], 1)
self.assertEqual(summary["duplicates"], 2)
rows = read_csv_rows(p)
self.assertEqual(len(rows), 2) # existing A + new B only
self.assertEqual(rows[1]["otpauth_uri"], B)
def test_all_existing_appends_nothing(self):
p = self._csv_with_A()
summary = apply_new_candidates([(A, "a2.png")], p)
self.assertEqual((summary["added"], summary["duplicates"]), (0, 1))
self.assertEqual(len(read_csv_rows(p)), 1) # unchanged
def test_dry_run_reports_but_does_not_write(self):
p = self._csv_with_A()
summary = apply_new_candidates([(B, "b.png")], p, dry_run=True)
self.assertEqual(summary["added"], 1)
self.assertEqual(len(read_csv_rows(p)), 1) # not written
if __name__ == "__main__":
unittest.main()