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.
117 lines
5.0 KiB
Python
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()
|