Trog-Vault.t 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. #!/usr/bin/env perl
  2. # The vault, the code that authorizes opening it, and the hashing underneath
  3. # both. Nothing here needs a webserver: what is being tested is that a database
  4. # on its own is not enough, and that a code is worth one thing.
  5. use 5.040;
  6. use warnings;
  7. use FindBin;
  8. use lib "$FindBin::Bin/../lib";
  9. use Test::More;
  10. use Test::MockModule qw{strict};
  11. our ( $REPO, $ROOT, $OLDCWD );
  12. BEGIN {
  13. require Cwd;
  14. require File::Copy;
  15. require File::Path;
  16. require File::Temp;
  17. $REPO = Cwd::abs_path("$FindBin::Bin/..");
  18. $OLDCWD = Cwd::getcwd();
  19. $ROOT = File::Temp::tempdir( 'tcms-vault-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
  20. $ENV{HOME} = $ROOT;
  21. File::Path::make_path( map { "$ROOT/$_" } qw{config schema logs totp} );
  22. File::Copy::copy( "$REPO/schema/auth.schema", "$ROOT/schema/auth.schema" ) or die $!;
  23. open( my $fh, '>', "$ROOT/config/default.cfg" ) or die $!;
  24. print {$fh} "[general]\n data_model=FlatFile\n hostname=vault.example.com\n";
  25. close $fh;
  26. # The key the way a deployment hands one over: base64, in the environment,
  27. # put there by tpsgi.sh out of a systemd credential. Trog::Vault takes it
  28. # out of %ENV as it loads, which is the behaviour tested below.
  29. require Crypt::PRNG;
  30. require Crypt::Misc;
  31. $ENV{TPSGI_VAULT_KEY} = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
  32. chdir($ROOT) or die "could not chdir to the sandbox: $!";
  33. }
  34. END {
  35. chdir($OLDCWD) if $OLDCWD;
  36. }
  37. require_ok('Trog::Auth') or BAIL_OUT("Can't find Trog::Auth");
  38. require_ok('Trog::Vault') or BAIL_OUT("Can't find SUT");
  39. Trog::Auth::useradd( 'bob', 'Bob Bobson', 'hunter2', ['admin'], 'bob@example.com' )
  40. or BAIL_OUT('could not make a user to test with');
  41. Trog::Auth::useradd( 'eve', 'Eve Evilson', 'letmein', ['public'], 'eve@example.com' )
  42. or BAIL_OUT('could not make a second user');
  43. subtest 'the key is taken out of the environment' => sub {
  44. ok( Trog::Vault::has_key(), 'the key was read' );
  45. ok( !exists $ENV{TPSGI_VAULT_KEY}, 'and is gone from the environment, so nothing we fork inherits it' );
  46. like( Trog::Vault::key_id(), qr/^[0-9a-f]{16}$/, 'and fingerprints to something a row can name' );
  47. };
  48. subtest 'what a secret looks like on disk' => sub {
  49. my ( $ok, $why ) = Trog::Vault::set( 'bob', 'keepass', 'correct horse battery staple' );
  50. is( $ok, 1, 'a secret can be stored' ) or diag($why);
  51. my $dbh = Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' );
  52. my $rows = $dbh->selectall_arrayref( "SELECT * FROM user_secret WHERE username='bob'", { Slice => {} } );
  53. is( scalar(@$rows), 1, 'and lands in one row' );
  54. my $row = $rows->[0];
  55. unlike( $row->{ciphertext}, qr/correct horse/, 'which does not contain the secret' );
  56. is( length( $row->{salt} ), 32, 'carries a fresh 32 byte salt' );
  57. is( length( $row->{tag} ), 16, 'and a GCM tag' );
  58. is( $row->{key_id}, Trog::Vault::key_id(), 'naming the key it was sealed under' );
  59. is( Trog::Vault::get( 'bob', 'keepass' ), 'correct horse battery staple', 'and it comes back out' );
  60. };
  61. subtest 'a fresh salt every write' => sub {
  62. my $dbh = Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' );
  63. Trog::Vault::set( 'bob', 'same', 'the very same secret' );
  64. my ($first) = $dbh->selectrow_array("SELECT hex(ciphertext) FROM user_secret WHERE username='bob' AND name='same'");
  65. Trog::Vault::set( 'bob', 'same', 'the very same secret' );
  66. my ($second) = $dbh->selectrow_array("SELECT hex(ciphertext) FROM user_secret WHERE username='bob' AND name='same'");
  67. # Same plaintext, same key, different ciphertext -- which is what a random
  68. # salt per write buys, and what GCM needs to stay safe.
  69. isnt( $first, $second, 'the same secret stored twice does not look the same twice' );
  70. is( Trog::Vault::get( 'bob', 'same' ), 'the very same secret', 'and still reads back' );
  71. };
  72. subtest 'a row cannot be moved or renamed' => sub {
  73. my $dbh = Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' );
  74. # Exactly what somebody with write access to the database would try: take
  75. # the admin's sealed passphrase and make it their own.
  76. $dbh->do("INSERT OR REPLACE INTO user_secret (username, name, key_id, salt, ciphertext, tag, created) SELECT 'eve', name, key_id, salt, ciphertext, tag, created FROM user_secret WHERE username='bob' AND name='keepass'");
  77. is( Trog::Vault::get( 'eve', 'keepass' ), undef, 'a row lifted into another account does not open' );
  78. $dbh->do("INSERT OR REPLACE INTO user_secret (username, name, key_id, salt, ciphertext, tag, created) SELECT 'bob', 'renamed', key_id, salt, ciphertext, tag, created FROM user_secret WHERE username='bob' AND name='keepass'");
  79. is( Trog::Vault::get( 'bob', 'renamed' ), undef, 'nor does one renamed into being the answer to a different question' );
  80. is( Trog::Vault::get( 'bob', 'keepass' ), 'correct horse battery staple', 'while the row it was copied from is untouched' );
  81. $dbh->do("DELETE FROM user_secret WHERE name IN ('renamed') OR username='eve'");
  82. };
  83. subtest 'and not without the key it was sealed under' => sub {
  84. my $dbh = Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' );
  85. # A different installation's key, which is what a stolen database meets.
  86. my $theirs = Trog::Vault::key();
  87. {
  88. no warnings qw{once};
  89. local $Trog::Vault::master = Crypt::PRNG::random_bytes(32);
  90. is( Trog::Vault::get( 'bob', 'keepass' ), undef, 'the wrong key opens nothing' );
  91. my $listed = Trog::Vault::list('bob');
  92. my ($keepass) = grep { $_->{name} eq 'keepass' } @$listed;
  93. is( $keepass->{readable}, 0, 'and the listing says so rather than pretending' );
  94. }
  95. is( Trog::Vault::get( 'bob', 'keepass' ), 'correct horse battery staple', 'the right one still does' );
  96. # Tampering, as opposed to the wrong key: the tag is what notices.
  97. $dbh->do("UPDATE user_secret SET ciphertext = ciphertext || X'00' WHERE username='bob' AND name='keepass'");
  98. is( Trog::Vault::get( 'bob', 'keepass' ), undef, 'a row somebody edited does not open either' );
  99. Trog::Vault::set( 'bob', 'keepass', 'correct horse battery staple' );
  100. };
  101. subtest 'the listing never shows a value' => sub {
  102. my $listed = Trog::Vault::list('bob');
  103. ok( scalar(@$listed) >= 2, 'lists what bob has' );
  104. foreach my $secret (@$listed) {
  105. ok( !exists $secret->{ciphertext}, "$secret->{name} is listed without its ciphertext" );
  106. ok( exists $secret->{created}, "$secret->{name} says when it was stored" );
  107. }
  108. is_deeply( Trog::Vault::list('eve'), [], 'and one user cannot see another\'s' );
  109. ok( Trog::Vault::forget( 'bob', 'same' ), 'a secret can be forgotten' );
  110. is( Trog::Vault::get( 'bob', 'same' ), undef, 'after which it is gone' );
  111. is( Trog::Vault::forget( 'bob', 'same' ), 0, 'and forgetting it twice is not a thing that happened' );
  112. };
  113. subtest 'a name is a name' => sub {
  114. foreach my $bad ( '../../etc/passwd', 'has spaces', '', 'a' x 100, '.hidden' ) {
  115. my ( $ok, $why ) = Trog::Vault::set( 'bob', $bad, 'nope' );
  116. is( $ok, 0, "'$bad' is refused as a name" );
  117. }
  118. my ( $ok, $why ) = Trog::Vault::set( 'bob', 'fine', '' );
  119. is( $ok, 0, 'and an empty secret is not a secret' );
  120. };
  121. subtest 'no key, no vault' => sub {
  122. no warnings qw{once};
  123. local $Trog::Vault::master = undef;
  124. local $Trog::Vault::looked_further = 1;
  125. ok( !Trog::Vault::has_key(), 'an installation with no key has no vault' );
  126. my ( $ok, $why ) = Trog::Vault::set( 'bob', 'nowhere', 'to put it' );
  127. is( $ok, 0, 'storing is refused' );
  128. like( $why, qr/no vault key/, 'saying why, since this is a thing to go and configure' );
  129. is( Trog::Vault::get( 'bob', 'keepass' ), undef, 'and nothing already stored is offered' );
  130. };
  131. subtest 'the other two places a key can be' => sub {
  132. no warnings qw{once};
  133. require Crypt::Misc;
  134. # A key file, for a machine not running systemd. bin/tcms-vault-key --file
  135. # writes exactly this: base64 and a newline.
  136. my $on_disk = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
  137. open( my $fh, '>', 'config/secrets.key' ) or die $!;
  138. print {$fh} "$on_disk\n";
  139. close $fh;
  140. chmod( oct('600'), 'config/secrets.key' );
  141. {
  142. local $Trog::Vault::master = undef;
  143. local $Trog::Vault::looked_further = 0;
  144. is( Crypt::Misc::encode_b64( Trog::Vault::key() ), $on_disk, 'a key file is found, newline and all' );
  145. }
  146. # And systemd's credential store, which is where it should be: the directory
  147. # systemd hands over, for a service that is not chrooted away from it.
  148. my $in_store = Crypt::Misc::encode_b64( Crypt::PRNG::random_bytes(32) );
  149. File::Path::make_path('creds');
  150. open( $fh, '>', 'creds/tpsgi-vault' ) or die $!;
  151. print {$fh} $in_store;
  152. close $fh;
  153. {
  154. local $ENV{CREDENTIALS_DIRECTORY} = 'creds';
  155. local $Trog::Vault::master = undef;
  156. local $Trog::Vault::looked_further = 0;
  157. is( Crypt::Misc::encode_b64( Trog::Vault::key() ), $in_store, 'the credential store wins over the file beside the database' );
  158. }
  159. # Something that is not a key is not quietly used as one.
  160. open( $fh, '>', 'config/secrets.key' ) or die $!;
  161. print {$fh} "hunter2\n";
  162. close $fh;
  163. {
  164. local $Trog::Vault::master = undef;
  165. local $Trog::Vault::looked_further = 0;
  166. is( Trog::Vault::key(), undef, 'a key that is not 32 bytes of base64 is refused' );
  167. }
  168. unlink('config/secrets.key');
  169. };
  170. subtest 'a code is worth one thing' => sub {
  171. my ( $uri, $qr, $failure, $message, $totp ) = Trog::Auth::totp( 'bob', 'vault.example.com' );
  172. ok( $uri, 'bob is enrolled' ) or diag($message);
  173. my $now = time();
  174. my $code = $totp->expected_totp_code($now);
  175. my ( $ok, $why ) = Trog::Auth::spend_totp( 'bob', $code );
  176. is( $ok, 1, 'the code from their authenticator is accepted' ) or diag($why);
  177. ( $ok, $why ) = Trog::Auth::spend_totp( 'bob', $code );
  178. is( $ok, 0, 'and is not accepted a second time' );
  179. like( $why, qr/already been used/, 'saying so, since the answer is to wait thirty seconds' );
  180. # A code from before the one just spent is also spent, whatever the
  181. # arithmetic says: that is what walking the counter forward is for.
  182. my $previous = $totp->expected_totp_code( $now - 30 );
  183. ( $ok, $why ) = Trog::Auth::spend_totp( 'bob', $previous );
  184. is( $ok, 0, 'nor is an older code from inside the tolerance window' );
  185. ( $ok, $why ) = Trog::Auth::spend_totp( 'bob', '000000' );
  186. is( $ok, 0, 'a wrong code is refused' );
  187. ( $ok, $why ) = Trog::Auth::spend_totp( 'bob', 'notacode' );
  188. is( $ok, 0, 'and so is something that is not a code at all' );
  189. ( $ok, $why ) = Trog::Auth::spend_totp( 'eve', $totp->expected_totp_code( $now + 30 ) );
  190. is( $ok, 0, 'somebody who has not enrolled cannot spend anything' );
  191. like( $why, qr/no second factor/, 'and is told that rather than that their code was wrong' );
  192. # The next window is a new code and works, which is the whole cost of this.
  193. ( $ok, $why ) = Trog::Auth::spend_totp( 'bob', $totp->expected_totp_code( $now + 30 ) );
  194. is( $ok, 1, 'the next code works' ) or diag($why);
  195. };
  196. subtest 'passwords are argon2id, and become it' => sub {
  197. my $dbh = Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' );
  198. my ($hash) = $dbh->selectrow_array("SELECT hash FROM user WHERE name='bob'");
  199. like( $hash, qr/^\$argon2id\$/, 'a user made today is stored as argon2id' );
  200. ok( Trog::Auth::check_password( 'bob', 'hunter2' ), 'and their password checks out' );
  201. ok( !Trog::Auth::check_password( 'bob', 'hunter3' ), 'while a wrong one does not' );
  202. ok( !Trog::Auth::check_password( 'bob', '' ), 'and neither does nothing at all' );
  203. # A row as it was before this change: one unstretched sha256 of the password
  204. # and the salt beside it.
  205. require Digest::SHA;
  206. $dbh->do( "UPDATE user SET hash=?, salt=? WHERE name=?", undef, Digest::SHA::sha256( 'letmein' . 'oldsalt' ), 'oldsalt', 'eve' );
  207. my ($legacy) = $dbh->selectrow_array("SELECT hash FROM user WHERE name='eve'");
  208. unlike( $legacy, qr/^\$argon2/, 'a row from before this reads as the old kind' );
  209. ok( !Trog::Auth::check_password( 'eve', 'wrong' ), 'which still refuses a wrong password' );
  210. ($legacy) = $dbh->selectrow_array("SELECT hash FROM user WHERE name='eve'");
  211. unlike( $legacy, qr/^\$argon2/, 'and is left alone when it does' );
  212. ok( Trog::Auth::check_password( 'eve', 'letmein' ), 'and accepts the right one' );
  213. my ($upgraded) = $dbh->selectrow_array("SELECT hash FROM user WHERE name='eve'");
  214. like( $upgraded, qr/^\$argon2id\$/, 'rewriting it as argon2id on the way past' );
  215. ok( Trog::Auth::check_password( 'eve', 'letmein' ), 'after which the same password still works' );
  216. # Raising the cost later is a one line change, not a migration, for the same
  217. # reason: the next correct password rewrites the row.
  218. {
  219. no warnings qw{once};
  220. local $Trog::Auth::argon2_time = $Trog::Auth::argon2_time + 1;
  221. ok( Trog::Auth::check_password( 'eve', 'letmein' ), 'a hash made under weaker parameters still verifies' );
  222. my ($rehashed) = $dbh->selectrow_array("SELECT hash FROM user WHERE name='eve'");
  223. isnt( $rehashed, $upgraded, 'and is rewritten under the stronger ones' );
  224. }
  225. };
  226. subtest 'logging in spends a code' => sub {
  227. my ( $uri, $qr, $failure, $message, $totp ) = Trog::Auth::totp( 'bob', 'vault.example.com' );
  228. # The subtest above spent the code for the window we are standing in, and
  229. # the counter only walks forwards. Standing here for thirty seconds would
  230. # say the same thing and cost thirty seconds.
  231. my $dbh = Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' );
  232. $dbh->do("DELETE FROM totp_spent WHERE username='bob'");
  233. my $now = time();
  234. my $code = $totp->expected_totp_code($now);
  235. is( Trog::Auth::mksession( 'bob', 'hunter2', '' ), '', 'an enrolled user cannot log in without a code' );
  236. is( Trog::Auth::mksession( 'bob', 'wrong', $code ), '', 'nor with a wrong password' );
  237. # And a login that failed on the password did not spend the code on the way
  238. # past, or a wrong guess would cost the person their next thirty seconds.
  239. ok( Trog::Auth::mksession( 'bob', 'hunter2', $code ), 'the right password and a live code get a session' );
  240. is( Trog::Auth::mksession( 'bob', 'hunter2', $code ), '', 'and that code cannot be replayed into a second one' );
  241. };
  242. subtest 'when the key is gone' => sub {
  243. no warnings qw{once};
  244. require Trog::Routes::HTML;
  245. my $tpsgi = bless {}, 'FakeTPSGI2';
  246. {
  247. no warnings qw{once};
  248. *FakeTPSGI2::see_also = sub { return [ 303, [ Location => $_[1] ], [''] ] };
  249. }
  250. my $rendered;
  251. my $index = Test::MockModule->new('Trog::Routes::HTML');
  252. $index->redefine( index => sub { $rendered = $_[0]; return [ 200, [], [''] ] } );
  253. Trog::Vault::set( 'bob', 'keepass', 'correct horse battery staple' );
  254. # The machine was rebuilt and the credential went with it. A key lives and
  255. # dies with its machine on purpose, so this is the ordinary case rather than
  256. # a disaster: everything sealed under the old one is gone, and the users
  257. # store theirs again.
  258. foreach my $gone (
  259. [ 'no key at all', sub { $Trog::Vault::master = undef; $Trog::Vault::looked_further = 1 } ],
  260. [ 'a different key', sub { $Trog::Vault::master = Crypt::PRNG::random_bytes(32) } ],
  261. ) {
  262. my ( $what, $break ) = @$gone;
  263. local $Trog::Vault::master = Trog::Vault::key();
  264. local $Trog::Vault::looked_further = 1;
  265. $break->();
  266. # Nothing explodes, and nothing claims a secret is usable.
  267. is( Trog::Vault::has( 'bob', 'keepass' ), 0, "$what: nothing reads as usable" );
  268. is( Trog::Vault::get( 'bob', 'keepass' ), undef, "$what: and nothing opens" );
  269. Trog::Routes::HTML::secrets( { user => 'bob', user_acls => ['admin'], tpsgi => $tpsgi } );
  270. my ($listed) = grep { $_->{name} eq 'keepass' } @{ $rendered->{secrets} };
  271. ok( $listed, "$what: the row is still listed rather than vanishing" );
  272. is( $listed->{readable}, 0, "$what: marked as one that will not open" );
  273. # And a page which knows storing cannot work does not draw the form for
  274. # it -- has_vault is what the template hangs that on.
  275. is( $rendered->{has_vault}, ( $what eq 'no key at all' ? 0 : 1 ), "$what: the page knows whether there is a vault to store into" );
  276. my ( $stored, $why ) = Trog::Vault::set( 'bob', 'replacement', 'a new one' );
  277. if ( $what eq 'no key at all' ) {
  278. is( $stored, 0, "$what: storing is refused rather than silently lost" );
  279. like( $why, qr/no vault key/, "$what: saying why" );
  280. }
  281. else {
  282. is( $stored, 1, "$what: a new key stores new secrets fine" );
  283. is( Trog::Vault::get( 'bob', 'replacement' ), 'a new one', "$what: and opens them" );
  284. Trog::Vault::forget( 'bob', 'replacement' );
  285. }
  286. }
  287. # The key came back: what was sealed under it was never damaged, only
  288. # unreadable while it was away.
  289. is( Trog::Vault::get( 'bob', 'keepass' ), 'correct horse battery staple', 'and the original key still opens the original row' );
  290. };
  291. subtest 'the page a user manages them from' => sub {
  292. require Trog::Routes::HTML;
  293. my $tpsgi = bless {}, 'FakeTPSGI';
  294. {
  295. no warnings qw{once};
  296. *FakeTPSGI::see_also = sub { return [ 303, [ Location => $_[1] ], [''] ] };
  297. *FakeTPSGI::forbidden = sub { return [ 403, [], ['no'] ] };
  298. }
  299. my $route = $Trog::Routes::HTML::routes{'/secrets'};
  300. ok( $route, 'the page is a route' );
  301. ok( $route->{auth}, 'behind a login' );
  302. ok( $route->{nocache}, 'and never cached, since it is about who is asking' );
  303. ok( !$route->{totp_exempt}, 'and not somewhere an unenrolled login can get to' );
  304. my $rendered;
  305. my $index = Test::MockModule->new('Trog::Routes::HTML');
  306. $index->redefine( index => sub { $rendered = $_[0]; return [ 200, [], [''] ] } );
  307. Trog::Routes::HTML::secrets( { user => 'bob', user_acls => ['admin'], tpsgi => $tpsgi } );
  308. ok( $rendered->{has_vault}, 'the page knows there is a vault' );
  309. my ($keepass) = grep { $_->{name} eq 'keepass' } @{ $rendered->{secrets} };
  310. ok( $keepass, "bob's own secret is listed" );
  311. ok( !exists $keepass->{ciphertext}, 'without its ciphertext' );
  312. ok( $keepass->{stored}, 'with when it was stored' );
  313. # The username comes off the session, so there is no field to put somebody
  314. # else's in.
  315. Trog::Routes::HTML::secrets( { user => 'eve', user_acls => ['public'], tpsgi => $tpsgi } );
  316. is_deeply( $rendered->{secrets}, [], "and eve sees none of bob's" );
  317. # Storing, and the thing stored not surviving the request that stored it.
  318. my $query = { user => 'eve', tpsgi => $tpsgi, name => 'ssh', secret => 'a passphrase' };
  319. my $res = Trog::Routes::HTML::secrets_save($query);
  320. is( $res->[0], 303, 'saving redirects back to the page' );
  321. ok( !exists $query->{secret}, 'and the secret is gone from the query afterwards' );
  322. is( Trog::Vault::get( 'eve', 'ssh' ), 'a passphrase', 'having been stored' );
  323. $res = Trog::Routes::HTML::secrets_forget( { user => 'eve', tpsgi => $tpsgi, name => 'ssh' } );
  324. is( $res->[0], 303, 'forgetting redirects too' );
  325. is( Trog::Vault::get( 'eve', 'ssh' ), undef, 'and it is gone' );
  326. # One user cannot reach into another's, whatever they post.
  327. Trog::Routes::HTML::secrets_forget( { user => 'eve', tpsgi => $tpsgi, name => 'keepass' } );
  328. is( Trog::Vault::get( 'bob', 'keepass' ), 'correct horse battery staple', "bob's is untouched by eve asking to forget it" );
  329. };
  330. # The handle raises on every error, so these two are the writes whose failure
  331. # must still come back as an answer rather than as a die.
  332. subtest 'a write the database refuses is an answer, not a crash' => sub {
  333. my ( $ok, $why ) = Trog::Vault::set( 'nobody', 'thing', 'a secret for a user who does not exist' );
  334. is( $ok, 0, 'a secret for a user the foreign key does not know is refused' );
  335. is( $why, 'could not store it', 'with the message the page shows' );
  336. my $dbh = Trog::SQLite::dbh( 'schema/auth.schema', 'config/auth.db' );
  337. $dbh->do("DELETE FROM totp_spent WHERE username='bob'");
  338. $dbh->do("CREATE TEMP TRIGGER refuse_spend BEFORE INSERT ON totp_spent BEGIN SELECT RAISE(ABORT, 'refused for the test'); END");
  339. my ( undef, undef, undef, undef, $totp ) = Trog::Auth::totp( 'bob', 'vault.example.com' );
  340. ( $ok, $why ) = Trog::Auth::spend_totp( 'bob', $totp->expected_totp_code( time() ) );
  341. $dbh->do("DROP TRIGGER temp.refuse_spend");
  342. is( $ok, 0, 'a right code that cannot be recorded as spent is refused' );
  343. like( $why, qr/could not record the code/, 'saying why' );
  344. };
  345. done_testing();