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 (58343B)


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