diff --git a/go.mod b/go.mod index 4d676a2..a1c6c76 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,7 @@ require ( require golang.org/x/crypto v0.27.0 // indirect require ( - github.com/google/go-cmp v0.5.9 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect - gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 4aa1986..f2ec7e7 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -18,5 +16,3 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/main.go b/main.go index b662cc8..235b2e8 100644 --- a/main.go +++ b/main.go @@ -41,13 +41,13 @@ const DB_VERSION = 1 const DEFAULT_PORT int64 = 8080 const HRT_DATE int64 = 1756478697 +const DEFAULT_LOG_FLAGS = log.Ldate | log.Ltime | log.Lmicroseconds //go:embed "public" var publicFS embed.FS func main() { - // TODO: switch to a new logger. this one kinda sucks - logger := log.New(os.Stderr, "main", model.DEFAULT_LOG_FLAGS) + logger := log.New(os.Stderr, "main", DEFAULT_LOG_FLAGS) logger.Print("made with <3 by ari melody\n\n") @@ -94,13 +94,13 @@ func main() { logRepo := logRepo.NewLogRepositoryPostgres(psqlDB) app.Log = logService.NewLogService( logRepo, - log.New(os.Stderr, "logger", model.DEFAULT_LOG_FLAGS), + log.New(os.Stderr, "logger", DEFAULT_LOG_FLAGS), ) accountRepo := accountRepo.NewAccountRepositoryPostgres(psqlDB) app.AccountService = accountService.NewAccountService( accountRepo, - log.New(os.Stderr, "account-repo", model.DEFAULT_LOG_FLAGS), + log.New(os.Stderr, "account-repo", DEFAULT_LOG_FLAGS), ) // handle command arguments @@ -306,7 +306,15 @@ func main() { } account.Password = string(hashedPassword) - if err = app.AccountService.ChangePassword(account.ID, string(hashedPassword)); err != nil { + var email *string = nil + if account.Email.Valid { email = &account.Email.String } + var avatarURL *string = nil + if account.AvatarURL.Valid { email = &account.AvatarURL.String } + if err = app.AccountService.Update( + account.ID, + username, string(hashedPassword), + email, avatarURL, + ); err != nil { logger.Fatalf("FATAL: Failed to update password: %v\n", err) } @@ -486,7 +494,7 @@ func main() { go cursor.StartCursor(&app) - httpLogger := log.New(os.Stderr, "http", model.DEFAULT_LOG_FLAGS) + httpLogger := log.New(os.Stderr, "http", DEFAULT_LOG_FLAGS) // start the web server! mux := createServeMux(&app) diff --git a/model/log.go b/model/log.go index 09e5af2..af4df5e 100644 --- a/model/log.go +++ b/model/log.go @@ -1,9 +1,6 @@ package model -import ( - "log" - "time" -) +import "time" type ( LogLevel int @@ -18,8 +15,6 @@ type ( ) const ( - DEFAULT_LOG_FLAGS = log.Ldate | log.Ltime | log.Lmicroseconds - LOG_ACCOUNT string = "account" LOG_MUSIC string = "music" LOG_ARTIST string = "artist" diff --git a/model/release_test.go b/model/release_test.go index fc0e221..b0ddaf5 100644 --- a/model/release_test.go +++ b/model/release_test.go @@ -1,149 +1,157 @@ package model import ( - "strings" - "testing" - "time" - - "gotest.tools/v3/assert" + "testing" + "time" ) -func Test_Release(t *testing.T) { - t.Run("prints correct description HTML", func(t *testing.T) { - release := Release{ - Description: "this is\na test\ndescription!", - } +func Test_Release_DescriptionHTML(t *testing.T) { + release := Release{ + Description: "this is\na test\ndescription!", + } - // descriptions are set by privileged users, - // so we'll allow HTML injection here - assert.Equal( - t, - string(release.GetDescriptionHTML()), - "this is
a test
description!", - ) - }) - - t.Run("prints correct release date", func(t *testing.T) { - release := Release{ - ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC), - } - - assert.Equal(t, release.TextReleaseDate(), "2025-07-26T16:00") - assert.Equal(t, release.PrintReleaseDate(), "26 July 2025") - }) - - t.Run("returns correct artwork", func(t *testing.T) { - artwork := "testartwork.png" - release := Release{ Artwork: artwork } - assert.Equal(t, release.GetArtwork(), artwork) - }) - - t.Run("returns placeholder artwork when empty", func(t *testing.T) { - release := Release{} - assert.Equal(t, release.GetArtwork(), "/img/default-cover-art.png") - }) - - t.Run("singles", func(t *testing.T) { - release := Release{ - Tracks: []*Track{}, - } - - t.Run("false when no tracks are present", func(t *testing.T) { - assert.Equal(t, release.IsSingle(), false) - }) - - release.Tracks = append(release.Tracks, &Track{}) - t.Run("true when one track is present", func(t *testing.T) { - assert.Equal(t, release.IsSingle(), true) - }) - - release.Tracks = append(release.Tracks, &Track{}) - t.Run("false when >1 tracks are present", func(t *testing.T) { - assert.Equal(t, release.IsSingle(), false) - }) - }) - - t.Run("released", func(t *testing.T) { - release := Release { - ReleaseDate: time.Now(), - } - - t.Run("true when release date in the past", func(t *testing.T) { - assert.Equal(t, release.IsReleased(), true) - }) - - release.ReleaseDate = time.Now().Add(time.Hour) - t.Run("false when release date in the future", func(t *testing.T) { - assert.Equal(t, release.IsReleased(), false) - }) - }) - - t.Run("printing artists", func(t *testing.T) { - artist1 := "ari melody" - artist2 := "aridoodle" - artist3 := "idk" - artist4 := "guest" - - release := Release{} - t.Run("prints \"Unknown Artist\" when release has no credits", func(t *testing.T) { - assert.Equal(t, release.PrintArtists(false, true), "Unknown Artist") - }) - - release.Credits = append( - release.Credits, - &Credit{ Artist: Artist{ Name: artist1 }, Primary: true }, - ) - t.Run("prints ONLY first artist name when release has one credit", func(t *testing.T) { - assert.Equal(t, release.PrintArtists(false, true), artist1) - }) - - release.Credits = append(release.Credits, []*Credit{ - { Artist: Artist{ Name: artist2 }, Primary: true }, - { Artist: Artist{ Name: artist3 }, Primary: false }, - { Artist: Artist{ Name: artist4 }, Primary: true }, - }...) - t.Run("can get only unique primary artist names", func(t *testing.T) { - assert.Equal( - t, - strings.Join(release.GetUniqueArtistNames(true), " "), - strings.Join([]string{ artist1, artist2, artist4 }, " "), - ) - }) - t.Run("can get only unique artist names", func(t *testing.T) { - assert.Equal( - t, - strings.Join(release.GetUniqueArtistNames(false), " "), - strings.Join([]string{ artist1, artist2, artist3, artist4 }, " "), - ) - }) - - t.Run("can print only primary artists, with ampersands", func(t *testing.T) { - assert.Equal( - t, - release.PrintArtists(true, true), - "ari melody, aridoodle & guest", - ) - }) - t.Run("can print only primary artists, without ampersands", func(t *testing.T) { - assert.Equal( - t, - release.PrintArtists(true, false), - "ari melody, aridoodle, guest", - ) - }) - t.Run("can print all artists, with ampersands", func(t *testing.T) { - assert.Equal( - t, - release.PrintArtists(false, true), - "ari melody, aridoodle, idk & guest", - ) - }) - t.Run("can print all artists, without ampersands", func(t *testing.T) { - assert.Equal( - t, - release.PrintArtists(false, false), - "ari melody, aridoodle, idk, guest", - ) - }) - }) + // descriptions are set by privileged users, + // so we'll allow HTML injection here + want := "this is
a test
description!" + got := release.GetDescriptionHTML() + if want != string(got) { + t.Errorf(`release description incorrectly formatted (want "%s", got "%s")`, want, got) + } +} + +func Test_Release_ReleaseDate(t *testing.T) { + release := Release{ + ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC), + } + + want := "2025-07-26T16:00" + got := release.TextReleaseDate() + if want != got { + t.Errorf(`release date incorrectly formatted (want "%s", got "%s")`, want, got) + } + + want = "26 July 2025" + got = release.PrintReleaseDate() + if want != got { + t.Errorf(`release date (print) incorrectly formatted (want "%s", got "%s")`, want, got) + } +} + +func Test_Release_Artwork(t *testing.T) { + want := "testartwork.png" + release := Release{ Artwork: want } + + got := release.GetArtwork() + if want != got { + t.Errorf(`correct value not returned when artwork is populated (want "%s", got "%s")`, want, got) + } + + release = Release{} + + want = "/img/default-cover-art.png" + got = release.GetArtwork() + if want != got { + t.Errorf(`default value not returned when artwork is empty (want "%s", got "%s")`, want, got) + } +} + +func Test_Release_IsSingle(t *testing.T) { + release := Release{ + Tracks: []*Track{}, + } + + if release.IsSingle() { + t.Errorf("IsSingle() == true when no tracks are present") + } + + release.Tracks = append(release.Tracks, &Track{}) + if !release.IsSingle() { + t.Errorf("IsSingle() == false when one track is present") + } + + release.Tracks = append(release.Tracks, &Track{}) + if release.IsSingle() { + t.Errorf("IsSingle() == true when >1 tracks are present") + } +} + +func Test_Release_IsReleased(t *testing.T) { + release := Release { + ReleaseDate: time.Now(), + } + + if !release.IsReleased() { + t.Errorf("IsRelease() == false when release date in the past") + } + + release.ReleaseDate = time.Now().Add(time.Hour) + if release.IsReleased() { + t.Errorf("IsRelease() == true when release date in the future") + } +} + +func Test_Release_PrintArtists(t *testing.T) { + artist1 := "ari melody" + artist2 := "aridoodle" + artist3 := "idk" + artist4 := "guest" + + release := Release { + Credits: []*Credit{ + { Artist: Artist{ Name: artist1 }, Primary: true }, + { Artist: Artist{ Name: artist2 }, Primary: true }, + { Artist: Artist{ Name: artist3 }, Primary: false }, + { Artist: Artist{ Name: artist4 }, Primary: true }, + }, + } + + { + want := []string{ artist1, artist2, artist4 } + got := release.GetUniqueArtistNames(true) + if len(want) != len(got) { + t.Errorf(`len(GetUniqueArtistNames) (primary only) == %d, want %d`, len(got), len(want)) + } + for i := range got { + if want[i] != got[i] { + t.Errorf(`GetUniqueArtistNames[%d] (primary only) == %s, want %s`, i, got[i], want[i]) + } + } + + want = []string{ artist1, artist2, artist3, artist4 } + got = release.GetUniqueArtistNames(false) + if len(want) != len(got) { + t.Errorf(`len(GetUniqueArtistNames) == %d, want %d`, len(got), len(want)) + } + for i := range got { + if want[i] != got[i] { + t.Errorf(`GetUniqueArtistNames[%d] == %s, want %s`, i, got[i], want[i]) + } + } + } + + { + want := "ari melody, aridoodle & guest" + got := release.PrintArtists(true, true) + if want != got { + t.Errorf(`PrintArtists (primary only, ampersand) == "%s", want "%s"`, want, got) + } + + want = "ari melody, aridoodle, guest" + got = release.PrintArtists(true, false) + if want != got { + t.Errorf(`PrintArtists (primary only) == "%s", want "%s"`, want, got) + } + + want = "ari melody, aridoodle, idk & guest" + got = release.PrintArtists(false, true) + if want != got { + t.Errorf(`PrintArtists (all, ampersand) == "%s", want "%s"`, want, got) + } + + want = "ari melody, aridoodle, idk, guest" + got = release.PrintArtists(false, false) + if want != got { + t.Errorf(`PrintArtists (all) == "%s", want "%s"`, want, got) + } + } } diff --git a/repository/account/interface.go b/repository/account/interface.go index fda2b1a..5e047d6 100644 --- a/repository/account/interface.go +++ b/repository/account/interface.go @@ -3,20 +3,18 @@ package account import "arimelody-web/model" type AccountRepository interface { - GetAll() ([]*model.Account, error) + GetAll() ([]model.Account, error) GetCount() (int, error) GetByID(id string) (*model.Account, error) GetByUsername(username string) (*model.Account, error) GetByEmail(email string) (*model.Account, error) - - // Pulled this function: Cross-cutting concerns between accounts and sessions. - // Instead, fetch account ID from session and use GetByID() - // GetBySession(sessionToken string) (*model.Account, error) + GetBySession(sessionToken string) (*model.Account, error) // Create an account, returning the new account ID. Create(username string, password string, email *string, avatarURL *string) (string, error) - // Deprecated in favour of more specialised Change* and Remove* functions. + // Intended for large profile updates. For smaller adjusments, + // more specialised Change* and Remove* functions should be used. Update(id string, username string, password string, email *string, avatarUrl *string) error ChangeUsername(id string, username string) error ChangePassword(id string, password string) error @@ -25,12 +23,11 @@ type AccountRepository interface { ChangeAvatarURL(id string, avatarURL string) error RemoveAvatar(id string) error - Delete(id string) error + Delete(accountID string) error // Increment the number of account login failure attempts, // returning the current fail count. - IncrementFails(id string) (int, error) - ResetFails(id string) error - Lock(id string) error - Unlock(id string) error + IncrementFails(accountID string) (int, error) + Lock(accountID string) error + Unlock(accountID string) error } diff --git a/repository/account/memory.go b/repository/account/memory.go deleted file mode 100644 index 1b94167..0000000 --- a/repository/account/memory.go +++ /dev/null @@ -1,198 +0,0 @@ -package account - -import ( - "arimelody-web/model" - "database/sql" - "errors" - "strconv" -) - -type ( - AccountRepositoryMemory struct { - accounts []*model.Account - } -) - -var _ AccountRepository = new(AccountRepositoryMemory) - -func NewAccountRepositoryMemory() *AccountRepositoryMemory { - return &AccountRepositoryMemory{ accounts: make([]*model.Account, 0) } -} - -func (repo *AccountRepositoryMemory) GetAll() ([]*model.Account, error) { - return repo.accounts, nil -} -func (repo *AccountRepositoryMemory) GetCount() (int, error) { - return len(repo.accounts), nil -} -func (repo *AccountRepositoryMemory) GetByID(id string) (*model.Account, error) { - for _, account := range repo.accounts { - if account.ID == id { return account, nil } - } - return nil, nil -} -func (repo *AccountRepositoryMemory) GetByUsername(username string) (*model.Account, error) { - for _, account := range repo.accounts { - if account.Username == username { return account, nil } - } - return nil, nil -} -func (repo *AccountRepositoryMemory) GetByEmail(email string) (*model.Account, error) { - for _, account := range repo.accounts { - if account.Email.Valid && account.Email.String == email { - return account, nil - } - } - return nil, nil -} - -// Create an account, returning the new account ID. -func (repo *AccountRepositoryMemory) Create(username string, password string, email *string, avatarURL *string) (string, error) { - if account, err := repo.GetByUsername(username); err != nil { - return "", errors.New("Failed to fetch other acccounts by username") - } else if account != nil { - return "", errors.New("Account with this username already exists") - } - - emailRef := "" - if email != nil { emailRef = *email } - avatarURLRef := "" - if avatarURL != nil { avatarURLRef = *avatarURL } - - id := strconv.Itoa(len(repo.accounts)) - - repo.accounts = append(repo.accounts, &model.Account{ - ID: id, - Username: username, - Password: password, - Email: sql.NullString{ String: emailRef, Valid: email != nil }, - AvatarURL: sql.NullString{ String: avatarURLRef, Valid: avatarURL != nil }, - }) - - return id, nil -} - -// Intended for large profile updates. For smaller adjusments, -// more specialised Change* and Remove* functions should be used. -func (repo *AccountRepositoryMemory) Update(id string, username string, password string, email *string, avatarUrl *string) error { - if account, err := repo.GetByUsername(username); err != nil { - return errors.New("Failed to fetch other acccounts by username") - } else if account != nil && account.ID != id { - return errors.New("Account with this username already exists") - } - - account, err := repo.GetByID(id) - if err != nil { return err } - - account.Username = username - account.Password = password - account.Email.Valid = email != nil - if account.Email.Valid { account.Email.String = *email } - account.AvatarURL.Valid = avatarUrl != nil - if account.AvatarURL.Valid { account.AvatarURL.String = *avatarUrl } - - return nil -} -func (repo *AccountRepositoryMemory) ChangeUsername(id string, username string) error { - if account, err := repo.GetByUsername(username); err != nil { - return errors.New("Failed to fetch other acccounts by username") - } else if account != nil && account.ID != id { - return errors.New("Account with this username already exists") - } - - account, err := repo.GetByID(id) - if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } - - account.Username = username - return nil -} -func (repo *AccountRepositoryMemory) ChangePassword(id string, password string) error { - account, err := repo.GetByID(id) - if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } - - account.Password = password - return nil -} -func (repo *AccountRepositoryMemory) ChangeEmail(id string, email string) error { - account, err := repo.GetByID(id) - if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } - - account.Email.Valid = true - account.Email.String = email - return nil -} -func (repo *AccountRepositoryMemory) RemoveEmail(id string) error { - account, err := repo.GetByID(id) - if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } - - account.Email.Valid = false - account.Email.String = "" - return nil -} -func (repo *AccountRepositoryMemory) ChangeAvatarURL(id string, avatarURL string) error { - account, err := repo.GetByID(id) - if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } - - account.AvatarURL.Valid = true - account.AvatarURL.String = avatarURL - return nil -} -func (repo *AccountRepositoryMemory) RemoveAvatar(id string) error { - account, err := repo.GetByID(id) - if err != nil { return err } - if account == nil { return errors.New("Account does not exist") } - - account.AvatarURL.Valid = false - account.AvatarURL.String = "" - return nil -} - -func (repo *AccountRepositoryMemory) Delete(id string) error { - accountIndex := -1 - for index, account := range repo.accounts { - if account.ID == id { - accountIndex = index - break - } - } - if accountIndex == -1 { return nil } - - repo.accounts = append( - repo.accounts[:accountIndex], - repo.accounts[accountIndex+1:]..., - ) - - return nil -} - -// Increment the number of account login failure attempts, -// returning the current fail count. -func (repo *AccountRepositoryMemory) IncrementFails(id string) (int, error) { - account, err := repo.GetByID(id) - if err != nil { return 0, err } - account.FailAttempts += 1 - return account.FailAttempts, nil -} -func (repo *AccountRepositoryMemory) ResetFails(id string) error { - account, err := repo.GetByID(id) - if err != nil { return err } - account.FailAttempts = 0 - return nil -} -func (repo *AccountRepositoryMemory) Lock(id string) error { - account, err := repo.GetByID(id) - if err != nil { return err } - account.Locked = true - return nil -} -func (repo *AccountRepositoryMemory) Unlock(id string) error { - account, err := repo.GetByID(id) - if err != nil { return err } - account.Locked = false - return nil -} diff --git a/repository/account/postgres.go b/repository/account/postgres.go index 74ac4c3..f38cf3e 100644 --- a/repository/account/postgres.go +++ b/repository/account/postgres.go @@ -20,8 +20,8 @@ func NewAccountRepositoryPostgres(db *sqlx.DB) *AccountRepositoryPostgres { return &AccountRepositoryPostgres{ db: db } } -func (repo *AccountRepositoryPostgres) GetAll() ([]*model.Account, error) { - var accounts = []*model.Account{} +func (repo *AccountRepositoryPostgres) GetAll() ([]model.Account, error) { + var accounts = []model.Account{} err := repo.db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC") if err != nil { @@ -79,6 +79,22 @@ func (repo *AccountRepositoryPostgres) GetByEmail(email string) (*model.Account, return &account, nil } +func (repo *AccountRepositoryPostgres) GetBySession(sessionToken string) (*model.Account, error) { + if sessionToken == "" { return nil, nil } + + account := model.Account{} + + err := repo.db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, nil + } + return nil, err + } + + return &account, nil +} + func (repo *AccountRepositoryPostgres) Create( username string, password string, @@ -159,30 +175,25 @@ func (repo *AccountRepositoryPostgres) RemoveAvatar(id string) error { return err } -func (repo *AccountRepositoryPostgres) Delete(id string) error { - _, err := repo.db.Exec("DELETE FROM account WHERE id=$1", id) +func (repo *AccountRepositoryPostgres) Delete(accountID string) error { + _, err := repo.db.Exec("DELETE FROM account WHERE id=$1", accountID) return err } // Increment the number of account login failure attempts, // returning the current fail count. -func (repo *AccountRepositoryPostgres) IncrementFails(id string) (int, error) { +func (repo *AccountRepositoryPostgres) IncrementFails(accountID string) (int, error) { failAttempts := 0 - err := repo.db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", id) + err := repo.db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID) return failAttempts, err } -func (repo *AccountRepositoryPostgres) ResetFails(id string) error { - _, err := repo.db.Exec("UPDATE account SET fail_attempts = 0 WHERE id=$1", id) +func (repo *AccountRepositoryPostgres) Lock(accountID string) error { + _, err := repo.db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID) return err } -func (repo *AccountRepositoryPostgres) Lock(id string) error { - _, err := repo.db.Exec("UPDATE account SET locked = true WHERE id=$1", id) - return err -} - -func (repo *AccountRepositoryPostgres) Unlock(id string) error { - _, err := repo.db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", id) +func (repo *AccountRepositoryPostgres) Unlock(accountID string) error { + _, err := repo.db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID) return err } diff --git a/service/account/account.go b/service/account/account.go index 730fc8b..29be2cc 100644 --- a/service/account/account.go +++ b/service/account/account.go @@ -19,7 +19,7 @@ func NewAccountService(repo repository.AccountRepository, logger *log.Logger) (* } } -func (s *AccountService) GetAll() ([]*model.Account, error) { +func (s *AccountService) GetAll() ([]model.Account, error) { return s.repo.GetAll() } @@ -39,6 +39,12 @@ func (s *AccountService) GetByEmail(email string) (*model.Account, error) { return s.repo.GetByEmail(email) } +func (s *AccountService) GetBySession(sessionToken string) (*model.Account, error) { + if sessionToken == "" { return nil, nil } + + return s.repo.GetBySession(sessionToken) +} + func (s *AccountService) Create( username string, password string, @@ -60,6 +66,26 @@ func (s *AccountService) Create( return id, nil } +// Intended for large profile updates. For smaller adjusments, +// more specialised Change* and Remove* functions should be used. +func (s *AccountService) Update( + id string, + username string, + password string, + email *string, + avatarUrl *string, +) error { + if len(username) == 0 { return errors.New("Username cannot be empty") } + if len(password) == 0 { return errors.New("Password cannot be empty") } + if email != nil && len(*email) == 0 { return errors.New("Email cannot be empty") } + + if err := s.repo.Update(id, username, password, email, avatarUrl); err != nil { + return err + } + + s.log.Printf("Updated account '%s' (%s)", username, id) + return nil +} func (s *AccountService) ChangeUsername(id string, username string) error { if len(username) == 0 { return errors.New("Username cannot be empty") } if err := s.repo.ChangeUsername(id, username); err != nil { @@ -105,10 +131,6 @@ func (s *AccountService) IncrementFails(accountID string) (int, error) { return s.repo.IncrementFails(accountID) } -func (s *AccountService) ResetFails(accountID string) (error) { - return s.repo.ResetFails(accountID) -} - func (s *AccountService) Lock(accountID string) error { return s.repo.Lock(accountID) } diff --git a/service/account/account_test.go b/service/account/account_test.go deleted file mode 100644 index 6487d64..0000000 --- a/service/account/account_test.go +++ /dev/null @@ -1,363 +0,0 @@ -package account - -import ( - "arimelody-web/model" - accountRepo "arimelody-web/repository/account" - "log" - "os" - "testing" - "gotest.tools/v3/assert" -) - -var ( - service *AccountService -) - -func init() { - devNullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0666) - if err != nil { panic(err) } - defer devNullFile.Close() - - repo := accountRepo.NewAccountRepositoryMemory() - service = NewAccountService( - repo, - log.New(devNullFile, "", model.DEFAULT_LOG_FLAGS), - ) -} - -// Tests the account lifecycle. Users: -// - [x] can create account -// - [x] can't create duplicate account -// - [x] can change username -// - [x] can change password -// - [x] can change email -// - [x] can change avatar URL -// - [x] can increment auth failures -// - [x] can reset auth failures -// - [x] can lock account -// - [x] can unlock account -// - [x] can delete account -func Test_Account(t *testing.T) { - username := "testificate" - password := "the amazing digital data breach" - email := "goober@arimelody.space" - avatarURL := "/img/default-avatar.webp" - - var id string - var err error - - t.Run("accounts should start empty", func(t *testing.T) { - t.Run("count is zero", func(t *testing.T) { - if num, err := service.GetCount(); err != nil { - t.Errorf("Failed to get number of accounts: %v", err) - } else { - assert.Equal(t, num, 0) - } - }) - - t.Run("service returns empty array", func(t *testing.T) { - if accounts, err := service.GetAll(); err != nil { - t.Errorf("Failed to get number of accounts: %v", err) - } else { - assert.Equal(t, len(accounts), 0) - } - }) - }) - - t.Run("can create account", func(t *testing.T) { - id, err = service.Create(username, password, &email, &avatarURL) - if err != nil { - t.Errorf("Failed to create account: %v", err) - } - - t.Run("and fetch by ID", func(t *testing.T) { - account, err := service.GetByID(id) - if err != nil { - t.Errorf("Failed to get account after creation: %v", err) - } - - assert.Equal(t, account.Username, username) - assert.Equal(t, account.Password, password) - assert.Equal(t, account.Email.String, email) - assert.Equal(t, account.AvatarURL.String, avatarURL) - assert.Equal(t, account.FailAttempts, 0) - assert.Equal(t, account.Locked, false) - }) - t.Run("and fetch by username", func(t *testing.T) { - account, err := service.GetByUsername(username) - if err != nil { - t.Errorf("Failed to get account after creation: %v", err) - } - - assert.Equal(t, account.ID, id) - assert.Equal(t, account.Password, password) - assert.Equal(t, account.Email.String, email) - assert.Equal(t, account.AvatarURL.String, avatarURL) - assert.Equal(t, account.FailAttempts, 0) - assert.Equal(t, account.Locked, false) - }) - t.Run("and fetch by email", func(t *testing.T) { - account, err := service.GetByEmail(email) - if err != nil { - t.Errorf("Failed to get account after creation: %v", err) - } - - assert.Equal(t, account.ID, id) - assert.Equal(t, account.Username, username) - assert.Equal(t, account.Password, password) - assert.Equal(t, account.AvatarURL.String, avatarURL) - assert.Equal(t, account.FailAttempts, 0) - assert.Equal(t, account.Locked, false) - }) - }) - - t.Run("number of accounts should increment", func(t *testing.T) { - t.Run("count is one", func(t *testing.T) { - if num, err := service.GetCount(); err != nil { - t.Errorf("Failed to get number of accounts: %v", err) - } else { - assert.Equal(t, num, 1) - } - }) - - t.Run("service returns array with one account", func(t *testing.T) { - if accounts, err := service.GetAll(); err != nil { - t.Errorf("Failed to get number of accounts: %v", err) - } else { - assert.Equal(t, len(accounts), 1) - } - }) - }) - - t.Run("can't create duplicate account", func(t *testing.T) { - _, err := service.Create(username, password, &email, &avatarURL) - if err == nil { - t.Error("Duplicate account was created") - } - }) - - t.Run("can change username", func(t *testing.T) { - testUsername := "some_other_name" - if err := service.ChangeUsername(id, testUsername); err != nil { - t.Errorf("Failed to change username: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else if account.Username != testUsername { - t.Error("Username did not update") - } - - t.Run("but not to an invalid value", func(t *testing.T) { - if err := service.ChangeUsername(id, ""); err == nil { - t.Error("Could change username to invalid value") - } - }) - }) - - t.Run("can change password", func(t *testing.T) { - testPassword := "other more different password" - if err := service.ChangePassword(id, testPassword); err != nil { - t.Errorf("Failed to change password: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else if account.Password != testPassword { - t.Error("Password did not update") - } - - t.Run("but not to an invalid value", func(t *testing.T) { - if err := service.ChangePassword(id, ""); err == nil { - t.Error("Could change password to invalid value") - } - }) - }) - - t.Run("can change email", func(t *testing.T) { - testEmail := "brandnewemail@for.me" - if err := service.ChangeEmail(id, testEmail); err != nil { - t.Errorf("Failed to change email: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else if !account.Email.Valid || account.Email.String != testEmail { - t.Error("Email did not update") - } - }) - - t.Run("can remove email", func(t *testing.T) { - if err := service.ChangeEmail(id, ""); err != nil { - t.Errorf("Failed to change email: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else if account.Email.Valid || len(account.Email.String) > 0 { - t.Error("Email did not update") - } - }) - - t.Run("can change avatar URL", func(t *testing.T) { - testAvatarURL := "/img/some-other-avatar.webp" - if err := service.ChangeAvatarURL(id, testAvatarURL); err != nil { - t.Errorf("Failed to change avatar URL: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else if !account.AvatarURL.Valid || account.AvatarURL.String != testAvatarURL { - t.Error("Avatar URL did not update") - } - }) - - t.Run("can remove avatar URL", func(t *testing.T) { - if err := service.ChangeAvatarURL(id, ""); err != nil { - t.Errorf("Failed to change avatar URL: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else if account.AvatarURL.Valid || len(account.AvatarURL.String) > 0 { - t.Error("Avatar URL did not update") - } - }) - - t.Run("can increment auth failures", func(t *testing.T) { - if num, err := service.IncrementFails(id); err != nil { - t.Errorf("Failed to increment account auth failures: %v", err) - } else { - assert.Equal(t, num, 1) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else { - assert.Equal(t, account.FailAttempts, 1) - } - }) - - t.Run("can reset auth failures", func(t *testing.T) { - if err := service.ResetFails(id); err != nil { - t.Errorf("Failed to reset account auth failures: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else { - assert.Equal(t, account.FailAttempts, 0) - } - }) - - t.Run("can lock account", func(t *testing.T) { - if err := service.Lock(id); err != nil { - t.Errorf("Failed to lock account: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else { - assert.Equal(t, account.Locked, true) - } - }) - - t.Run("can unlock account", func(t *testing.T) { - if err := service.Unlock(id); err != nil { - t.Errorf("Failed to unlock account: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account: %v", err) - } else if account == nil { - t.Error("Account is nil after update") - } else { - assert.Equal(t, account.Locked, false) - } - }) - - t.Run("can delete account", func(t *testing.T) { - if err = service.Delete(id); err != nil { - t.Errorf("Failed to delete account: %v", err) - } - - if account, err := service.GetByID(id); err != nil { - t.Errorf("Failed to get account after deletion: %v", err) - } else if account != nil { - t.Error("Account still exists after deletion") - } - }) - - t.Run("can't create an account with invalid", func(t *testing.T) { - t.Run("username", func(t *testing.T) { - if _, err := service.Create("", password, &email, &avatarURL); err == nil { - t.Error("Could create account with empty username") - } - }) - - t.Run("password", func(t *testing.T) { - if _, err := service.Create(username, "", &email, &avatarURL); err == nil { - t.Error("Could create account with empty password") - } - }) - - t.Run("email", func(t *testing.T) { - testEmail := "" - if _, err := service.Create(username, password, &testEmail, &avatarURL); err == nil { - t.Error("Could create account with empty (non-nil) email") - } - }) - }) - - t.Run("can't update account that doesn't exist", func(t *testing.T) { - garbageAccountID := "adsginh534g9405gmb40i9bm" - - t.Run("username", func(t *testing.T) { - if err := service.ChangeUsername(garbageAccountID, "some-username"); err == nil { - t.Error("Could update non-existent account's username") - } - }) - - t.Run("password", func(t *testing.T) { - if err := service.ChangePassword(garbageAccountID, "some-password"); err == nil { - t.Error("Could update non-existent account's password") - } - }) - - t.Run("email", func(t *testing.T) { - if err := service.ChangeEmail(garbageAccountID, "some-email@real.gov"); err == nil { - t.Error("Could update non-existent account's email") - } - }) - - t.Run("avatar URL", func(t *testing.T) { - if err := service.ChangeAvatarURL(garbageAccountID, "/img/null.webp"); err == nil { - t.Error("Could update non-existent account's avatar URL") - } - }) - }) - - t.Cleanup(func() { - if err := service.Delete(id); err != nil { - t.Errorf("Failed to clean up test case: %v", err) - } - }) -}