diff options
Diffstat (limited to 'test/settings/OtpFormTest.mc')
| -rw-r--r-- | test/settings/OtpFormTest.mc | 182 |
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"); +} |
