// 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"); }