aboutsummaryrefslogtreecommitdiff
path: root/test/settings/OtpFormTest.mc
diff options
context:
space:
mode:
Diffstat (limited to 'test/settings/OtpFormTest.mc')
-rw-r--r--test/settings/OtpFormTest.mc182
1 files changed, 182 insertions, 0 deletions
diff --git a/test/settings/OtpFormTest.mc b/test/settings/OtpFormTest.mc
new file mode 100644
index 0000000..9e6249e
--- /dev/null
+++ b/test/settings/OtpFormTest.mc
@@ -0,0 +1,182 @@
+// Author: cirakg & claude ai
+// Summary: Tests for the Add OTP form: every type, every validation message, clearing, warnings, idempotency, pending locked entries.
+
+import Toybox.Lang;
+import Toybox.Test;
+
+// A TOTP form is added with all its fields; the form is cleared (secret
+// included), the switch turned off and the status names the new ID.
+(:test)
+function settingsAddsTotpAndClearsTheForm(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1);
+ s.set("otpAlgorithm", Hashes.SHA256);
+ s.set("otpDigits", 8);
+ s.set("otpPeriod", 60);
+ s.set("otpGroup", "Work");
+ var job = SettingsFixtures.run(vault, s);
+ if (vault.ids().size() != 1) {
+ logger.error("status: " + SettingsFixtures.text(s, OtpForm.STATUS));
+ return false;
+ }
+ var id = vault.ids()[0];
+ var e = vault.open(id);
+ var fields = e.name.equals("GitHub") && e.account.equals("alice") && e.group.equals("Work") && e.algorithm == Hashes.SHA256
+ && e.digits == 8 && e.period == 60 && e.source == Entry.SOURCE_MANUAL && !e.locked;
+ e.wipeSecrets();
+ return fields && SettingsFixtures.secretOf(vault, id).equals("12345678901234567890")
+ && SettingsFixtures.text(s, OtpForm.STATUS).equals("Added " + id + " GitHub (alice)")
+ && SettingsFixtures.text(s, "otpSecret").equals("") && SettingsFixtures.text(s, "otpIssuer").equals("")
+ && !Forms.flag(s, OtpForm.SWITCH) && Forms.number(s, "otpDigits") == 6 && job.forms == 1 && job.changed
+ && SettingsFixtures.hasLine(job.lines, "Add OTP: Added " + id);
+}
+
+// HOTP keeps its counter; Steam ignores algorithm, digits and period.
+(:test)
+function settingsAddsHotpAndSteam(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_HOTP, "VPN", "", AegisFixtures.SECRET_1);
+ s.set("otpCounter", 42l);
+ SettingsFixtures.run(vault, s);
+ SettingsFixtures.otp(s, Entry.OTP_STEAM, "Steam", "erin", AegisFixtures.SECRET_3);
+ s.set("otpAlgorithm", Hashes.SHA512);
+ s.set("otpDigits", 8);
+ s.set("otpPeriod", 10);
+ SettingsFixtures.run(vault, s);
+ if (vault.ids().size() != 2) {
+ logger.error("status: " + SettingsFixtures.text(s, OtpForm.STATUS));
+ return false;
+ }
+ var hotp = vault.open(vault.ids()[0]);
+ var steam = vault.open(vault.ids()[1]);
+ var ok = hotp.type == Entry.OTP_HOTP && hotp.counter == 42l && steam.type == Entry.OTP_STEAM
+ && steam.algorithm == Hashes.SHA1 && steam.period == Otp.STEAM_PERIOD;
+ hotp.wipeSecrets();
+ steam.wipeSecrets();
+ return ok;
+}
+
+// Each invalid field gives its message; nothing is added, only the secret
+// is cleared (so the rest can be corrected) and the switch goes off.
+(:test)
+function settingsRejectsInvalidOtpForms(logger as Test.Logger) as Boolean {
+ // [field, value, expected message part]
+ var cases = [
+ ["otpSecret", "JBSW1Y3D","Not added: Secret: '1' is not a Base32 character (position 5)"],
+ ["otpSecret", " ", "Secret: missing"],
+ ["otpSecret", "JBSWY3DP=A", "Secret: characters after '=' padding (position 10)"],
+ ["otpDigits", 9, "Digits: choose 6, 7 or 8"],
+ ["otpDigits", "eight", "Digits: choose 6, 7 or 8"],
+ ["otpPeriod", 0, "Period: 1 to 300 seconds"],
+ ["otpPeriod", 301, "Period: 1 to 300 seconds"],
+ ["otpAlgorithm", 5, "Algorithm: choose SHA1, SHA256 or SHA512"],
+ ["otpType", 7, "Type: choose TOTP, HOTP or Steam"],
+ ["otpIssuer", "Git\nHub", "Issuer: control character at position 4"],
+ ["otpGroup", SettingsFixtures.repeat("g", 33), "Group: too long (33 bytes, at most 32)"],
+ ["otpAccount", SettingsFixtures.repeat("é", 33), "Account: too long (66 bytes, at most 64)"]
+ ];
+ var ok = true;
+ for (var i = 0; i < cases.size(); i++) {
+ var c = cases[i] as Array;
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_2);
+ s.set("otpLocked", true); // a field no case changes: it must be kept
+ s.set(c[0] as String, c[1] as String or Number);
+ SettingsFixtures.run(vault, s);
+ var status = SettingsFixtures.text(s, OtpForm.STATUS);
+ if (status.find(c[2] as String) == null || vault.ids().size() != 0 || vault.pendingIds().size() != 0
+ || Forms.flag(s, OtpForm.SWITCH) || !SettingsFixtures.text(s, "otpSecret").equals("")
+ || !Forms.flag(s, "otpLocked")) {
+ logger.error("case " + i + ": status '" + status + "'");
+ ok = false;
+ }
+ }
+ return ok;
+}
+
+(:test)
+function settingsNeedsANameAndACounter(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, " ", "", AegisFixtures.SECRET_2);
+ SettingsFixtures.run(vault, s);
+ var names = SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Fill in the issuer or the account");
+ SettingsFixtures.otp(s, Entry.OTP_HOTP, "VPN", "", AegisFixtures.SECRET_2);
+ s.set("otpCounter", -1l);
+ SettingsFixtures.run(vault, s);
+ return names && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Counter: a whole number, 0 or more") && vault.ids().size() == 0;
+}
+
+// A secret below 80 bits is accepted with a warning (README 10.4).
+(:test)
+function settingsWarnsAboutShortSecrets(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "Old", "x", "JBSW Y3DP"); // 5 bytes; the space is ignored
+ var job = SettingsFixtures.run(vault, s);
+ return vault.ids().size() == 1 && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Warning: the secret is only 40 bits")
+ && SettingsFixtures.hasLine(job.lines, "Warning: the secret is only 40 bits");
+}
+
+// The same form sent twice is applied once (README 10.3, rule 3); any
+// change makes it a new request.
+(:test)
+function settingsAppliesTheSameOtpFormOnce(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1);
+ SettingsFixtures.run(vault, s);
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice", AegisFixtures.SECRET_1);
+ SettingsFixtures.run(vault, s);
+ var repeated = SettingsFixtures.contains(logger, s, OtpForm.STATUS, "Already added") && vault.ids().size() == 1
+ && SettingsFixtures.text(s, "otpSecret").equals("");
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "GitHub", "alice (work)", AegisFixtures.SECRET_1);
+ SettingsFixtures.run(vault, s);
+ return repeated && vault.ids().size() == 2;
+}
+
+// A locked entry sent while the watch is locked waits as pending (README 7.7).
+(:test)
+function settingsLockedOtpWaitsForThePin(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ StorageFixtures.setPin(vault, StorageFixtures.PIN);
+ vault.lock();
+ var s = SettingsFixtures.store();
+ SettingsFixtures.otp(s, Entry.OTP_TOTP, "Bank", "carol", AegisFixtures.SECRET_1);
+ s.set("otpLocked", true);
+ SettingsFixtures.run(vault, s);
+ return vault.pendingIds().size() == 1 && vault.ids().size() == 0
+ && SettingsFixtures.contains(logger, s, OtpForm.STATUS, "It waits for your PIN on the watch");
+}
+
+// Nothing switched on: no form is looked at, nothing is written.
+(:test)
+function settingsIgnoresFormsThatAreOff(logger as Test.Logger) as Boolean {
+ var vault = SettingsFixtures.vault();
+ var s = SettingsFixtures.store();
+ s.set("otpIssuer", "GitHub");
+ s.set("otpSecret", AegisFixtures.SECRET_1);
+ var job = SettingsFixtures.run(vault, s);
+ return job.forms == 0 && vault.ids().size() == 0 && SettingsFixtures.text(s, "otpSecret").equals(AegisFixtures.SECRET_1)
+ && job.lines.size() == 0;
+}
+
+// Field values of unexpected types are read safely (the phone promises
+// nothing): numbers as text, longs, whole floats.
+(:test)
+function settingsReadsLooselyTypedNumbers(logger as Test.Logger) as Boolean {
+ var s = new Forms.MemoryStore();
+ s.set("a", "42");
+ s.set("b", 7l);
+ s.set("c", 3.0f);
+ s.set("d", 3.5f);
+ s.set("e", "-12");
+ s.set("f", "x1");
+ s.set("g", true);
+ return Forms.number(s, "a") == 42 && Forms.number(s, "b") == 7 && Forms.number(s, "c") == 3 && Forms.number(s, "d") == null
+ && Forms.long(s, "e") == -12l && Forms.number(s, "f") == null && Forms.number(s, "g") == null
+ && Forms.number(s, "missing") == null && Forms.text(s, "g").equals("") && !Forms.flag(s, "a");
+}