add in-memory DB for accounts, with tests!

This commit is contained in:
ari melody 2026-07-31 04:20:39 +01:00
parent 49e14b5bc5
commit 5a540184c9
Signed by: ari
GPG key ID: CF99829C92678188
9 changed files with 534 additions and 45 deletions

2
go.mod
View file

@ -10,7 +10,9 @@ 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
)

4
go.sum
View file

@ -2,6 +2,8 @@ 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=
@ -16,3 +18,5 @@ 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=

10
main.go
View file

@ -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() {
logger := log.New(os.Stderr, "main", DEFAULT_LOG_FLAGS)
// TODO: switch to a new logger. this one kinda sucks
logger := log.New(os.Stderr, "main", model.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", DEFAULT_LOG_FLAGS),
log.New(os.Stderr, "logger", model.DEFAULT_LOG_FLAGS),
)
accountRepo := accountRepo.NewAccountRepositoryPostgres(psqlDB)
app.AccountService = accountService.NewAccountService(
accountRepo,
log.New(os.Stderr, "account-repo", DEFAULT_LOG_FLAGS),
log.New(os.Stderr, "account-repo", model.DEFAULT_LOG_FLAGS),
)
// handle command arguments
@ -494,7 +494,7 @@ func main() {
go cursor.StartCursor(&app)
httpLogger := log.New(os.Stderr, "http", DEFAULT_LOG_FLAGS)
httpLogger := log.New(os.Stderr, "http", model.DEFAULT_LOG_FLAGS)
// start the web server!
mux := createServeMux(&app)

View file

@ -1,6 +1,9 @@
package model
import "time"
import (
"log"
"time"
)
type (
LogLevel int
@ -15,6 +18,8 @@ type (
)
const (
DEFAULT_LOG_FLAGS = log.Ldate | log.Ltime | log.Lmicroseconds
LOG_ACCOUNT string = "account"
LOG_MUSIC string = "music"
LOG_ARTIST string = "artist"

View file

@ -3,12 +3,15 @@ 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)
GetBySession(sessionToken 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)
// Create an account, returning the new account ID.
Create(username string, password string, email *string, avatarURL *string) (string, error)
@ -23,11 +26,12 @@ type AccountRepository interface {
ChangeAvatarURL(id string, avatarURL string) error
RemoveAvatar(id string) error
Delete(accountID string) error
Delete(id string) error
// Increment the number of account login failure attempts,
// returning the current fail count.
IncrementFails(accountID string) (int, error)
Lock(accountID string) error
Unlock(accountID string) error
IncrementFails(id string) (int, error)
ResetFails(id string) error
Lock(id string) error
Unlock(id string) error
}

View file

@ -0,0 +1,185 @@
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 }
account.Username = username
return nil
}
func (repo *AccountRepositoryMemory) ChangePassword(id string, password string) error {
account, err := repo.GetByID(id)
if err != nil { return err }
account.Password = password
return nil
}
func (repo *AccountRepositoryMemory) ChangeEmail(id string, email string) error {
account, err := repo.GetByID(id)
if err != nil { return err }
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 }
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 }
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 }
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
}

View file

@ -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,22 +79,6 @@ 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,
@ -175,25 +159,30 @@ func (repo *AccountRepositoryPostgres) RemoveAvatar(id string) error {
return err
}
func (repo *AccountRepositoryPostgres) Delete(accountID string) error {
_, err := repo.db.Exec("DELETE FROM account WHERE id=$1", accountID)
func (repo *AccountRepositoryPostgres) Delete(id string) error {
_, err := repo.db.Exec("DELETE FROM account WHERE id=$1", id)
return err
}
// Increment the number of account login failure attempts,
// returning the current fail count.
func (repo *AccountRepositoryPostgres) IncrementFails(accountID string) (int, error) {
func (repo *AccountRepositoryPostgres) IncrementFails(id string) (int, error) {
failAttempts := 0
err := repo.db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
err := repo.db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", id)
return failAttempts, err
}
func (repo *AccountRepositoryPostgres) Lock(accountID string) error {
_, err := repo.db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
func (repo *AccountRepositoryPostgres) ResetFails(id string) error {
_, err := repo.db.Exec("UPDATE account SET fail_attempts = 0 WHERE id=$1", id)
return err
}
func (repo *AccountRepositoryPostgres) Unlock(accountID string) error {
_, err := repo.db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
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)
return err
}

View file

@ -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,12 +39,6 @@ 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,
@ -131,6 +125,10 @@ 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)
}

View file

@ -0,0 +1,302 @@
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("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("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.Cleanup(func() {
if err := service.Delete(id); err != nil {
t.Errorf("Failed to clean up test case: %v", err)
}
})
}