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:
87
test_qr.py
Normal file
87
test_qr.py
Normal 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()
|
||||
Reference in New Issue
Block a user