From 41a5f1e20dafe8d27cbea51e659cb707eadf2c4f Mon Sep 17 00:00:00 2001 From: Victoria Nadasdi Date: Wed, 27 Mar 2024 17:33:34 +0100 Subject: [PATCH] refactor: cleanup, mostly tests In preparation for more work, related to #112, #37 and #8. - Migrated all tests to use testify's `Suite` instead of direct `assert` calls. - Simplified how the credentials file location is determined, now it's defined in one simple function. Changes I may revert: I hate string concatenation, it's just ugly in my eyes, so I may just add that linter to disabled and revert to `Sprintf`. I know it's more efficient, but not on this scale. Closes #8 References: - https://github.com/yitsushi/totp-cli/issues/8 - https://github.com/yitsushi/totp-cli/issues/37 - https://github.com/yitsushi/totp-cli/issues/112 --- .github/workflows/quality-check.yaml | 41 +++++-- .golangci.yml | 2 +- Makefile | 17 +++ go.mod | 12 +- go.sum | 30 ++--- internal/cmd/error.go | 10 +- internal/cmd/rename.go | 6 +- internal/cmd/set_length.go | 4 +- internal/cmd/set_prefix.go | 4 +- internal/security/error.go | 4 +- internal/security/otp_test.go | 40 ++++--- internal/security/unsecure.go | 4 +- internal/storage/account.go | 8 +- internal/storage/error.go | 2 +- internal/storage/filebackend.go | 73 +++++++----- internal/storage/filebackend_test.go | 172 ++++++++++++++++++++------- internal/storage/namespace.go | 4 +- internal/storage/namespace_test.go | 44 ++++--- internal/terminal/terminal_test.go | 34 ++++-- 19 files changed, 327 insertions(+), 184 deletions(-) create mode 100644 Makefile diff --git a/.github/workflows/quality-check.yaml b/.github/workflows/quality-check.yaml index ac8367e..6bf886e 100644 --- a/.github/workflows/quality-check.yaml +++ b/.github/workflows/quality-check.yaml @@ -13,9 +13,9 @@ jobs: uses: actions/checkout@v4 - name: Set up Go 1.x - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: ^1.21 + go-version: ^1.22 - name: Get dependencies run: | @@ -41,21 +41,37 @@ jobs: uses: actions/checkout@v4 - name: Set up Go 1.x - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: ^1.21 + go-version: ^1.22 + + - name: go vet + run: go vet ./... + + lint: + name: go vet and lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go 1.x + uses: actions/setup-go@v5 + with: + go-version: ^1.22 - name: Get dependencies run: | go get -v -t -d ./... go install golang.org/x/lint/golint@latest + go install github.com/Antonboom/testifylint@latest - - name: go vet - run: go vet ./... - - - name: go lint + - name: golint run: golint -set_exit_status ./... + - name: testifylint + run: testifylint -v -enable-all ./... + golangci: name: golangci lint check runs-on: ubuntu-latest @@ -63,7 +79,12 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Set up Go 1.x + uses: actions/setup-go@v5 + with: + go-version: ^1.22 + - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v4 with: - version: v1.54.2 + version: v1.57.1 diff --git a/.golangci.yml b/.golangci.yml index aebb88c..34e704f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,7 +14,7 @@ linters-settings: stylecheck: go: "1.18" govet: - check-shadowing: true + shadow: true misspell: locale: US cyclop: diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0709949 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +ifeq (, $(shell which testifylint)) +$(error "No 'testifylint' on PATH, consider doing: go install github.com/Antonboom/testifylint@latest") +endif + +ifeq (, $(shell which golint)) +$(error "No 'golint' on PATH, consider doing: go install golang.org/x/lint/golint@latest") +endif + +ifeq (, $(shell which golangci-lint)) +$(error "No 'golangci-lint' on PATH, consider following these instructions: https://golangci-lint.run/welcome/install/#local-installation") +endif + +.PHONY: lint +lint: + golint -set_exit_status ./... + testifylint ./... + golangci-lint run diff --git a/go.mod b/go.mod index 1722e2b..ade7f27 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,21 @@ module github.com/yitsushi/totp-cli -go 1.21 +go 1.22 require ( filippo.io/age v1.1.1 github.com/stretchr/testify v1.7.1 github.com/urfave/cli/v2 v2.27.1 - golang.org/x/term v0.16.0 + golang.org/x/term v0.18.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect - golang.org/x/crypto v0.17.0 // indirect - golang.org/x/sys v0.16.0 // indirect + github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/sys v0.18.0 // indirect ) diff --git a/go.sum b/go.sum index 396a75b..fa21927 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg= filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -14,24 +12,16 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= -github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI= -github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/error.go b/internal/cmd/error.go index e676736..631eb7a 100644 --- a/internal/cmd/error.go +++ b/internal/cmd/error.go @@ -1,14 +1,12 @@ package cmd -import "fmt" - // DownloadError is an error during downloading an update. type DownloadError struct { Message string } func (e DownloadError) Error() string { - return fmt.Sprintf("download error: %s", e.Message) + return "download error: %s" + e.Message } // CommandError is an error during downloading an update. @@ -17,5 +15,9 @@ type CommandError struct { } func (e CommandError) Error() string { - return fmt.Sprintf("error: %s", e.Message) + return "error: %s" + e.Message +} + +func resourceNotFoundError(name string) CommandError { + return CommandError{Message: name + " does not exist"} } diff --git a/internal/cmd/rename.go b/internal/cmd/rename.go index 013d9f4..c443723 100644 --- a/internal/cmd/rename.go +++ b/internal/cmd/rename.go @@ -55,7 +55,7 @@ func renameNamespaceCommand() *cli.Command { namespace, err := storage.FindNamespace(nsName) if err != nil { - return CommandError{Message: fmt.Sprintf("%s does not exist", nsName)} + return resourceNotFoundError(nsName) } namespace.Name = newName @@ -91,12 +91,12 @@ func renameAccountCommand() *cli.Command { namespace, err := storage.FindNamespace(nsName) if err != nil { - return CommandError{Message: fmt.Sprintf("%s does not exist", nsName)} + return resourceNotFoundError(nsName) } account, err := namespace.FindAccount(accName) if err != nil { - return CommandError{Message: fmt.Sprintf("%s/%s does not exist", namespace.Name, accName)} + return resourceNotFoundError(fmt.Sprintf("%s/%s", namespace.Name, accName)) } account.Name = newName diff --git a/internal/cmd/set_length.go b/internal/cmd/set_length.go index ddd91d7..8ad1d9d 100644 --- a/internal/cmd/set_length.go +++ b/internal/cmd/set_length.go @@ -44,12 +44,12 @@ func SetLengthCommand() *cli.Command { namespace, err = storage.FindNamespace(nsName) if err != nil { - return CommandError{Message: fmt.Sprintf("%s does not exist", nsName)} + return resourceNotFoundError(nsName) } account, err = namespace.FindAccount(accName) if err != nil { - return CommandError{Message: fmt.Sprintf("%s/%s does not exist", namespace.Name, accName)} + return resourceNotFoundError(fmt.Sprintf("%s/%s", namespace.Name, accName)) } account.Length = length diff --git a/internal/cmd/set_prefix.go b/internal/cmd/set_prefix.go index db26c7a..e1b08a3 100644 --- a/internal/cmd/set_prefix.go +++ b/internal/cmd/set_prefix.go @@ -51,12 +51,12 @@ func SetPrefixCommand() *cli.Command { namespace, err = storage.FindNamespace(nsName) if err != nil { - return CommandError{Message: fmt.Sprintf("%s does not exist", nsName)} + return resourceNotFoundError(nsName) } account, err = namespace.FindAccount(accName) if err != nil { - return CommandError{Message: fmt.Sprintf("%s/%s does not exist", namespace.Name, accName)} + return resourceNotFoundError(fmt.Sprintf("%s/%s", namespace.Name, accName)) } account.Prefix = prefix diff --git a/internal/security/error.go b/internal/security/error.go index f3304be..dff2fbd 100644 --- a/internal/security/error.go +++ b/internal/security/error.go @@ -1,12 +1,10 @@ package security -import "fmt" - // OTPError is an error describing an error during generation. type OTPError struct { Message string } func (e OTPError) Error() string { - return fmt.Sprintf("otp error: %s", e.Message) + return "otp error: " + e.Message } diff --git a/internal/security/otp_test.go b/internal/security/otp_test.go index 7c983cd..3f06f7a 100644 --- a/internal/security/otp_test.go +++ b/internal/security/otp_test.go @@ -7,12 +7,20 @@ import ( "github.com/yitsushi/totp-cli/internal/storage" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" "github.com/yitsushi/totp-cli/internal/security" ) -func TestGenerateOTPCode(t *testing.T) { +func TestGenerateOTPCodeSuit(t *testing.T) { + suite.Run(t, &GenerateOTPCodeTestSuite{}) +} + +type GenerateOTPCodeTestSuite struct { + suite.Suite +} + +func (suite *GenerateOTPCodeTestSuite) TestDefault() { input := base32.StdEncoding.EncodeToString([]byte("82394783472398472348")) table := map[time.Time]string{ time.Date(1970, 1, 1, 0, 0, 59, 0, time.UTC): "007459", @@ -27,12 +35,12 @@ func TestGenerateOTPCode(t *testing.T) { for when, expected := range table { code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength) - assert.NoError(t, err) - assert.Equal(t, expected, code, when.String()) + suite.Require().NoError(err) + suite.Equal(expected, code, when.String()) } } -func TestGenerateOTPCode_length8(t *testing.T) { +func (suite *GenerateOTPCodeTestSuite) TestDifferentLength() { input := base32.StdEncoding.EncodeToString([]byte("82394783472398472348")) table := map[time.Time]string{ time.Date(1970, 1, 1, 0, 0, 59, 0, time.UTC): "53007459", @@ -47,12 +55,12 @@ func TestGenerateOTPCode_length8(t *testing.T) { for when, expected := range table { code, _, err := security.GenerateOTPCode(input, when, 8) - assert.NoError(t, err) - assert.Equal(t, expected, code, when.String()) + suite.Require().NoError(err) + suite.Equal(expected, code, when.String()) } } -func TestGenerateOTPCode_SpaceSeparatedToken(t *testing.T) { +func (suite *GenerateOTPCodeTestSuite) TestSpaceSeparatedToken() { input := "37kh vdxt c5hj ttfp ujok cipy jy" table := map[time.Time]string{ time.Date(1970, 1, 1, 0, 0, 59, 0, time.UTC): "066634", @@ -67,12 +75,12 @@ func TestGenerateOTPCode_SpaceSeparatedToken(t *testing.T) { for when, expected := range table { code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength) - assert.NoError(t, err) - assert.Equal(t, expected, code, when.String()) + suite.Require().NoError(err) + suite.Equal(expected, code, when.String()) } } -func TestGenerateOTPCode_NonPaddedHashes(t *testing.T) { +func (suite *GenerateOTPCodeTestSuite) TestNonPaddedHashes() { input := "a6mryljlbufszudtjdt42nh5by" table := map[time.Time]string{ time.Date(1970, 1, 1, 0, 0, 59, 0, time.UTC): "866149", @@ -87,12 +95,12 @@ func TestGenerateOTPCode_NonPaddedHashes(t *testing.T) { for when, expected := range table { code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength) - assert.NoError(t, err) - assert.Equal(t, expected, code, when.String()) + suite.Require().NoError(err) + suite.Equal(expected, code, when.String()) } } -func TestGenerateOTPCode_InvalidPadding(t *testing.T) { +func (suite *GenerateOTPCodeTestSuite) TestInvalidPadding() { input := "a6mr*&^&*%*&ylj|'[lbufszudtjdt42nh5by" table := map[time.Time]string{ time.Date(1970, 1, 1, 0, 0, 59, 0, time.UTC): "", @@ -102,7 +110,7 @@ func TestGenerateOTPCode_InvalidPadding(t *testing.T) { for when, expected := range table { code, _, err := security.GenerateOTPCode(input, when, storage.DefaultTokenLength) - assert.Error(t, err) - assert.Equal(t, expected, code, when.String()) + suite.Require().Error(err) + suite.Equal(expected, code, when.String()) } } diff --git a/internal/security/unsecure.go b/internal/security/unsecure.go index 51bf21b..617cd86 100644 --- a/internal/security/unsecure.go +++ b/internal/security/unsecure.go @@ -2,7 +2,7 @@ package security import ( "crypto/sha1" //nolint:gosec // It's hard to change now without breaking. Issue #41. - "fmt" + "encoding/hex" ) // UnsecureSHA1 is not secure, but makes a fixed length password. @@ -15,7 +15,7 @@ func UnsecureSHA1(text string) []byte { hash := sha1.New() //nolint:gosec // yolo? _, _ = hash.Write([]byte(text)) h := hash.Sum(nil) - text = fmt.Sprintf("%x", h) + text = hex.EncodeToString(h) copy(result, text[0:passwordHashLength]) diff --git a/internal/storage/account.go b/internal/storage/account.go index bbb5312..de86460 100644 --- a/internal/storage/account.go +++ b/internal/storage/account.go @@ -6,8 +6,8 @@ const DefaultTokenLength = 6 // Account represents a TOTP account. type Account struct { - Name string - Token string - Prefix string - Length uint + Name string `json:"name" yaml:"name"` + Token string `json:"token" yaml:"token"` + Prefix string `json:"prefix" yaml:"prefix"` + Length uint `json:"length" yaml:"length"` } diff --git a/internal/storage/error.go b/internal/storage/error.go index c373953..a6ae450 100644 --- a/internal/storage/error.go +++ b/internal/storage/error.go @@ -19,5 +19,5 @@ type BackendError struct { } func (e BackendError) Error() string { - return fmt.Sprintf("storage error: %s", e.Message) + return "storage error: " + e.Message } diff --git a/internal/storage/filebackend.go b/internal/storage/filebackend.go index 474eb6e..d396644 100644 --- a/internal/storage/filebackend.go +++ b/internal/storage/filebackend.go @@ -6,7 +6,6 @@ import ( "crypto/cipher" "encoding/base64" "encoding/json" - "fmt" "io" "os" "os/user" @@ -125,7 +124,7 @@ func (s *FileBackend) DeleteNamespace(namespace *Namespace) { func (s *FileBackend) AddNamespace(ns *Namespace) (*Namespace, error) { if lookupNS, err := s.FindNamespace(ns.Name); err == nil { return lookupNS, BackendError{ - Message: fmt.Sprintf("namespace already exists: %s", ns.Name), + Message: "namespace already exists: " + ns.Name, } } @@ -141,8 +140,10 @@ func (s *FileBackend) ListNamespaces() []*Namespace { // Save tries to encrypt and save the storage. func (s *FileBackend) Save() error { - tmpFile, err := os.CreateTemp(filepath.Dir(s.file), - fmt.Sprintf("%s.*.tmp", filepath.Base(s.file))) + tmpFile, err := os.CreateTemp( + filepath.Dir(s.file), + filepath.Base(s.file)+".*.tmp", + ) if err != nil { return BackendError{Message: err.Error()} } @@ -270,27 +271,9 @@ func (s *FileBackend) decryptV2() error { func (s *FileBackend) initfileStorage() error { var credentialFile string - credentialFile = os.Getenv("TOTP_CLI_CREDENTIAL_FILE") - - if credentialFile == "" { - currentUser, err := user.Current() - if err != nil { - return BackendError{Message: err.Error()} - } - - homePath := currentUser.HomeDir - documentDirectory := filepath.Join(homePath, ".config/totp-cli") - - _, err = os.Stat(documentDirectory) - if os.IsNotExist(err) { - err = os.MkdirAll(documentDirectory, storageDirectoryPermissions) - } - - if err != nil { - return BackendError{Message: err.Error()} - } - - credentialFile = filepath.Join(documentDirectory, "credentials") + credentialFile, err := s.credentialsFilePath() + if err != nil { + return err } if _, err := os.Stat(credentialFile); err == nil { @@ -299,11 +282,17 @@ func (s *FileBackend) initfileStorage() error { return nil } - term := terminal.New(os.Stdin, os.Stdout, os.Stderr) + password := os.Getenv("TOTP_PASS") - password, err := term.Hidden("Your Password (do not forget it):") - if err != nil { - return BackendError{Message: err.Error()} + if password == "" { + term := terminal.New(os.Stdin, os.Stdout, os.Stderr) + + var err error + + password, err = term.Hidden("Your Password (do not forget it):") + if err != nil { + return BackendError{Message: err.Error()} + } } s.file = credentialFile @@ -383,3 +372,29 @@ func (s *FileBackend) parseV2(decodedData []byte) error { return nil } + +func (s *FileBackend) credentialsFilePath() (string, error) { + filePath := os.Getenv("TOTP_CLI_CREDENTIAL_FILE") + if filePath != "" { + return filePath, nil + } + + currentUser, err := user.Current() + if err != nil { + return "", BackendError{Message: err.Error()} + } + + homePath := currentUser.HomeDir + documentDirectory := filepath.Join(homePath, ".config", "totp-cli") + + _, err = os.Stat(documentDirectory) + if os.IsNotExist(err) { + err = os.MkdirAll(documentDirectory, storageDirectoryPermissions) + } + + if err != nil { + return "", BackendError{Message: err.Error()} + } + + return filepath.Join(documentDirectory, "credentials"), nil +} diff --git a/internal/storage/filebackend_test.go b/internal/storage/filebackend_test.go index 19fd6c1..93178ec 100644 --- a/internal/storage/filebackend_test.go +++ b/internal/storage/filebackend_test.go @@ -1,69 +1,155 @@ package storage_test import ( + "os" + "path" "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" - s "github.com/yitsushi/totp-cli/internal/storage" + "github.com/yitsushi/totp-cli/internal/storage" ) -func TestFindNamespace(t *testing.T) { - storage := s.NewFileStorage() +func TestFileBackend(t *testing.T) { + suite.Run(t, &FileBackendTestSuite{}) +} + +type FileBackendTestSuite struct { + suite.Suite + storage storage.Storage +} - storage.AddNamespace(&s.Namespace{Name: "Namespace1"}) - storage.AddNamespace(&s.Namespace{Name: "Namespace2"}) - storage.AddNamespace(&s.Namespace{Name: "Namespace3"}) +func (suite *FileBackendTestSuite) SetupTest() { + suite.storage = storage.NewFileStorage() +} + +func (suite *FileBackendTestSuite) TestFindNamespace() { + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace1"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace2"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace3"}) - namespace, err := storage.FindNamespace("Namespace1") + namespace, err := suite.storage.FindNamespace("Namespace1") - assert.Equal(t, err, nil, "Error should be nil") - assert.Equal(t, namespace.Name, "Namespace1", "Found namespace name should be Namespace1") + suite.Require().NoError(err) + suite.Equal("Namespace1", namespace.Name, "Found namespace name should be Namespace1") } -func TestFindNamespace_NotFound(t *testing.T) { - storage := s.NewFileStorage() +func (suite *FileBackendTestSuite) TestNamespaceNotFound() { + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace1"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace2"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace3"}) - storage.AddNamespace(&s.Namespace{Name: "Namespace1"}) - storage.AddNamespace(&s.Namespace{Name: "Namespace2"}) - storage.AddNamespace(&s.Namespace{Name: "Namespace3"}) + namespace, err := suite.storage.FindNamespace("NamespaceNotFound") - namespace, err := storage.FindNamespace("NamespaceNotFound") + suite.Require().ErrorIs(err, storage.NotFoundError{Type: "namespace", Name: "NamespaceNotFound"}) + suite.Nil(namespace, "Namespace should be nil") +} - assert.EqualError( - t, - err, - "namespace not found: NamespaceNotFound", - "Error should be 'namespace not found: NamespaceNotFound'", - ) - assert.Nil(t, namespace, "Namespace should be nil") +func (suite *FileBackendTestSuite) TestAddNamespace() { + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace1"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace2"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace3"}) + + namespace, err := suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace4"}) + + suite.Require().NoError(err) + suite.Equal("Namespace4", namespace.Name) } -func TestDeleteNamespace(t *testing.T) { +func (suite *FileBackendTestSuite) TestAddAlreadyExistingNamespace() { + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace1"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace2"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace3"}) + + namespace, err := suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace3"}) + + suite.Require().ErrorIs(err, storage.BackendError{Message: "namespace already exists: Namespace3"}) + suite.Equal("Namespace3", namespace.Name) +} + +func (suite *FileBackendTestSuite) TestDeleteNamespace() { var ( - namespace *s.Namespace + namespace *storage.Namespace err error ) - storage := s.NewFileStorage() - - storage.AddNamespace(&s.Namespace{Name: "Namespace1"}) - storage.AddNamespace(&s.Namespace{Name: "Namespace2"}) - storage.AddNamespace(&s.Namespace{Name: "Namespace3"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace1"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace2"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace3"}) - assert.Equal(t, len(storage.ListNamespaces()), 3) - namespace, err = storage.FindNamespace("Namespace1") - assert.NoError(t, err) + suite.Len(suite.storage.ListNamespaces(), 3) + namespace, err = suite.storage.FindNamespace("Namespace1") + suite.Require().NoError(err) - storage.DeleteNamespace(namespace) - assert.Equal(t, len(storage.ListNamespaces()), 2) - namespace, err = storage.FindNamespace("Namespace1") - assert.EqualError( - t, - err, - "namespace not found: Namespace1", - "Error should be 'namespace not found: Namespace1'") + suite.storage.DeleteNamespace(namespace) + suite.Len(suite.storage.ListNamespaces(), 2) + namespace, err = suite.storage.FindNamespace("Namespace1") + suite.Require().ErrorIs(err, storage.NotFoundError{Type: "namespace", Name: "Namespace1"}) // Delete again :D - storage.DeleteNamespace(namespace) - assert.Equal(t, len(storage.ListNamespaces()), 2) + suite.storage.DeleteNamespace(namespace) + suite.Len(suite.storage.ListNamespaces(), 2) +} + +func (suite *FileBackendTestSuite) TestDeleteNonExistingNamespace() { + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace1"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace2"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "Namespace3"}) + + suite.Len(suite.storage.ListNamespaces(), 3) + suite.storage.DeleteNamespace(&storage.Namespace{Name: "Namespace4"}) + suite.Len(suite.storage.ListNamespaces(), 3) +} + +func (suite *FileBackendTestSuite) TestReadWrite() { + tmpDir, err := os.MkdirTemp("", "totp-cli-test-*") + if err != nil { + return + } + credsFilepath := path.Join(tmpDir, "credentials") + + defer func() { + os.RemoveAll(tmpDir) + }() + + os.Setenv("TOTP_PASS", "password") + os.Setenv("TOTP_CLI_CREDENTIAL_FILE", credsFilepath) + + suite.storage.Prepare() + suite.Empty(suite.storage.ListNamespaces()) + suite.storage.AddNamespace(&storage.Namespace{Name: "ns1"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "ns2"}) + suite.Len(suite.storage.ListNamespaces(), 2) + suite.storage.Save() + + newStorage := storage.NewFileStorage() + newStorage.Prepare() + suite.Len(newStorage.ListNamespaces(), 2) +} + +func (suite *FileBackendTestSuite) TestInvalidPassword() { + tmpDir, err := os.MkdirTemp("", "totp-cli-test-*") + if err != nil { + return + } + credsFilepath := path.Join(tmpDir, "credentials") + + defer func() { + os.RemoveAll(tmpDir) + }() + + os.Setenv("TOTP_PASS", "password") + os.Setenv("TOTP_CLI_CREDENTIAL_FILE", credsFilepath) + + err = suite.storage.Prepare() + suite.Require().NoError(err) + suite.Empty(suite.storage.ListNamespaces()) + suite.storage.AddNamespace(&storage.Namespace{Name: "ns1"}) + suite.storage.AddNamespace(&storage.Namespace{Name: "ns2"}) + suite.Len(suite.storage.ListNamespaces(), 2) + suite.storage.Save() + + newStorage := storage.NewFileStorage() + newStorage.SetPassword("new password") + err = newStorage.Prepare() + suite.Require().ErrorIs(err, storage.BackendError{Message: "no identity matched any of the recipients"}) } diff --git a/internal/storage/namespace.go b/internal/storage/namespace.go index f88c5b5..14193a7 100644 --- a/internal/storage/namespace.go +++ b/internal/storage/namespace.go @@ -2,8 +2,8 @@ package storage // Namespace represents a Namespace "category". type Namespace struct { - Name string - Accounts []*Account + Name string `json:"name" yaml:"name"` + Accounts []*Account `json:"accounts" yaml:"accounts"` } // FindAccount returns with an account under a specific Namespace diff --git a/internal/storage/namespace_test.go b/internal/storage/namespace_test.go index 4ba36c5..96bca5c 100644 --- a/internal/storage/namespace_test.go +++ b/internal/storage/namespace_test.go @@ -3,12 +3,20 @@ package storage_test import ( "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" "github.com/yitsushi/totp-cli/internal/storage" ) -func TestFindAccount(t *testing.T) { +func TestAccount(t *testing.T) { + suite.Run(t, &AccountTestSuite{}) +} + +type AccountTestSuite struct { + suite.Suite +} + +func (suite *AccountTestSuite) TestFindAccount() { namespace := &storage.Namespace{ Name: "myNamespace", Accounts: []*storage.Account{ @@ -20,11 +28,11 @@ func TestFindAccount(t *testing.T) { account, err := namespace.FindAccount("Account1") - assert.Equal(t, err, nil, "Error should be nil") - assert.Equal(t, account.Name, "Account1", "Found account name should be Account1") + suite.Require().NoError(err) + suite.Equal("Account1", account.Name, "Found account name should be Account1") } -func TestFindAccount_NotFound(t *testing.T) { +func (suite *AccountTestSuite) TestAccountNotFound() { namespace := &storage.Namespace{ Name: "myNamespace", Accounts: []*storage.Account{ @@ -36,16 +44,11 @@ func TestFindAccount_NotFound(t *testing.T) { account, err := namespace.FindAccount("AccountNotFound") - assert.EqualError( - t, - err, - "account not found: AccountNotFound", - "Error should be 'account not found: AccountNotFound'", - ) - assert.Nil(t, account) + suite.Require().ErrorIs(err, storage.NotFoundError{Type: "account", Name: "AccountNotFound"}) + suite.Nil(account) } -func TestDeleteAccount(t *testing.T) { +func (suite *AccountTestSuite) TestDeleteAccount() { var ( account *storage.Account err error @@ -60,20 +63,15 @@ func TestDeleteAccount(t *testing.T) { }, } - assert.Equal(t, len(namespace.Accounts), 3) + suite.Len(namespace.Accounts, 3) account, err = namespace.FindAccount("Account1") - assert.NoError(t, err) + suite.Require().NoError(err) namespace.DeleteAccount(account) - assert.Equal(t, len(namespace.Accounts), 2) + suite.Len(namespace.Accounts, 2) account, err = namespace.FindAccount("Account1") - assert.EqualError( - t, - err, - "account not found: Account1", - "Error should be 'account not found: Account1'", - ) + suite.Require().ErrorIs(err, storage.NotFoundError{Type: "account", Name: "Account1"}) // Delete again :D namespace.DeleteAccount(account) - assert.Equal(t, len(namespace.Accounts), 2) + suite.Len(namespace.Accounts, 2) } diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index d49eecf..a7d34b2 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -5,7 +5,7 @@ import ( "io" "testing" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" "github.com/yitsushi/totp-cli/internal/terminal" ) @@ -14,7 +14,15 @@ func prepareIO(input []byte) (io.Reader, *bytes.Buffer, *bytes.Buffer) { return bytes.NewReader(input), &bytes.Buffer{}, &bytes.Buffer{} } -func TestTerminal_Read(t *testing.T) { +func TestTerminal(t *testing.T) { + suite.Run(t, &TerminalTestSuite{}) +} + +type TerminalTestSuite struct { + suite.Suite +} + +func (suite *TerminalTestSuite) TestRead() { type testCase struct { Prompt string In string @@ -34,13 +42,13 @@ func TestTerminal_Read(t *testing.T) { term := terminal.New(input, output, errorOut) value, err := term.Read(tc.Prompt) - assert.Nil(t, err) - assert.Equal(t, tc.Out, output.String()) - assert.Equal(t, tc.Value, value) + suite.Require().NoError(err) + suite.Equal(tc.Out, output.String()) + suite.Equal(tc.Value, value) } } -func TestTerminal_Confirm(t *testing.T) { +func (suite *TerminalTestSuite) TestConfirm() { type testCase struct { Prompt string In string @@ -64,12 +72,12 @@ func TestTerminal_Confirm(t *testing.T) { term := terminal.New(input, output, errorOut) value := term.Confirm(tc.Prompt) - assert.Equal(t, tc.Out, output.String()) - assert.Equal(t, tc.Value, value) + suite.Equal(tc.Out, output.String()) + suite.Equal(tc.Value, value) } } -func TestTerminal_Hidden(t *testing.T) { +func (suite *TerminalTestSuite) TestHidden() { type testCase struct { Prompt string In string @@ -94,9 +102,9 @@ func TestTerminal_Hidden(t *testing.T) { term := terminal.New(input, output, errorOut) value, err := term.Hidden(tc.Prompt) - assert.Nil(t, err) - assert.Equal(t, tc.Err, errorOut.String()) - assert.Equal(t, tc.Out, output.String()) - assert.Equal(t, tc.Value, value) + suite.Require().NoError(err) + suite.Equal(tc.Err, errorOut.String()) + suite.Equal(tc.Out, output.String()) + suite.Equal(tc.Value, value) } }