pashage

Yet Another Opinionated Re-engineering of the Unix Password Store
git clone https://git.instinctive.eu/pashage.git
Log | Files | Refs | README | LICENSE

pashage_extra_spec.sh (56365B)


      1 # pashage - age-backed POSIX password manager
      2 # Copyright (C) 2024  Natasha Kerensikova
      3 #
      4 # This program is free software; you can redistribute it and/or
      5 # modify it under the terms of the GNU General Public License
      6 # as published by the Free Software Foundation; either version 2
      7 # of the License, or (at your option) any later version.
      8 #
      9 # This program is distributed in the hope that it will be useful,
     10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
     11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     12 # GNU General Public License for more details.
     13 #
     14 # You should have received a copy of the GNU General Public License
     15 # along with this program; if not, write to the Free Software
     16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
     17 
     18 # This test file exercises the whole software through command functions,
     19 # using minimal mocking, limited to cryptography (to make it more robust
     20 # and easier to debug), like the `pass_usage.sh` suite.
     21 # It complements `pass_usage.sh` with pashage-specific behavior, aiming for
     22 # maximal coverage of normal code paths.
     23 
     24 Describe 'Integrated Command Functions'
     25   Include src/pashage.sh
     26   if [ "${SHELLSPEC_SHELL_TYPE}" = sh ]; then
     27     Set 'errexit:on' 'nounset:on'
     28   else
     29     Set 'errexit:on' 'nounset:on' 'pipefail:on'
     30   fi
     31 
     32   GITLOG="${SHELLSPEC_WORKDIR}/git-log.txt"
     33 
     34   AGE='mock-age'
     35   IDENTITIES_FILE="${SHELLSPEC_WORKDIR}/age-identities"
     36   PREFIX="${SHELLSPEC_WORKDIR}/store"
     37 
     38   CHARACTER_SET='[:punct:][:alnum:]'
     39   CHARACTER_SET_NO_SYMBOLS='[:alnum:]'
     40   CLIP_TIME=45
     41   GENERATED_LENGTH=25
     42   X_SELECTION=clipboard
     43 
     44   TREE__='   '
     45   TREE_I='|  '
     46   TREE_T='|- '
     47   TREE_L='`- '
     48 
     49   BOLD_TEXT='(B)'
     50   NORMAL_TEXT='(N)'
     51   RED_TEXT='(R)'
     52   BLUE_TEXT='(B)'
     53   UNDERLINE_TEXT='(U)'
     54   NO_UNDERLINE_TEXT='(!U)'
     55 
     56   git_log() {
     57     @git -C "${PREFIX}" status --porcelain >&2
     58     @git -C "${PREFIX}" log --format='%s' --stat >|"${GITLOG}"
     59   }
     60 
     61   setup_log() { %text
     62     #|Initial setup
     63     #|
     64     #| extra/subdir/file.age  | 2 ++
     65     #| fluff/.age-recipients  | 2 ++
     66     #| fluff/one.age          | 3 +++
     67     #| fluff/three.age        | 5 +++++
     68     #| fluff/two.age          | 4 ++++
     69     #| old.gpg                | 3 +++
     70     #| shared/.age-recipients | 2 ++
     71     #| stale.age              | 3 +++
     72     #| subdir/file.age        | 2 ++
     73     #| y.txt                  | 3 +++
     74     #| 10 files changed, 29 insertions(+)
     75   }
     76 
     77   setup_log_bin() { %text
     78     #|Initial setup
     79     #|
     80     #| extra/subdir/file.age  | Bin 0 -> 33 bytes
     81     #| fluff/.age-recipients  |   2 ++
     82     #| fluff/one.age          | Bin 0 -> 55 bytes
     83     #| fluff/three.age        | Bin 0 -> 110 bytes
     84     #| fluff/two.age          | Bin 0 -> 90 bytes
     85     #| old.gpg                |   3 +++
     86     #| shared/.age-recipients |   2 ++
     87     #| stale.age              | Bin 0 -> 55 bytes
     88     #| subdir/file.age        | Bin 0 -> 33 bytes
     89     #| y.txt                  |   3 +++
     90     #| 10 files changed, 10 insertions(+)
     91   }
     92 
     93   expected_log() { setup_log; } # Default log to override as needed
     94 
     95   check_git_log() {
     96     git_log && expected_log | diff -u "${GITLOG}" - >&2
     97   }
     98 
     99   setup_id() {
    100     @mkdir -p "${PREFIX}/$1"
    101     @cat >"${PREFIX}/$1/.age-recipients"
    102   }
    103 
    104   setup_secret() {
    105     [ "$1" = "${1%/*}" ] || @mkdir -p "${PREFIX}/${1%/*}"
    106     @sed 's/^/age/' >"${PREFIX}/$1.age"
    107   }
    108 
    109   setup() {
    110     @git init -q -b main "${PREFIX}"
    111     @git -C "${PREFIX}" config --local user.name 'Test User'
    112     @git -C "${PREFIX}" config --local user.email 'test@example.com'
    113     %putsn 'myself' >"${IDENTITIES_FILE}"
    114     %text | setup_secret 'subdir/file'
    115     #|Recipient:myself
    116     #|:p4ssw0rd
    117     %text | setup_secret 'extra/subdir/file'
    118     #|Recipient:myself
    119     #|:Pa55worD
    120     %text | setup_id 'shared'
    121     #|myself
    122     #|friend
    123     %text | setup_id 'fluff'
    124     #|master
    125     #|myself
    126     %text | setup_secret 'fluff/one'
    127     #|Recipient:master
    128     #|Recipient:myself
    129     #|:1-password
    130     %text | setup_secret 'fluff/two'
    131     #|Recipient:master
    132     #|Recipient:myself
    133     #|:2-password
    134     #|:URL: https://example.com/login
    135     %text | setup_secret 'fluff/three'
    136     #|Recipient:master
    137     #|Recipient:myself
    138     #|:3-password
    139     #|:Username: 3Jane
    140     #|:URL: https://example.com/login
    141     %text | setup_secret 'stale'
    142     #|Recipient:master
    143     #|Recipient:myself
    144     #|:0-password
    145     %text >"${PREFIX}/old.gpg"
    146     #|gpgRecipient:myOldSelf
    147     #|gpg:very-old-password
    148     #|gpg:Username: previous-life
    149     %text >"${PREFIX}/y.txt"
    150     #|# Title
    151     #|Line of text
    152     #|End of note
    153     @git -C "${PREFIX}" add .
    154     @git -C "${PREFIX}" commit -m 'Initial setup' >/dev/null
    155 
    156     # Check setup_log consistency
    157     git_log
    158     setup_log | @diff -u - "${GITLOG}"
    159   }
    160 
    161   cleanup() {
    162     @rm -rf "${PREFIX}"
    163     @rm -f "${IDENTITIES_FILE}"
    164     @rm -rf "${SHELLSPEC_WORKDIR}/clone"
    165     @rm -rf "${SHELLSPEC_WORKDIR}/secure"
    166   }
    167 
    168   BeforeEach setup
    169   AfterEach cleanup
    170 
    171   basename() { @basename "$@"; }
    172   cat()      { @cat      "$@"; }
    173   cp()       { @cp       "$@"; }
    174   dd()       { @dd       "$@"; }
    175   diff()     { @diff     "$@"; }
    176   dirname()  { @dirname  "$@"; }
    177   git()      { @git      "$@"; }
    178   mkdir()    { @mkdir    "$@"; }
    179   mktemp()   { @mktemp   "$@"; }
    180   mv()       { @mv       "$@"; }
    181   rm()       { @rm       "$@"; }
    182   tr()       { @tr       "$@"; }
    183 
    184   platform_tmpdir() {
    185     SECURE_TMPDIR="${SHELLSPEC_WORKDIR}/secure"
    186     @mkdir -p "${SECURE_TMPDIR}"
    187   }
    188 
    189 # Describe 'cmd_copy' is not needed (covered by 'cmd_copy_move')
    190 
    191   Describe 'cmd_copy_move'
    192     It 'processes several files and directories into a directory'
    193       When call cmd_move extra stale subdir
    194       The status should be success
    195       The error should be blank
    196       The output should be blank
    197       expected_log() { %text
    198         #|Move stale.age to subdir/stale.age
    199         #|
    200         #| stale.age => subdir/stale.age | 0
    201         #| 1 file changed, 0 insertions(+), 0 deletions(-)
    202         #|Move extra/ to subdir/extra/
    203         #|
    204         #| {extra => subdir/extra}/subdir/file.age | 0
    205         #| 1 file changed, 0 insertions(+), 0 deletions(-)
    206         setup_log
    207       }
    208       The result of function check_git_log should be successful
    209     End
    210 
    211     It 'processes unencrypted files'
    212       When run cmd_move y.txt shared/yy
    213       The status should be success
    214       The error should be blank
    215       The output should be blank
    216       expected_log() { %text
    217         #|Move y.txt to shared/yy
    218         #|
    219         #| y.txt => shared/yy | 0
    220         #| 1 file changed, 0 insertions(+), 0 deletions(-)
    221         setup_log
    222       }
    223       The result of function check_git_log should be successful
    224     End
    225 
    226     It 'does not overwrite a file without confirmation'
    227       Data 'n'
    228       When call cmd_copy subdir/file stale
    229       The status should be success
    230       The error should be blank
    231       The output should equal 'stale.age already exists. Overwrite? [y/n]'
    232       The result of function check_git_log should be successful
    233     End
    234 
    235     It 'overwrites a file after confirmation'
    236       Data 'y'
    237       When call cmd_copy subdir/file stale
    238       The status should be success
    239       The error should be blank
    240       The output should equal 'stale.age already exists. Overwrite? [y/n]'
    241       expected_log() { %text
    242         #|Copy subdir/file.age to stale.age
    243         #|
    244         #| stale.age | 3 +--
    245         #| 1 file changed, 1 insertion(+), 2 deletions(-)
    246         setup_log
    247       }
    248       The result of function check_git_log should be successful
    249     End
    250 
    251     It 'does not re-encrypt by default when recipients do not change'
    252       When call cmd_move stale renamed
    253       The status should be success
    254       The error should be blank
    255       The output should be blank
    256       expected_log() { %text
    257         #|Move stale.age to renamed.age
    258         #|
    259         #| stale.age => renamed.age | 0
    260         #| 1 file changed, 0 insertions(+), 0 deletions(-)
    261         setup_log
    262       }
    263       The result of function check_git_log should be successful
    264     End
    265 
    266     It 're-encrypts by default when recipients change'
    267       When call cmd_move stale shared
    268       The status should be success
    269       The error should be blank
    270       The output should be blank
    271       expected_file() { %text
    272         #|ageRecipient:myself
    273         #|ageRecipient:friend
    274         #|age:0-password
    275       }
    276       The contents of file "${PREFIX}/shared/stale.age" should \
    277         equal "$(expected_file)"
    278       expected_log() { %text
    279         #|Move stale.age to shared/stale.age
    280         #|
    281         #| stale.age => shared/stale.age | 2 +-
    282         #| 1 file changed, 1 insertion(+), 1 deletion(-)
    283         setup_log
    284       }
    285       The result of function check_git_log should be successful
    286     End
    287 
    288     It 'always re-encrypts when forced'
    289       When call cmd_move --reencrypt stale renamed
    290       The status should be success
    291       The error should be blank
    292       The output should be blank
    293       expected_log() { %text
    294         #|Move stale.age to renamed.age
    295         #|
    296         #| stale.age => renamed.age | 1 -
    297         #| 1 file changed, 1 deletion(-)
    298         setup_log
    299       }
    300       The result of function check_git_log should be successful
    301     End
    302 
    303     It 'never re-encrypts when forced'
    304       When call cmd_move --keep stale shared
    305       The status should be success
    306       The error should be blank
    307       The output should be blank
    308       expected_log() { %text
    309         #|Move stale.age to shared/stale.age
    310         #|
    311         #| stale.age => shared/stale.age | 0
    312         #| 1 file changed, 0 insertions(+), 0 deletions(-)
    313         setup_log
    314       }
    315       The result of function check_git_log should be successful
    316     End
    317 
    318     It 'interactively re-encrypts when asked'
    319       Data
    320         #|n
    321         #|y
    322       End
    323       When call cmd_move --interactive stale extra/subdir/file shared
    324       The status should be success
    325       The error should be blank
    326       The output should equal 'Reencrypt stale into shared/stale? [y/n]Reencrypt extra/subdir/file into shared/file? [y/n]'
    327       expected_file() { %text
    328         #|ageRecipient:myself
    329         #|ageRecipient:friend
    330         #|age:Pa55worD
    331       }
    332       The contents of file "${PREFIX}/shared/file.age" should \
    333         equal "$(expected_file)"
    334       expected_log() { %text
    335         #|Move extra/subdir/file.age to shared/file.age
    336         #|
    337         #| {extra/subdir => shared}/file.age | 1 +
    338         #| 1 file changed, 1 insertion(+)
    339         #|Move stale.age to shared/stale.age
    340         #|
    341         #| stale.age => shared/stale.age | 0
    342         #| 1 file changed, 0 insertions(+), 0 deletions(-)
    343         setup_log
    344       }
    345       The result of function check_git_log should be successful
    346     End
    347 
    348     It 'aborts on decryption failure even without pipefail'
    349       if ! [ "${SHELLSPEC_SHELL_TYPE}" = sh ]; then
    350         Set 'pipefail:off'
    351       fi
    352       AGE=false
    353       When run cmd_move --reencrypt stale renamed
    354       The status should equal 1
    355       The error should equal \
    356         "Fatal(1): false -d -i ${IDENTITIES_FILE} -- ${PREFIX}/stale.age"
    357       The output should be blank
    358       The result of function check_git_log should be successful
    359     End
    360 
    361     It 'displays usage when called with incompatible reencryption arguments'
    362       PROGRAM=prg
    363       COMMAND=copy
    364       When run cmd_copy_move -eik stale shared/
    365       The status should equal 1
    366       The output should be blank
    367       expected_err() { %text
    368         #|Usage: prg copy [--reencrypt,-e | --interactive,-i | --keep,-k ]
    369         #|                [--force,-f] old-path new-path
    370       }
    371       The error should equal "$(expected_err)"
    372       The result of function check_git_log should be successful
    373     End
    374 
    375     It 'displays copy usage with `c*` commands'
    376       PROGRAM=prg
    377       COMMAND=curious
    378       When run cmd_copy_move single
    379       The status should equal 1
    380       The output should be blank
    381       expected_err() { %text
    382         #|Usage: prg copy [--reencrypt,-e | --interactive,-i | --keep,-k ]
    383         #|                [--force,-f] old-path new-path
    384       }
    385       The error should equal "$(expected_err)"
    386       The result of function check_git_log should be successful
    387     End
    388 
    389     It 'displays move usage with `m*` commands'
    390       PROGRAM=prg
    391       COMMAND=memory
    392       When run cmd_copy_move single
    393       The status should equal 1
    394       The output should be blank
    395       expected_err() { %text
    396         #|Usage: prg move [--reencrypt,-e | --interactive,-i | --keep,-k ]
    397         #|                [--force,-f] old-path new-path
    398       }
    399       The error should equal "$(expected_err)"
    400       The result of function check_git_log should be successful
    401     End
    402 
    403     It 'displays both usages when in doubt'
    404       PROGRAM=prg
    405       COMMAND=bad
    406       When run cmd_copy_move single
    407       The status should equal 1
    408       The output should be blank
    409       expected_err() { %text
    410         #|Usage: prg copy [--reencrypt,-e | --interactive,-i | --keep,-k ]
    411         #|                [--force,-f] old-path new-path
    412         #|       prg move [--reencrypt,-e | --interactive,-i | --keep,-k ]
    413         #|                [--force,-f] old-path new-path
    414       }
    415       The error should equal "$(expected_err)"
    416       The result of function check_git_log should be successful
    417     End
    418   End
    419 
    420   Describe 'cmd_delete'
    421     It 'deletes multiple files at once, prompting before each one'
    422       Data
    423         #|y
    424         #|n
    425         #|y
    426       End
    427       When call cmd_delete stale subdir/file fluff/two
    428       The status should be success
    429       The output should equal 'Are you sure you would like to delete stale? [y/n]Are you sure you would like to delete subdir/file? [y/n]Are you sure you would like to delete fluff/two? [y/n]'
    430       The error should be blank
    431       The file "${PREFIX}/fluff/two.age" should not be exist
    432       The file "${PREFIX}/stale.age" should not be exist
    433       The file "${PREFIX}/subdir/file.age" should be exist
    434       expected_log() { %text
    435         #|Remove fluff/two from store.
    436         #|
    437         #| fluff/two.age | 4 ----
    438         #| 1 file changed, 4 deletions(-)
    439         #|Remove stale from store.
    440         #|
    441         #| stale.age | 3 ---
    442         #| 1 file changed, 3 deletions(-)
    443         setup_log
    444       }
    445       The result of function check_git_log should be successful
    446     End
    447   End
    448 
    449   Describe 'cmd_edit'
    450     It 'uses EDITOR in a dumb terminal'
    451       unset EDIT_CMD
    452       EDITOR=false
    453       TERM=dumb
    454       VISUAL=true
    455       When run cmd_edit stale
    456       The status should equal 1
    457       The output should be blank
    458       The error should equal 'Editor "false" exited with code 1'
    459       expected_file() { %text:expand
    460         #|ageRecipient:master
    461         #|ageRecipient:myself
    462         #|age:0-password
    463       }
    464       The contents of file "${PREFIX}/stale.age" should \
    465         equal "$(expected_file)"
    466       The result of function check_git_log should be successful
    467     End
    468 
    469     It 'uses EDITOR when VISUAL is not set'
    470       unset EDIT_CMD
    471       EDITOR=false
    472       TERM=not-dumb
    473       unset VISUAL
    474       When run cmd_edit stale
    475       The status should equal 1
    476       The output should be blank
    477       The error should equal 'Editor "false" exited with code 1'
    478       expected_file() { %text:expand
    479         #|ageRecipient:master
    480         #|ageRecipient:myself
    481         #|age:0-password
    482       }
    483       The contents of file "${PREFIX}/stale.age" should \
    484         equal "$(expected_file)"
    485       The result of function check_git_log should be successful
    486     End
    487 
    488     It 'uses VISUAL in a non-dumb terminal'
    489       unset EDIT_CMD
    490       EDITOR=true
    491       TERM=not-dumb
    492       VISUAL=false
    493       When run cmd_edit stale
    494       The status should equal 1
    495       The output should be blank
    496       The error should equal 'Editor "false" exited with code 1'
    497       expected_file() { %text:expand
    498         #|ageRecipient:master
    499         #|ageRecipient:myself
    500         #|age:0-password
    501       }
    502       The contents of file "${PREFIX}/stale.age" should \
    503         equal "$(expected_file)"
    504       The result of function check_git_log should be successful
    505     End
    506 
    507     It 'falls back on vi without EDITOR nor visual'
    508       unset EDIT_CMD
    509       unset EDITOR
    510       unset VISUAL
    511       When run cmd_edit subdir/new
    512       The status should equal 127
    513       The output should be blank
    514       The line 1 of error should include 'not found'
    515       The line 2 of error should equal 'Editor "vi" exited with code 127'
    516       The file "${PREFIX}/subdir/new.age" should not be exist
    517       The file "${PREFIX}/subdir/new.gpg" should not be exist
    518       The result of function check_git_log should be successful
    519     End
    520 
    521     It 'reports unchanged file'
    522       EDIT_CMD=true
    523       When call cmd_edit stale
    524       The status should be success
    525       The output should equal 'Password for stale unchanged.'
    526       The error should be blank
    527       expected_file() { %text:expand
    528         #|ageRecipient:master
    529         #|ageRecipient:myself
    530         #|age:0-password
    531       }
    532       The contents of file "${PREFIX}/stale.age" should \
    533         equal "$(expected_file)"
    534       The result of function check_git_log should be successful
    535     End
    536 
    537     It 'allows lack of file creation without error'
    538       EDIT_CMD=true
    539       When run cmd_edit subdir/new
    540       The status should be success
    541       The output should equal 'New password for subdir/new not saved.'
    542       The error should be blank
    543       The file "${PREFIX}/subdir/new.age" should not be exist
    544       The file "${PREFIX}/subdir/new.gpg" should not be exist
    545       The result of function check_git_log should be successful
    546     End
    547 
    548     It 'reports editor failure'
    549       ret42() { return 42; }
    550       EDIT_CMD=ret42
    551       When run cmd_edit subdir/new
    552       The status should equal 42
    553       The output should be blank
    554       The error should equal 'Editor "ret42" exited with code 42'
    555       The file "${PREFIX}/subdir/new.age" should not be exist
    556       The file "${PREFIX}/subdir/new.gpg" should not be exist
    557       The result of function check_git_log should be successful
    558     End
    559 
    560     It 'aborts on decryption failure even without pipefail'
    561       if ! [ "${SHELLSPEC_SHELL_TYPE}" = sh ]; then
    562         Set 'pipefail:off'
    563       fi
    564       AGE=false
    565       tail() { @tail "$@"; }
    566       When run cmd_edit stale
    567       The status should equal 1
    568       The error should equal \
    569         "Fatal(1): false -d -i ${IDENTITIES_FILE} -- ${PREFIX}/stale.age"
    570       The output should be blank
    571       The result of function check_git_log should be successful
    572     End
    573   End
    574 
    575   Describe 'cmd_find'
    576     grep() { @grep "$@"; }
    577 
    578     It 'interprets the pattern as a regular expression'
    579       expected_output() { %text
    580         #|Search pattern: ^o
    581         #||- (B)fluff(N)
    582         #||  `- one
    583         #|`- (R)old(N)
    584       }
    585       When call cmd_find '^o'
    586       The status should be success
    587       The output should equal "$(expected_output)"
    588       The error should be blank
    589       The result of function check_git_log should be successful
    590     End
    591 
    592     It 'forwards flags to grep'
    593       expected_output() { %text
    594         #|Search pattern: -E -i F|I
    595         #||- (B)extra(N)
    596         #||  `- (B)subdir(N)
    597         #||     `- file
    598         #|`- (B)subdir(N)
    599         #|   `- file
    600       }
    601       When call cmd_find -E -i 'F|I'
    602       The status should be success
    603       The output should equal "$(expected_output)"
    604       The error should be blank
    605       The result of function check_git_log should be successful
    606     End
    607   End
    608 
    609   Describe 'cmd_generate'
    610     It 'uses the character set given explicitly instead of environment'
    611       CHARACTER_SET='[0-9]'
    612       CHARACTER_SET_NO_SYMBOLS='[0-9]'
    613       When call cmd_generate new 5 '[:upper:]'
    614       The status should be success
    615       The error should be blank
    616       The lines of output should equal 2
    617       The line 1 of output should \
    618         equal '(B)The generated password for (U)new(!U) is:(N)'
    619       The line 2 of output should match pattern '[A-Z][A-Z][A-Z][A-Z][A-Z]'
    620       expected_log() { %text
    621         #|Add generated password for new.
    622         #|
    623         #| new.age | 2 ++
    624         #| 1 file changed, 2 insertions(+)
    625         setup_log
    626       }
    627       The result of function check_git_log should be successful
    628     End
    629 
    630     It 'overwrites after asking for confirmation'
    631       Data 'y'
    632       When call cmd_generate subdir/file 10
    633       The status should be success
    634       The output should start with 'An entry already exists for subdir/file. Overwrite it? [y/n](B)The generated password for (U)subdir/file(!U) is:(N)'
    635       The error should be blank
    636       expected_log() { %text
    637         #|Add generated password for subdir/file.
    638         #|
    639         #| subdir/file.age | 2 +-
    640         #| 1 file changed, 1 insertion(+), 1 deletion(-)
    641         setup_log
    642       }
    643       The result of function check_git_log should be successful
    644     End
    645 
    646     It 'does nothing without confirmation'
    647       Data 'n'
    648       When call cmd_generate subdir/file 10
    649       The status should be success
    650       The output should equal \
    651         'An entry already exists for subdir/file. Overwrite it? [y/n]'
    652       The error should be blank
    653       The result of function check_git_log should be successful
    654     End
    655 
    656     It 'cannot overwrite a directory'
    657       run_test() {
    658         mkdir -p "${PREFIX}/new-secret.age" && \
    659         cmd_generate -f new-secret 10
    660       }
    661       When run run_test
    662       The status should equal 1
    663       The output should be blank
    664       The error should equal 'Cannot replace directory new-secret.age'
    665       The result of function check_git_log should be successful
    666     End
    667 
    668     It 'aborts on decryption failure even without pipefail'
    669       if ! [ "${SHELLSPEC_SHELL_TYPE}" = sh ]; then
    670         Set 'pipefail:off'
    671       fi
    672       AGE=false
    673       tail() { @tail "$@"; }
    674       When run cmd_generate --in-place stale
    675       The status should equal 1
    676       The error should equal \
    677         "Fatal(1): false -d -i ${IDENTITIES_FILE} -- ${PREFIX}/stale.age"
    678       The output should equal 'Decrypting previous secret for stale'
    679       The result of function check_git_log should be successful
    680     End
    681 
    682     It 'saves after showing and getting confirmation'
    683       Data 'y'
    684       When call cmd_generate --try new
    685       The status should be success
    686       The error should be blank
    687       The file "${PREFIX}/new.age" should be exist
    688       expected_out() {
    689         %putsn '(B)The generated password for (U)new(!U) is:(N)'
    690         @sed '$s/^age://p;d' "${PREFIX}/new.age"
    691         %putsn 'Save generated password for new? [y/n]'
    692       }
    693       The output should equal "$(expected_out)"
    694       expected_log() { %text
    695         #|Add generated password for new.
    696         #|
    697         #| new.age | 2 ++
    698         #| 1 file changed, 2 insertions(+)
    699         setup_log
    700       }
    701       The result of function check_git_log should be successful
    702     End
    703 
    704     It 'does not save after showing and getting cancellation'
    705       Data 'n'
    706       When call cmd_generate --try new 5 '[:lower:]'
    707       The status should be success
    708       The error should be blank
    709       The lines of output should equal 3
    710       The line 1 of output should \
    711         equal '(B)The generated password for (U)new(!U) is:(N)'
    712       The line 2 of output should match pattern '[a-z][a-z][a-z][a-z][a-z]'
    713       The line 3 of output should \
    714         equal 'Save generated password for new? [y/n]'
    715       The result of function check_git_log should be successful
    716     End
    717 
    718     It 'accepts an extra line after the generated secret'
    719       Data 'extra comment line'
    720       When call cmd_generate --multiline new 15
    721       The status should be success
    722       The error should be blank
    723       The lines of output should equal 3
    724       The line 1 of output should \
    725         equal 'Enter extra secrets then Ctrl+D when finished:'
    726       The line 2 of output should \
    727         equal '(B)The generated password for (U)new(!U) is:(N)'
    728       The line 3 of output should match pattern '???????????????'
    729       The lines of contents of file "${PREFIX}/new.age" should equal 3
    730       The line 3 of contents of file "${PREFIX}/new.age" should \
    731         equal 'age:extra comment line'
    732       expected_log() { %text
    733         #|Add generated password for new.
    734         #|
    735         #| new.age | 3 +++
    736         #| 1 file changed, 3 insertions(+)
    737         setup_log
    738       }
    739       The result of function check_git_log should be successful
    740     End
    741 
    742     It 'accepts extra lines after the generated secret when overwriting'
    743       Data
    744         #|Extra: line
    745         #|Extra: end of input
    746       End
    747       When call cmd_generate --multiline --force fluff/three 5
    748       The status should be success
    749       The error should be blank
    750       The lines of output should equal 3
    751       The line 1 of output should \
    752         equal 'Enter extra secrets then Ctrl+D when finished:'
    753       The line 2 of output should \
    754         equal '(B)The generated password for (U)fluff/three(!U) is:(N)'
    755       The line 3 of output should match pattern '?????'
    756       The lines of contents of file "${PREFIX}/fluff/three.age" should equal 5
    757       The line 4 of contents of file "${PREFIX}/fluff/three.age" should \
    758         equal 'age:Extra: line'
    759       The line 5 of contents of file "${PREFIX}/fluff/three.age" should \
    760         equal 'age:Extra: end of input'
    761       expected_log() { %text
    762         #|Add generated password for fluff/three.
    763         #|
    764         #| fluff/three.age | 6 +++---
    765         #| 1 file changed, 3 insertions(+), 3 deletions(-)
    766         setup_log
    767       }
    768       The result of function check_git_log should be successful
    769     End
    770 
    771     It 'accepts extra lines after the generated secret after in-place data'
    772       Data
    773         #|Extra: line
    774         #|Extra: end of input
    775       End
    776       When call cmd_generate --multiline --in-place fluff/three 5
    777       The status should be success
    778       The error should be blank
    779       The lines of output should equal 4
    780       The line 1 of output should \
    781         equal 'Decrypting previous secret for fluff/three'
    782       The line 2 of output should \
    783         equal 'Enter extra secrets then Ctrl+D when finished:'
    784       The line 3 of output should \
    785         equal '(B)The generated password for (U)fluff/three(!U) is:(N)'
    786       The line 4 of output should match pattern '?????'
    787       The lines of contents of file "${PREFIX}/fluff/three.age" should equal 7
    788       The line 4 of contents of file "${PREFIX}/fluff/three.age" should \
    789         equal 'age:Username: 3Jane'
    790       The line 5 of contents of file "${PREFIX}/fluff/three.age" should \
    791         equal 'age:URL: https://example.com/login'
    792       The line 6 of contents of file "${PREFIX}/fluff/three.age" should \
    793         equal 'age:Extra: line'
    794       The line 7 of contents of file "${PREFIX}/fluff/three.age" should \
    795         equal 'age:Extra: end of input'
    796       expected_log() { %text
    797         #|Replace generated password for fluff/three.
    798         #|
    799         #| fluff/three.age | 4 +++-
    800         #| 1 file changed, 3 insertions(+), 1 deletion(-)
    801         setup_log
    802       }
    803       The result of function check_git_log should be successful
    804     End
    805   End
    806 
    807   Describe 'cmd_git'
    808     It 'initializes a clone like a new repository'
    809       SOURCE="${PREFIX}"
    810       PREFIX="${SHELLSPEC_WORKDIR}/clone"
    811       expected_err() { %text:expand
    812         #|Cloning into '${PREFIX}'...
    813         #|done.
    814       }
    815       When call cmd_git clone "${SOURCE}"
    816       The status should be success
    817       The output should be blank
    818       The error should equal "$(expected_err)"
    819       The file "${PREFIX}/.gitattributes" should be exist
    820       The contents of file "${PREFIX}/.gitattributes" should equal \
    821         '*.age diff=age'
    822       expected_log() { %text
    823         #|Configure git repository for age file diff.
    824         #|
    825         #| .gitattributes | 1 +
    826         #| 1 file changed, 1 insertion(+)
    827         setup_log_bin
    828       }
    829       The result of function check_git_log should be successful
    830       PREFIX="${SOURCE}"
    831     End
    832   End
    833 
    834   Describe 'cmd_grep'
    835     It 'aborts on decryption failure even without pipefail'
    836       if ! [ "${SHELLSPEC_SHELL_TYPE}" = sh ]; then
    837         Set 'pipefail:off'
    838       fi
    839       grep() { @grep "$@"; }
    840       AGE=false
    841       When run cmd_grep foo
    842       The status should equal 1
    843       The error should equal \
    844         "Fatal(1): false -d -i ${IDENTITIES_FILE} -- file.age"
    845       The output should be blank
    846       The result of function check_git_log should be successful
    847     End
    848   End
    849 
    850   Describe 'cmd_gitconfig'
    851     grep() { @grep "$@"; }
    852 
    853     It 'creates a new .gitattributes and configures diff'
    854       When call cmd_gitconfig
    855       The status should be success
    856       The output should be blank
    857       The error should be blank
    858       The file "${PREFIX}/.gitattributes" should be exist
    859       The contents of file "${PREFIX}/.gitattributes" should equal \
    860         '*.age diff=age'
    861       expected_log() { %text
    862         #|Configure git repository for age file diff.
    863         #|
    864         #| .gitattributes | 1 +
    865         #| 1 file changed, 1 insertion(+)
    866         setup_log_bin
    867       }
    868       The result of function check_git_log should be successful
    869     End
    870 
    871     It 'expands an existing .gitattributes'
    872       run_test() {
    873         %putsn '# Existing but empty' >"${PREFIX}/.gitattributes"
    874         @git -C "${PREFIX}" add .gitattributes >/dev/null
    875         @git -C "${PREFIX}" commit -m 'Test case setup' >/dev/null
    876         cmd_gitconfig
    877       }
    878       When call run_test
    879       The status should be success
    880       The output should be blank
    881       The error should be blank
    882       expected_file() { %text
    883         #|# Existing but empty
    884         #|*.age diff=age
    885       }
    886       The file "${PREFIX}/.gitattributes" should be exist
    887       The contents of file "${PREFIX}/.gitattributes" should \
    888         equal "$(expected_file)"
    889       expected_log() { %text
    890         #|Configure git repository for age file diff.
    891         #|
    892         #| .gitattributes | 1 +
    893         #| 1 file changed, 1 insertion(+)
    894         #|Test case setup
    895         #|
    896         #| .gitattributes | 1 +
    897         #| 1 file changed, 1 insertion(+)
    898         setup_log_bin
    899       }
    900       The result of function check_git_log should be successful
    901     End
    902 
    903     It 'is idempotent'
    904       run_test() {
    905         cmd_gitconfig && cmd_gitconfig
    906       }
    907       When call run_test
    908       The status should be success
    909       The output should be blank
    910       The error should be blank
    911       The file "${PREFIX}/.gitattributes" should be exist
    912       The contents of file "${PREFIX}/.gitattributes" should equal \
    913         '*.age diff=age'
    914       expected_log() { %text
    915         #|Configure git repository for age file diff.
    916         #|
    917         #| .gitattributes | 1 +
    918         #| 1 file changed, 1 insertion(+)
    919         setup_log_bin
    920       }
    921       The result of function check_git_log should be successful
    922     End
    923   End
    924 
    925   Describe 'cmd_help'
    926     It 'displays a help text with pashage-specific supported commands'
    927       PROGRAM=prg
    928       When call cmd_help
    929       The status should be success
    930       The output should include ' prg copy '
    931       The output should include ' prg delete '
    932       The output should include ' prg gitconfig'
    933       The output should include ' prg move '
    934       The output should include ' prg random '
    935       The output should include ' prg reencrypt '
    936     End
    937   End
    938 
    939   Describe 'cmd_init'
    940     It 're-encrypts the whole store using a recipient ids named like a flag'
    941       When call cmd_init -- -p 'new-id'
    942       The status should be success
    943       The output should equal 'Password store recipients set at store root'
    944       The error should be blank
    945       expected_file() { %text
    946         #|-p
    947         #|new-id
    948       }
    949       The contents of file "${PREFIX}/.age-recipients" should \
    950         equal "$(expected_file)"
    951       expected_log() { %text
    952         #|Set age recipients at store root
    953         #|
    954         #| .age-recipients       | 2 ++
    955         #| extra/subdir/file.age | 3 ++-
    956         #| stale.age             | 4 ++--
    957         #| subdir/file.age       | 3 ++-
    958         #| 4 files changed, 8 insertions(+), 4 deletions(-)
    959         setup_log
    960       }
    961       The result of function check_git_log should be successful
    962     End
    963 
    964     It 'does not re-encrypt with `keep` flag'
    965       When call cmd_init -k 'new-id'
    966       The status should be success
    967       The output should equal 'Password store recipients set at store root'
    968       The error should be blank
    969       The contents of file "${PREFIX}/.age-recipients" should equal 'new-id'
    970       expected_log() { %text
    971         #|Set age recipients at store root
    972         #|
    973         #| .age-recipients | 1 +
    974         #| 1 file changed, 1 insertion(+)
    975         setup_log
    976       }
    977       The result of function check_git_log should be successful
    978     End
    979 
    980     It 'asks before re-encrypting each file with `interactive` flag'
    981       Data
    982         #|n
    983         #|y
    984         #|n
    985       End
    986       When call cmd_init -i 'new-id'
    987       The status should be success
    988       The output should equal 'Re-encrypt extra/subdir/file? [y/n]Re-encrypt stale? [y/n]Re-encrypt subdir/file? [y/n]Password store recipients set at store root'
    989       The error should be blank
    990       The contents of file "${PREFIX}/.age-recipients" should equal 'new-id'
    991       expected_log() { %text
    992         #|Set age recipients at store root
    993         #|
    994         #| .age-recipients | 1 +
    995         #| stale.age       | 3 +--
    996         #| 2 files changed, 2 insertions(+), 2 deletions(-)
    997         setup_log
    998       }
    999       The result of function check_git_log should be successful
   1000     End
   1001 
   1002     usage_text() { %text
   1003       #|Usage: prg init [--interactive,-i | --keep,-k ]
   1004       #|                [--path=subfolder,-p subfolder] age-recipient ...
   1005     }
   1006 
   1007     It 'displays usage when using incompatible options (`-i` then `-k`)'
   1008       PROGRAM=prg
   1009       When run cmd_init --interactive --keep 'new-id'
   1010       The status should equal 1
   1011       The output should be blank
   1012       The error should equal "$(usage_text)"
   1013       The result of function check_git_log should be successful
   1014     End
   1015 
   1016     It 'displays usage when using incompatible options (`-k` then `-i`)'
   1017       PROGRAM=prg
   1018       When run cmd_init -ki 'new-id'
   1019       The status should equal 1
   1020       The output should be blank
   1021       The error should equal "$(usage_text)"
   1022       The result of function check_git_log should be successful
   1023     End
   1024   End
   1025 
   1026   Describe 'cmd_insert'
   1027     It 'inserts an entry encrypted using an explicit recipient file'
   1028       PASHAGE_RECIPIENTS_FILE="${PREFIX}/fluff/.age-recipients"
   1029       PASSAGE_RECIPIENTS_FILE="${PREFIX}/shared/.age-recipients"
   1030       PASHAGE_RECIPIENTS='shadowed'
   1031       PASSAGE_RECIPIENTS='shadowed'
   1032       Data 'pass'
   1033       When call cmd_insert -e shared/new-file
   1034       The status should be success
   1035       The output should include 'shared/new-file'
   1036       expected_file() { %text:expand
   1037         #|ageRecipient:master
   1038         #|ageRecipient:myself
   1039         #|age:pass
   1040       }
   1041       The contents of file "${PREFIX}/shared/new-file.age" should \
   1042         equal "$(expected_file)"
   1043       expected_log() { %text
   1044         #|Add given password for shared/new-file to store.
   1045         #|
   1046         #| shared/new-file.age | 3 +++
   1047         #| 1 file changed, 3 insertions(+)
   1048         setup_log
   1049       }
   1050       The result of function check_git_log should be successful
   1051     End
   1052 
   1053     It 'inserts an entry encrypted using explicit recipients'
   1054       PASHAGE_RECIPIENTS='force-1 force-2'
   1055       PASSAGE_RECIPIENTS='shadowed'
   1056       Data 'pass'
   1057       When call cmd_insert -e shared/new-file
   1058       The status should be success
   1059       The output should include 'shared/new-file'
   1060       expected_file() { %text:expand
   1061         #|ageRecipient:force-1
   1062         #|ageRecipient:force-2
   1063         #|age:pass
   1064       }
   1065       The contents of file "${PREFIX}/shared/new-file.age" should \
   1066         equal "$(expected_file)"
   1067       expected_log() { %text
   1068         #|Add given password for shared/new-file to store.
   1069         #|
   1070         #| shared/new-file.age | 3 +++
   1071         #| 1 file changed, 3 insertions(+)
   1072         setup_log
   1073       }
   1074       The result of function check_git_log should be successful
   1075     End
   1076 
   1077     It 'inserts several new single-line entries'
   1078       stty() { false; }
   1079       Data
   1080         #|password-1
   1081         #|n
   1082         #|password-2
   1083         #|password-3
   1084       End
   1085       When call cmd_insert -e newdir/pass-1 subdir/file newdir/pass-2
   1086       The status should be success
   1087       The error should be blank
   1088       The output should equal 'Enter password for newdir/pass-1: An entry already exists for subdir/file. Overwrite it? [y/n]Enter password for newdir/pass-2: '
   1089       The contents of file "${PREFIX}/newdir/pass-1.age" \
   1090         should include "age:password-1"
   1091       The contents of file "${PREFIX}/newdir/pass-2.age" \
   1092         should include "age:password-2"
   1093       expected_log() { %text
   1094         #|Add given password for newdir/pass-2 to store.
   1095         #|
   1096         #| newdir/pass-2.age | 2 ++
   1097         #| 1 file changed, 2 insertions(+)
   1098         #|Add given password for newdir/pass-1 to store.
   1099         #|
   1100         #| newdir/pass-1.age | 2 ++
   1101         #| 1 file changed, 2 insertions(+)
   1102         setup_log
   1103       }
   1104       The result of function check_git_log should be successful
   1105     End
   1106 
   1107     It 'inserts several new multi-line entries'
   1108       stty() { false; }
   1109       Data
   1110         #|password-1
   1111         #| extra spaced line
   1112         #|
   1113         #|y
   1114         #|password-2
   1115         #|	extra tabbed line
   1116         #|
   1117         #|password-3
   1118       End
   1119       When call cmd_insert -m newdir/pass-1 subdir/file newdir/pass-2
   1120       The status should be success
   1121       The error should be blank
   1122       expected_out() { %text
   1123         #|Enter contents of newdir/pass-1 and
   1124         #|press Ctrl+D or enter an empty line when finished:
   1125         #|An entry already exists for subdir/file. Overwrite it? [y/n]Enter contents of subdir/file and
   1126         #|press Ctrl+D or enter an empty line when finished:
   1127         #|Enter contents of newdir/pass-2 and
   1128         #|press Ctrl+D or enter an empty line when finished:
   1129       }
   1130       The output should equal "$(expected_out)"
   1131       expected_file_1() { %text
   1132         #|ageRecipient:myself
   1133         #|age:password-1
   1134         #|age: extra spaced line
   1135       }
   1136       expected_file_2() { %text
   1137         #|ageRecipient:myself
   1138         #|age:password-2
   1139         #|age:	extra tabbed line
   1140       }
   1141       expected_file_3() { %text
   1142         #|ageRecipient:myself
   1143         #|age:password-3
   1144       }
   1145       The contents of file "${PREFIX}/newdir/pass-1.age" \
   1146         should equal "$(expected_file_1)"
   1147       The contents of file "${PREFIX}/subdir/file.age" \
   1148         should equal "$(expected_file_2)"
   1149       The contents of file "${PREFIX}/newdir/pass-2.age" \
   1150         should equal "$(expected_file_3)"
   1151       expected_log() { %text
   1152         #|Add given password for newdir/pass-2 to store.
   1153         #|
   1154         #| newdir/pass-2.age | 2 ++
   1155         #| 1 file changed, 2 insertions(+)
   1156         #|Add given password for subdir/file to store.
   1157         #|
   1158         #| subdir/file.age | 3 ++-
   1159         #| 1 file changed, 2 insertions(+), 1 deletion(-)
   1160         #|Add given password for newdir/pass-1 to store.
   1161         #|
   1162         #| newdir/pass-1.age | 3 +++
   1163         #| 1 file changed, 3 insertions(+)
   1164         setup_log
   1165       }
   1166       The result of function check_git_log should be successful
   1167     End
   1168 
   1169     It 'inserts a new single-line entry on the second try'
   1170       stty() { :; }
   1171       Data
   1172         #|first try
   1173         #|First Try
   1174         #|pass-word
   1175         #|pass-word
   1176       End
   1177       When call cmd_insert newdir/newpass
   1178       The status should be success
   1179       The error should be blank
   1180       expected_out() { %text | @sed 's/\$$//'
   1181         #|Enter password for newdir/newpass:  $
   1182         #|Retype password for newdir/newpass: $
   1183         #|Passwords don't match$
   1184         #|Enter password for newdir/newpass:  $
   1185         #|Retype password for newdir/newpass: $
   1186       }
   1187       The output should equal "$(expected_out)"
   1188       The contents of file "${PREFIX}/newdir/newpass.age" \
   1189         should include "age:pass-word"
   1190       expected_log() { %text
   1191         #|Add given password for newdir/newpass to store.
   1192         #|
   1193         #| newdir/newpass.age | 2 ++
   1194         #| 1 file changed, 2 insertions(+)
   1195         setup_log
   1196       }
   1197       The result of function check_git_log should be successful
   1198     End
   1199 
   1200     It 'overwrites an entry after confirmation'
   1201       Data
   1202         #|y
   1203         #|pass-word
   1204       End
   1205       When call cmd_insert -e subdir/file
   1206       The status should be success
   1207       The error should be blank
   1208       The output should equal 'An entry already exists for subdir/file. Overwrite it? [y/n]Enter password for subdir/file: '
   1209       expected_file() { %text
   1210         #|ageRecipient:myself
   1211         #|age:pass-word
   1212       }
   1213       The contents of file "${PREFIX}/subdir/file.age" \
   1214         should equal "$(expected_file)"
   1215       expected_log() { %text
   1216         #|Add given password for subdir/file to store.
   1217         #|
   1218         #| subdir/file.age | 2 +-
   1219         #| 1 file changed, 1 insertion(+), 1 deletion(-)
   1220         setup_log
   1221       }
   1222       The result of function check_git_log should be successful
   1223     End
   1224 
   1225     It 'does not overwrite an entry without confirmation'
   1226       Data
   1227         #|n
   1228         #|pass-word
   1229       End
   1230       When call cmd_insert -e subdir/file
   1231       The status should be success
   1232       The error should be blank
   1233       The output should equal \
   1234         'An entry already exists for subdir/file. Overwrite it? [y/n]'
   1235       The result of function check_git_log should be successful
   1236     End
   1237   End
   1238 
   1239   Describe 'cmd_list_or_show'
   1240     It 'decrypts a GPG secret in the store using GPG'
   1241       GPG=mock-gpg
   1242       gpg() { false; }
   1243       gpg2() { false; }
   1244       When call cmd_list_or_show old
   1245       The status should be success
   1246       The error should be blank
   1247       expected_out() { %text
   1248         #|very-old-password
   1249         #|Username: previous-life
   1250       }
   1251       The output should equal "$(expected_out)"
   1252     End
   1253 
   1254     It 'decrypts a GPG secret in the store using gpg2'
   1255       unset GPG
   1256       gpg() { false; }
   1257       gpg2() {
   1258         [ $# -eq 9 ] && [ "$6" = '--batch' ] && [ "$7" = '--use-agent' ] \
   1259          && mock-gpg "$1" "$2" "$3" "$4" "$5" "$8" "$9"
   1260       }
   1261       When call cmd_list_or_show old
   1262       The status should be success
   1263       The error should be blank
   1264       expected_out() { %text
   1265         #|very-old-password
   1266         #|Username: previous-life
   1267       }
   1268       The output should equal "$(expected_out)"
   1269     End
   1270 
   1271     It 'decrypts a GPG secret in the store using gpg'
   1272       unset GPG
   1273       gpg() { mock-gpg "$@"; }
   1274       When call cmd_list_or_show old
   1275       The status should be success
   1276       The error should be blank
   1277       expected_out() { %text
   1278         #|very-old-password
   1279         #|Username: previous-life
   1280       }
   1281       The output should equal "$(expected_out)"
   1282     End
   1283 
   1284     It 'fails to decrypt a GPG secret without gpg'
   1285       unset GPG
   1286       When run cmd_list_or_show old
   1287       The status should equal 1
   1288       The error should equal 'GPG does not seem available'
   1289       The output should be blank
   1290     End
   1291 
   1292     It 'displays both list and show usage on parse error with ambiguity'
   1293       PROGRAM=prg
   1294       COMMAND=both
   1295       When run cmd_list_or_show -x
   1296       The status should equal 1
   1297       The output should be blank
   1298       expected_err() { %text
   1299         #|Usage: prg [list] [subfolder]
   1300         #|       prg [show] [--clip[=line-number],-c[line-number] |
   1301         #|                   --qrcode[=line-number],-q[line-number]] pass-name
   1302       }
   1303       The error should equal "$(expected_err)"
   1304     End
   1305 
   1306     It 'displays list usage on parse error with list command'
   1307       PROGRAM=prg
   1308       COMMAND=list
   1309       When run cmd_list_or_show -x
   1310       The status should equal 1
   1311       The output should be blank
   1312       expected_err() { %text
   1313         #|Usage: prg [list] [subfolder]
   1314       }
   1315       The error should equal "$(expected_err)"
   1316     End
   1317 
   1318     It 'displays show usage on parse error with show command'
   1319       PROGRAM=prg
   1320       COMMAND=show
   1321       When run cmd_list_or_show -x
   1322       The status should equal 1
   1323       The output should be blank
   1324       expected_err() { %text
   1325         #|Usage: prg [show] [--clip[=line-number],-c[line-number] |
   1326         #|                   --qrcode[=line-number],-q[line-number]] pass-name
   1327       }
   1328       The error should equal "$(expected_err)"
   1329     End
   1330 
   1331     It 'aborts on age decryption failure even without pipefail'
   1332       if ! [ "${SHELLSPEC_SHELL_TYPE}" = sh ]; then
   1333         Set 'pipefail:off'
   1334       fi
   1335       AGE=false
   1336       When run cmd_list_or_show stale
   1337       The status should equal 1
   1338       The error should equal \
   1339         "Fatal(1): false -d -i ${IDENTITIES_FILE} -- ${PREFIX}/stale.age"
   1340       The output should be blank
   1341       The result of function check_git_log should be successful
   1342     End
   1343 
   1344     It 'aborts on gpg decryption failure even without pipefail'
   1345       if ! [ "${SHELLSPEC_SHELL_TYPE}" = sh ]; then
   1346         Set 'pipefail:off'
   1347       fi
   1348       GPG=false
   1349       When run cmd_list_or_show old
   1350       The status should equal 1
   1351       The error should equal "Fatal(1): false -d --quiet --yes --compress-algo=none --no-encrypt-to -- ${PREFIX}/old.gpg"
   1352       The output should be blank
   1353       The result of function check_git_log should be successful
   1354     End
   1355   End
   1356 
   1357 # Describe 'cmd_move' is not needed (covered by 'cmd_copy_move')
   1358 
   1359   Describe 'cmd_random'
   1360     It 'generates random characters'
   1361       When call cmd_random 2 '[:digit:]'
   1362       The status should be success
   1363       The error should be blank
   1364       The output should match pattern '[0-9][0-9]'
   1365     End
   1366 
   1367     It 'defaults to using CHARACTER_SET'
   1368       PREV_CHARACTER_SET="${CHARACTER_SET}"
   1369       CHARACTER_SET='[:lower:]'
   1370       When call cmd_random 2
   1371       The status should be success
   1372       The error should be blank
   1373       The output should match pattern '[a-z][a-z]'
   1374       CHARACTER_SET="${PREV_CHARACTER_SET}"
   1375     End
   1376 
   1377     It 'defaults to using both GENERATED_LENGTH and CHARACTER_SET'
   1378       PREV_CHARACTER_SET="${CHARACTER_SET}"
   1379       PREV_GENERATED_LENGTH="${GENERATED_LENGTH}"
   1380       CHARACTER_SET='[:upper:]'
   1381       GENERATED_LENGTH=5
   1382       When call cmd_random
   1383       The status should be success
   1384       The error should be blank
   1385       The output should match pattern '[A-Z][A-Z][A-Z][A-Z][A-Z]'
   1386       CHARACTER_SET="${PREV_CHARACTER_SET}"
   1387       GENERATED_LENGTH="${PREV_GENERATED_LENGTH}"
   1388     End
   1389 
   1390     It 'displays usage when called with too many arguments'
   1391       PROGRAM=prg
   1392       When run cmd_random 2 '[:digit:]' extra
   1393       The status should equal 1
   1394       The output should be blank
   1395       The error should equal 'Usage: prg random [pass-length [character-set]]'
   1396     End
   1397   End
   1398 
   1399   Describe 'cmd_reencrypt'
   1400     usage_text() { %text
   1401       #|Usage: prg reencrypt [--interactive,-i] pass-name|subfolder ...
   1402     }
   1403 
   1404     It 'reencrypts a single file'
   1405       When call cmd_reencrypt stale
   1406       The status should be success
   1407       The error should be blank
   1408       The output should be blank
   1409       expected_file() { %text
   1410         #|ageRecipient:myself
   1411         #|age:0-password
   1412       }
   1413       The contents of file "${PREFIX}/stale.age" \
   1414         should equal "$(expected_file)"
   1415       expected_log() { %text
   1416         #|Re-encrypt stale
   1417         #|
   1418         #| stale.age | 1 -
   1419         #| 1 file changed, 1 deletion(-)
   1420         setup_log
   1421       }
   1422       The result of function check_git_log should be successful
   1423     End
   1424 
   1425     It 'reencrypts a single file interactively'
   1426       Data 'y'
   1427       When call cmd_reencrypt -i stale
   1428       The status should be success
   1429       The error should be blank
   1430       The output should equal 'Re-encrypt stale? [y/n]'
   1431       expected_file() { %text
   1432         #|ageRecipient:myself
   1433         #|age:0-password
   1434       }
   1435       The contents of file "${PREFIX}/stale.age" \
   1436         should equal "$(expected_file)"
   1437       expected_log() { %text
   1438         #|Re-encrypt stale
   1439         #|
   1440         #| stale.age | 1 -
   1441         #| 1 file changed, 1 deletion(-)
   1442         setup_log
   1443       }
   1444       The result of function check_git_log should be successful
   1445     End
   1446 
   1447     It 'does not reencrypt a single file when interactively refused'
   1448       Data 'n'
   1449       When call cmd_reencrypt --interactive stale
   1450       The status should be success
   1451       The error should be blank
   1452       The output should equal 'Re-encrypt stale? [y/n]'
   1453       expected_file() { %text
   1454         #|ageRecipient:master
   1455         #|ageRecipient:myself
   1456         #|age:0-password
   1457       }
   1458       The contents of file "${PREFIX}/stale.age" \
   1459         should equal "$(expected_file)"
   1460       The result of function check_git_log should be successful
   1461     End
   1462 
   1463     It 'reencrypts a directory recursively'
   1464       When call cmd_reencrypt /
   1465       The status should be success
   1466       The error should be blank
   1467       The output should be blank
   1468       expected_file() { %text
   1469         #|ageRecipient:myself
   1470         #|age:0-password
   1471       }
   1472       The contents of file "${PREFIX}/stale.age" \
   1473         should equal "$(expected_file)"
   1474       expected_log() { %text
   1475         #|Re-encrypt /
   1476         #|
   1477         #| stale.age | 1 -
   1478         #| 1 file changed, 1 deletion(-)
   1479         setup_log
   1480       }
   1481       The result of function check_git_log should be successful
   1482     End
   1483 
   1484     It 'reencrypts a directory recursively and interactively'
   1485       Data
   1486         #|n
   1487         #|y
   1488         #|n
   1489       End
   1490       When call cmd_reencrypt -i ''
   1491       The status should be success
   1492       The error should be blank
   1493       The output should equal 'Re-encrypt extra/subdir/file? [y/n]Re-encrypt stale? [y/n]Re-encrypt subdir/file? [y/n]'
   1494       expected_file() { %text
   1495         #|ageRecipient:myself
   1496         #|age:0-password
   1497       }
   1498       The contents of file "${PREFIX}/stale.age" \
   1499         should equal "$(expected_file)"
   1500       expected_log() { %text
   1501         #|Re-encrypt /
   1502         #|
   1503         #| stale.age | 1 -
   1504         #| 1 file changed, 1 deletion(-)
   1505         setup_log
   1506       }
   1507       The result of function check_git_log should be successful
   1508     End
   1509 
   1510     It 'fails to reencrypt a file named like a flag without escape'
   1511       PROGRAM=prg
   1512       When run cmd_reencrypt -g
   1513       The status should equal 1
   1514       The error should equal "$(usage_text)"
   1515       The output should be blank
   1516       The result of function check_git_log should be successful
   1517     End
   1518 
   1519     It 'fails to reencrypt a non-existent direcotry'
   1520       When run cmd_reencrypt -- -y/
   1521       The status should equal 1
   1522       The error should equal 'Error: -y/ is not in the password store.'
   1523       The output should be blank
   1524       The result of function check_git_log should be successful
   1525     End
   1526 
   1527     It 'fails to reencrypt a non-existent file'
   1528       When run cmd_reencrypt -- -y
   1529       The status should equal 1
   1530       The error should equal 'Error: -y is not in the password store.'
   1531       The output should be blank
   1532       The result of function check_git_log should be successful
   1533     End
   1534 
   1535     It 'rejects a path containing ..'
   1536       When run cmd_reencrypt fluff/../stale
   1537       The status should equal 1
   1538       The output should be blank
   1539       The error should include 'sneaky'
   1540       The result of function check_git_log should be successful
   1541     End
   1542 
   1543     It 'aborts on age decryption failure even without pipefail'
   1544       if ! [ "${SHELLSPEC_SHELL_TYPE}" = sh ]; then
   1545         Set 'pipefail:off'
   1546       fi
   1547       AGE=false
   1548       When run cmd_reencrypt stale
   1549       The status should equal 1
   1550       The error should equal \
   1551         "Fatal(1): false -d -i ${IDENTITIES_FILE} -- ${PREFIX}/stale.age"
   1552       The output should be blank
   1553       The result of function check_git_log should be successful
   1554     End
   1555   End
   1556 
   1557   Describe 'cmd_usage'
   1558     It 'defaults to four-space indentation'
   1559       PROGRAM=prg
   1560       When call cmd_usage
   1561       The status should be success
   1562       The error should be blank
   1563       The output should equal "$(cmd_usage '    ')"
   1564     End
   1565 
   1566     It 'fails with an unknown command'
   1567       PROGRAM=prg
   1568       When run cmd_usage 'Usage: ' bad version
   1569       The status should equal 1
   1570       The output should be blank
   1571       The error should equal 'cmd_usage: unknown command "bad"'
   1572     End
   1573   End
   1574 
   1575 # Describe 'cmd_version' is not needed (fully covered in pass_spec.sh)
   1576 
   1577   Describe 'refuse to operate on dirty checkout:'
   1578     make_dirty() {
   1579       %putsn 'untracked data' >"${PREFIX}/untracked.txt"
   1580     }
   1581     BeforeEach make_dirty
   1582 
   1583     git_log() {
   1584       @rm -f "${PREFIX}/untracked.txt"
   1585       @git -C "${PREFIX}" status --porcelain >&2
   1586       @git -C "${PREFIX}" log --format='%s' --stat >|"${GITLOG}"
   1587     }
   1588 
   1589     # 'copy' relies on 'copy/move'
   1590 
   1591     Example 'copy/move'
   1592       When run cmd_copy_move stale subdir/
   1593       The status should equal 1
   1594       The error should equal 'There are already pending changes.'
   1595       The output should be blank
   1596       The result of function check_git_log should be successful
   1597     End
   1598 
   1599     Example 'delete'
   1600       When run cmd_delete -f stale
   1601       The status should equal 1
   1602       The error should equal 'There are already pending changes.'
   1603       The output should equal 'Removing stale'
   1604       The result of function check_git_log should be successful
   1605     End
   1606 
   1607     Example 'edit'
   1608       VISUAL='false'
   1609       When run cmd_edit subdir/file
   1610       The status should equal 1
   1611       The error should equal 'There are already pending changes.'
   1612       The output should be blank
   1613       The result of function check_git_log should be successful
   1614     End
   1615 
   1616     # 'find' does not change the repository
   1617 
   1618     Example 'generate'
   1619       When run cmd_generate new-pass
   1620       The status should equal 1
   1621       The error should equal 'There are already pending changes.'
   1622       The output should be blank
   1623       The result of function check_git_log should be successful
   1624     End
   1625 
   1626     # 'git' does not change directly the repository
   1627 
   1628     Example 'gitconfig'
   1629       When run cmd_gitconfig
   1630       The status should equal 1
   1631       The error should equal 'There are already pending changes.'
   1632       The output should be blank
   1633       The result of function check_git_log should be successful
   1634     End
   1635 
   1636     # 'grep' does not change the repository
   1637     # 'help' does not change the repository
   1638 
   1639     Example 'init'
   1640       When run cmd_init -p subdir/ new-id
   1641       The status should equal 1
   1642       The error should equal 'There are already pending changes.'
   1643       The output should be blank
   1644       The result of function check_git_log should be successful
   1645     End
   1646 
   1647     Example 'init (deinit)'
   1648       When run cmd_init -p fluff/ ''
   1649       The status should equal 1
   1650       The error should equal 'There are already pending changes.'
   1651       The output should be blank
   1652       The result of function check_git_log should be successful
   1653     End
   1654 
   1655     Example 'insert'
   1656       When run cmd_insert -e fluff/four
   1657       The status should equal 1
   1658       The error should equal 'There are already pending changes.'
   1659       The output should be blank
   1660       The result of function check_git_log should be successful
   1661     End
   1662 
   1663     # 'list_or_show' does not change the repository
   1664     # 'move' relies on 'copy/move'
   1665     # 'random' does not change the repository
   1666 
   1667     Example 'reencrypt'
   1668       When run cmd_reencrypt stale
   1669       The status should equal 1
   1670       The error should equal 'There are already pending changes.'
   1671       The output should be blank
   1672       The result of function check_git_log should be successful
   1673     End
   1674 
   1675     # 'usage' does not change the repository
   1676     # 'version' does not change the repository
   1677   End
   1678 
   1679   Describe 'unreachable defensive code'
   1680     # This sections breaks the end-to-end scheme of this file
   1681     # to reach full coverage, by precisely identifying unreachable lines
   1682     # written for defensive programming against internal inconsistencies.
   1683 
   1684     It 'includes invalid values of DECISION in do_copy_move_file'
   1685       DECISION='invalid'
   1686       When run do_copy_move_file subdir/file.age extra/file.age
   1687       The status should equal 1
   1688       The output should be blank
   1689       The error should equal 'Unexpected DECISION value "invalid"'
   1690     End
   1691 
   1692     It 'includes overwriting a file using do_encrypt'
   1693       OVERWRITE=no
   1694       When run do_encrypt 'y.txt'
   1695       The status should equal 1
   1696       The output should be blank
   1697       The error should equal 'Refusing to overwite y.txt'
   1698     End
   1699 
   1700     It 'includes invalid values of SHOW in do_show'
   1701       SHOW='invalid'
   1702       When run do_show
   1703       The status should equal 1
   1704       The output should be blank
   1705       expected_err() { %text
   1706         #|Usage: prg [show] [--clip[=line-number],-c[line-number] |
   1707         #|                   --qrcode[=line-number],-q[line-number]] pass-name
   1708       }
   1709       The error should equal 'Unexpected SHOW value "invalid"'
   1710     End
   1711 
   1712     It 'includes interactive yesno'
   1713       # Technically not unreachable, but not worse than faking a terminal
   1714       # for each call of `yesno` when the whole test suite is outside
   1715       # of terminal anyway
   1716 
   1717       stty() { true; }
   1718       Data
   1719         #|x
   1720         #|Y
   1721       End
   1722       When call yesno 'Prompt?'
   1723       The status should be success
   1724       The error should be blank
   1725       The output should equal 'Prompt? [y/n]'
   1726       The variable ANSWER should equal 'y'
   1727     End
   1728   End
   1729 End