Add QR-to-CSV utility and switch canonical format to CSV

Add qr_to_sheet.py: decode saved OTP QR screenshots and append new
credentials to the canonical CSV, deduplicated on the otpauth:// data.
Add otpauth.py (parse_otpauth / canonical_key / build_otpauth), shared
by both tools. The canonical format becomes a minimal CSV (issuer,
account, otpauth_uri, source_file); load_credentials reads CSV or xlsx
and derives crypto from the immutable otpauth_uri while taking
issuer/account from their editable label columns.
This commit is contained in:
2026-07-08 14:49:59 -07:00
parent cd705a446b
commit 07f65835d0
7 changed files with 577 additions and 48 deletions

87
test_qr.py Normal file
View File

@@ -0,0 +1,87 @@
"""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
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")), [])
if __name__ == "__main__":
unittest.main()