Manage users with server CLI

This commit is contained in:
Sung 2025-10-19 09:34:07 -07:00
commit eb60c5e6a9
15 changed files with 901 additions and 210 deletions

2
.gitignore vendored
View file

@ -6,4 +6,4 @@ node_modules
/test
tmp
*.db
server
/server

View file

@ -181,3 +181,120 @@ func TestServerUnknownCommand(t *testing.T) {
assert.Equal(t, strings.Contains(outputStr, "Unknown command"), true, "output should contain unknown command message")
assert.Equal(t, strings.Contains(outputStr, "Dnote server - a simple command line notebook"), true, "output should show help")
}
func TestServerUserCreate(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
cmd := exec.Command(testServerBinary, "user", "create",
"--dbPath", tmpDB,
"--email", "test@example.com",
"--password", "password123")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("user create failed: %v\nOutput: %s", err, output)
}
outputStr := string(output)
assert.Equal(t, strings.Contains(outputStr, "User created successfully"), true, "output should show success message")
assert.Equal(t, strings.Contains(outputStr, "test@example.com"), true, "output should show email")
// Verify user exists in database
db, err := gorm.Open(sqlite.Open(tmpDB), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
var count int64
db.Table("users").Count(&count)
assert.Equal(t, count, int64(1), "should have created 1 user")
}
func TestServerUserCreateShortPassword(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
cmd := exec.Command(testServerBinary, "user", "create",
"--dbPath", tmpDB,
"--email", "test@example.com",
"--password", "short")
output, err := cmd.CombinedOutput()
// Should fail with short password
if err == nil {
t.Fatal("expected command to fail with short password")
}
outputStr := string(output)
assert.Equal(t, strings.Contains(outputStr, "password should be longer than 8 characters"), true, "output should show password error")
}
func TestServerUserResetPassword(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
// Create user first
createCmd := exec.Command(testServerBinary, "user", "create",
"--dbPath", tmpDB,
"--email", "test@example.com",
"--password", "oldpassword123")
if output, err := createCmd.CombinedOutput(); err != nil {
t.Fatalf("failed to create user: %v\nOutput: %s", err, output)
}
// Reset password
resetCmd := exec.Command(testServerBinary, "user", "reset-password",
"--dbPath", tmpDB,
"--email", "test@example.com",
"--password", "newpassword123")
output, err := resetCmd.CombinedOutput()
if err != nil {
t.Fatalf("reset-password failed: %v\nOutput: %s", err, output)
}
outputStr := string(output)
assert.Equal(t, strings.Contains(outputStr, "Password reset successfully"), true, "output should show success message")
}
func TestServerUserRemove(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
// Create user first
createCmd := exec.Command(testServerBinary, "user", "create",
"--dbPath", tmpDB,
"--email", "test@example.com",
"--password", "password123")
if output, err := createCmd.CombinedOutput(); err != nil {
t.Fatalf("failed to create user: %v\nOutput: %s", err, output)
}
// Remove user
removeCmd := exec.Command(testServerBinary, "user", "remove",
"--dbPath", tmpDB,
"--email", "test@example.com")
output, err := removeCmd.CombinedOutput()
if err != nil {
t.Fatalf("user remove failed: %v\nOutput: %s", err, output)
}
outputStr := string(output)
assert.Equal(t, strings.Contains(outputStr, "User removed successfully"), true, "output should show success message")
// Verify user was removed
db, err := gorm.Open(sqlite.Open(tmpDB), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
var count int64
db.Table("users").Count(&count)
assert.Equal(t, count, int64(0), "should have 0 users after removal")
}

View file

@ -36,7 +36,7 @@ func NewTest() App {
WebURL: "http://127.0.0.0.1",
Port: "3000",
DisableRegistration: false,
DBPath: ":memory:",
DBPath: "",
AssetBaseURL: "",
}
}

View file

@ -29,6 +29,15 @@ import (
"gorm.io/gorm"
)
// ValidatePassword validates a password
func ValidatePassword(password string) error {
if len(password) < 8 {
return ErrPasswordTooShort
}
return nil
}
// TouchLastLoginAt updates the last login timestamp
func (a *App) TouchLastLoginAt(user database.User, tx *gorm.DB) error {
t := a.Clock.Now()
@ -45,8 +54,8 @@ func (a *App) CreateUser(email, password string, passwordConfirmation string) (d
return database.User{}, ErrEmailRequired
}
if len(password) < 8 {
return database.User{}, ErrPasswordTooShort
if err := ValidatePassword(password); err != nil {
return database.User{}, err
}
if password != passwordConfirmation {
@ -102,8 +111,8 @@ func (a *App) CreateUser(email, password string, passwordConfirmation string) (d
return user, nil
}
// Authenticate authenticates a user
func (a *App) Authenticate(email, password string) (*database.User, error) {
// GetAccountByEmail finds an account by email
func (a *App) GetAccountByEmail(email string) (*database.Account, error) {
var account database.Account
err := a.DB.Where("email = ?", email).First(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
@ -112,6 +121,16 @@ func (a *App) Authenticate(email, password string) (*database.User, error) {
return nil, err
}
return &account, nil
}
// Authenticate authenticates a user
func (a *App) Authenticate(email, password string) (*database.User, error) {
account, err := a.GetAccountByEmail(email)
if err != nil {
return nil, err
}
err = bcrypt.CompareHashAndPassword([]byte(account.Password.String), []byte(password))
if err != nil {
return nil, ErrLoginInvalid

View file

@ -28,6 +28,42 @@ import (
"golang.org/x/crypto/bcrypt"
)
func TestValidatePassword(t *testing.T) {
testCases := []struct {
name string
password string
wantErr error
}{
{
name: "valid password",
password: "password123",
wantErr: nil,
},
{
name: "valid password exactly 8 chars",
password: "12345678",
wantErr: nil,
},
{
name: "password too short",
password: "1234567",
wantErr: ErrPasswordTooShort,
},
{
name: "empty password",
password: "",
wantErr: ErrPasswordTooShort,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := ValidatePassword(tc.password)
assert.Equal(t, err, tc.wantErr, "error mismatch")
})
}
}
func TestCreateUser_ProValue(t *testing.T) {
db := testutils.InitMemoryDB(t)

62
pkg/server/cmd/helpers.go Normal file
View file

@ -0,0 +1,62 @@
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
import (
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/config"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"gorm.io/gorm"
)
func initDB(dbPath string) *gorm.DB {
db := database.Open(dbPath)
database.InitSchema(db)
database.Migrate(db)
return db
}
func initApp(cfg config.Config) app.App {
db := initDB(cfg.DBPath)
emailBackend, err := mailer.NewDefaultBackend(cfg.IsProd())
if err != nil {
emailBackend = &mailer.DefaultBackend{Enabled: false}
} else {
log.Info("Email backend configured")
}
return app.App{
DB: db,
Clock: clock.New(),
EmailTemplates: mailer.NewTemplates(),
EmailBackend: emailBackend,
HTTP500Page: cfg.HTTP500Page,
AppEnv: cfg.AppEnv,
WebURL: cfg.WebURL,
DisableRegistration: cfg.DisableRegistration,
Port: cfg.Port,
DBPath: cfg.DBPath,
AssetBaseURL: cfg.AssetBaseURL,
}
}

60
pkg/server/cmd/root.go Normal file
View file

@ -0,0 +1,60 @@
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
import (
"fmt"
"os"
)
func rootCmd() {
fmt.Printf(`Dnote server - a simple command line notebook
Usage:
dnote-server [command] [flags]
Available commands:
start: Start the server (use 'dnote-server start --help' for flags)
user: Manage users (use 'dnote-server user' for subcommands)
version: Print the version
`)
}
// Execute is the main entry point for the CLI
func Execute() {
if len(os.Args) < 2 {
rootCmd()
return
}
cmd := os.Args[1]
switch cmd {
case "start":
startCmd(os.Args[2:])
case "user":
userCmd(os.Args[2:])
case "version":
versionCmd()
default:
fmt.Printf("Unknown command %s\n", cmd)
rootCmd()
os.Exit(1)
}
}

100
pkg/server/cmd/start.go Normal file
View file

@ -0,0 +1,100 @@
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
import (
"flag"
"fmt"
"net/http"
"os"
"github.com/dnote/dnote/pkg/server/buildinfo"
"github.com/dnote/dnote/pkg/server/config"
"github.com/dnote/dnote/pkg/server/controllers"
"github.com/dnote/dnote/pkg/server/log"
"github.com/pkg/errors"
)
func startCmd(args []string) {
startFlags := flag.NewFlagSet("start", flag.ExitOnError)
startFlags.Usage = func() {
fmt.Printf(`Usage:
dnote-server start [flags]
Flags:
`)
startFlags.PrintDefaults()
}
appEnv := startFlags.String("appEnv", "", "Application environment (env: APP_ENV, default: PRODUCTION)")
port := startFlags.String("port", "", "Server port (env: PORT, default: 3001)")
webURL := startFlags.String("webUrl", "", "Full URL to server without trailing slash (env: WebURL, default: http://localhost:3001)")
dbPath := startFlags.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
disableRegistration := startFlags.Bool("disableRegistration", false, "Disable user registration (env: DisableRegistration, default: false)")
logLevel := startFlags.String("logLevel", "", "Log level: debug, info, warn, or error (env: LOG_LEVEL, default: info)")
startFlags.Parse(args)
cfg, err := config.New(config.Params{
AppEnv: *appEnv,
Port: *port,
WebURL: *webURL,
DBPath: *dbPath,
DisableRegistration: *disableRegistration,
LogLevel: *logLevel,
})
if err != nil {
fmt.Printf("Error: %s\n\n", err)
startFlags.Usage()
os.Exit(1)
}
// Set log level
log.SetLevel(cfg.LogLevel)
app := initApp(cfg)
defer func() {
sqlDB, err := app.DB.DB()
if err == nil {
sqlDB.Close()
}
}()
ctl := controllers.New(&app)
rc := controllers.RouteConfig{
WebRoutes: controllers.NewWebRoutes(&app, ctl),
APIRoutes: controllers.NewAPIRoutes(&app, ctl),
Controllers: ctl,
}
r, err := controllers.NewRouter(&app, rc)
if err != nil {
panic(errors.Wrap(err, "initializing router"))
}
log.WithFields(log.Fields{
"version": buildinfo.Version,
"port": cfg.Port,
}).Info("Dnote server starting")
if err := http.ListenAndServe(fmt.Sprintf(":%s", cfg.Port), r); err != nil {
log.ErrorWrap(err, "server failed")
os.Exit(1)
}
}

262
pkg/server/cmd/user.go Normal file
View file

@ -0,0 +1,262 @@
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
import (
"flag"
"fmt"
"os"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/config"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/log"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
// Helper functions for user commands
// setupUserFlags creates a FlagSet with standard usage format
func setupUserFlags(name, usageCmd string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ExitOnError)
fs.Usage = func() {
fmt.Printf(`Usage:
%s [flags]
Flags:
`, usageCmd)
fs.PrintDefaults()
}
return fs
}
// requireString validates that a required string flag is not empty
func requireString(fs *flag.FlagSet, value, fieldName string) {
if value == "" {
fmt.Printf("Error: %s is required\n", fieldName)
fs.Usage()
os.Exit(1)
}
}
// setupAppWithDB creates config, initializes app, and returns cleanup function
func setupAppWithDB(fs *flag.FlagSet, dbPath string) (*app.App, func()) {
cfg, err := config.New(config.Params{
DBPath: dbPath,
})
if err != nil {
fmt.Printf("Error: %s\n\n", err)
fs.Usage()
os.Exit(1)
}
a := initApp(cfg)
cleanup := func() {
sqlDB, err := a.DB.DB()
if err == nil {
sqlDB.Close()
}
}
return &a, cleanup
}
func userCreateCmd(args []string) {
fs := setupUserFlags("create", "dnote-server user create")
email := fs.String("email", "", "User email address (required)")
password := fs.String("password", "", "User password (required)")
dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
fs.Parse(args)
requireString(fs, *email, "email")
requireString(fs, *password, "password")
a, cleanup := setupAppWithDB(fs, *dbPath)
defer cleanup()
_, err := a.CreateUser(*email, *password, *password)
if err != nil {
log.ErrorWrap(err, "creating user")
os.Exit(1)
}
fmt.Printf("User created successfully\n")
fmt.Printf("Email: %s\n", *email)
}
func userRemoveCmd(args []string) {
fs := setupUserFlags("remove", "dnote-server user remove")
email := fs.String("email", "", "User email address (required)")
dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
fs.Parse(args)
requireString(fs, *email, "email")
a, cleanup := setupAppWithDB(fs, *dbPath)
defer cleanup()
// Find the account and user
account, err := a.GetAccountByEmail(*email)
if err != nil {
if errors.Is(err, app.ErrNotFound) {
fmt.Printf("Error: user with email %s not found\n", *email)
} else {
log.ErrorWrap(err, "finding account")
}
os.Exit(1)
}
// Check if user has any notes
var noteCount int64
if err := a.DB.Model(&database.Note{}).Where("user_id = ? AND deleted = ?", account.UserID, false).Count(&noteCount).Error; err != nil {
log.ErrorWrap(err, "counting notes")
os.Exit(1)
}
if noteCount > 0 {
fmt.Printf("Error: cannot remove user with existing notes (found %d notes)\n", noteCount)
os.Exit(1)
}
// Check if user has any books
var bookCount int64
if err := a.DB.Model(&database.Book{}).Where("user_id = ? AND deleted = ?", account.UserID, false).Count(&bookCount).Error; err != nil {
log.ErrorWrap(err, "counting books")
os.Exit(1)
}
if bookCount > 0 {
fmt.Printf("Error: cannot remove user with existing books (found %d books)\n", bookCount)
os.Exit(1)
}
// Delete account and user
tx := a.DB.Begin()
if err := tx.Delete(&account).Error; err != nil {
tx.Rollback()
log.ErrorWrap(err, "deleting account")
os.Exit(1)
}
var user database.User
if err := tx.Where("id = ?", account.UserID).First(&user).Error; err != nil {
tx.Rollback()
log.ErrorWrap(err, "finding user")
os.Exit(1)
}
if err := tx.Delete(&user).Error; err != nil {
tx.Rollback()
log.ErrorWrap(err, "deleting user")
os.Exit(1)
}
tx.Commit()
fmt.Printf("User removed successfully\n")
fmt.Printf("Email: %s\n", *email)
}
func userResetPasswordCmd(args []string) {
fs := setupUserFlags("reset-password", "dnote-server user reset-password")
email := fs.String("email", "", "User email address (required)")
password := fs.String("password", "", "New password (required)")
dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
fs.Parse(args)
requireString(fs, *email, "email")
requireString(fs, *password, "password")
a, cleanup := setupAppWithDB(fs, *dbPath)
defer cleanup()
// Validate password
if err := app.ValidatePassword(*password); err != nil {
fmt.Printf("Error: %s\n", err)
os.Exit(1)
}
// Find the account
account, err := a.GetAccountByEmail(*email)
if err != nil {
if errors.Is(err, app.ErrNotFound) {
fmt.Printf("Error: user with email %s not found\n", *email)
} else {
log.ErrorWrap(err, "finding account")
}
os.Exit(1)
}
// Hash the new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*password), bcrypt.DefaultCost)
if err != nil {
log.ErrorWrap(err, "hashing password")
os.Exit(1)
}
// Update the password
if err := a.DB.Model(&account).Update("password", string(hashedPassword)).Error; err != nil {
log.ErrorWrap(err, "updating password")
os.Exit(1)
}
fmt.Printf("Password reset successfully\n")
fmt.Printf("Email: %s\n", *email)
}
func userCmd(args []string) {
if len(args) < 1 {
fmt.Println(`Usage:
dnote-server user [command]
Available commands:
create: Create a new user
remove: Remove a user (only if they have no notes or books)
reset-password: Reset a user's password`)
os.Exit(1)
}
subcommand := args[0]
subArgs := []string{}
if len(args) > 1 {
subArgs = args[1:]
}
switch subcommand {
case "create":
userCreateCmd(subArgs)
case "remove":
userRemoveCmd(subArgs)
case "reset-password":
userResetPasswordCmd(subArgs)
default:
fmt.Printf("Unknown subcommand: %s\n\n", subcommand)
fmt.Println(`Available commands:
create: Create a new user
remove: Remove a user (only if they have no notes or books)
reset-password: Reset a user's password`)
os.Exit(1)
}
}

112
pkg/server/cmd/user_test.go Normal file
View file

@ -0,0 +1,112 @@
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
import (
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/testutils"
"golang.org/x/crypto/bcrypt"
)
func TestUserCreateCmd(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
// Call the function directly
userCreateCmd([]string{"--dbPath", tmpDB, "--email", "test@example.com", "--password", "password123"})
// Verify user was created in database
db := testutils.InitDB(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
var count int64
testutils.MustExec(t, db.Model(&database.User{}).Count(&count), "counting users")
assert.Equal(t, count, int64(1), "should have 1 user")
var account database.Account
testutils.MustExec(t, db.Where("email = ?", "test@example.com").First(&account), "finding account")
assert.Equal(t, account.Email.String, "test@example.com", "email mismatch")
}
func TestUserRemoveCmd(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
// Create a user first
db := testutils.InitDB(tmpDB)
user := testutils.SetupUserData(db)
testutils.SetupAccountData(db, user, "test@example.com", "password123")
sqlDB, _ := db.DB()
sqlDB.Close()
// Remove the user
userRemoveCmd([]string{"--dbPath", tmpDB, "--email", "test@example.com"})
// Verify user was removed
db2 := testutils.InitDB(tmpDB)
defer func() {
sqlDB2, _ := db2.DB()
sqlDB2.Close()
}()
var count int64
testutils.MustExec(t, db2.Model(&database.User{}).Count(&count), "counting users")
assert.Equal(t, count, int64(0), "should have 0 users")
}
func TestUserResetPasswordCmd(t *testing.T) {
tmpDB := t.TempDir() + "/test.db"
// Create a user first
db := testutils.InitDB(tmpDB)
user := testutils.SetupUserData(db)
account := testutils.SetupAccountData(db, user, "test@example.com", "oldpassword123")
oldPasswordHash := account.Password.String
sqlDB, _ := db.DB()
sqlDB.Close()
// Reset password
userResetPasswordCmd([]string{"--dbPath", tmpDB, "--email", "test@example.com", "--password", "newpassword123"})
// Verify password was changed
db2 := testutils.InitDB(tmpDB)
defer func() {
sqlDB2, _ := db2.DB()
sqlDB2.Close()
}()
var updatedAccount database.Account
testutils.MustExec(t, db2.Where("email = ?", "test@example.com").First(&updatedAccount), "finding account")
// Verify password hash changed
assert.Equal(t, updatedAccount.Password.String != oldPasswordHash, true, "password hash should be different")
assert.Equal(t, len(updatedAccount.Password.String) > 0, true, "password should be set")
// Verify new password works
err := bcrypt.CompareHashAndPassword([]byte(updatedAccount.Password.String), []byte("newpassword123"))
assert.Equal(t, err, nil, "new password should match")
// Verify old password doesn't work
err = bcrypt.CompareHashAndPassword([]byte(updatedAccount.Password.String), []byte("oldpassword123"))
assert.Equal(t, err != nil, true, "old password should not match")
}

29
pkg/server/cmd/version.go Normal file
View file

@ -0,0 +1,29 @@
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
import (
"fmt"
"github.com/dnote/dnote/pkg/server/buildinfo"
)
func versionCmd() {
fmt.Printf("dnote-server-%s\n", buildinfo.Version)
}

View file

@ -514,7 +514,7 @@ func (u *Users) PasswordUpdate(w http.ResponseWriter, r *http.Request) {
return
}
if err := validatePassword(form.NewPassword); err != nil {
if err := app.ValidatePassword(form.NewPassword); err != nil {
handleHTMLError(w, r, err, "invalid password", u.SettingView, vd)
return
}
@ -537,14 +537,6 @@ func (u *Users) PasswordUpdate(w http.ResponseWriter, r *http.Request) {
views.RedirectAlert(w, r, "/", http.StatusFound, alert)
}
func validatePassword(password string) error {
if len(password) < 8 {
return app.ErrPasswordTooShort
}
return nil
}
type updateProfileForm struct {
Email string `schema:"email"`
Password string `schema:"password"`

View file

@ -22,9 +22,6 @@ import (
"io/fs"
"testing"
"testing/fstest"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// unsortedFS wraps fstest.MapFS to return entries in reverse order
@ -56,10 +53,12 @@ func (e errorFS) ReadDir(name string) ([]fs.DirEntry, error) {
}
func TestMigrate_createsSchemaTable(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
migrationsFs := fstest.MapFS{}
migrate(db, migrationsFs)
@ -72,10 +71,12 @@ func TestMigrate_createsSchemaTable(t *testing.T) {
}
func TestMigrate_idempotency(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Set up table before migration
if err := db.Exec("CREATE TABLE counter (value INTEGER)").Error; err != nil {
@ -114,10 +115,12 @@ func TestMigrate_idempotency(t *testing.T) {
}
func TestMigrate_ordering(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Create table before migrations
if err := db.Exec("CREATE TABLE log (value INTEGER)").Error; err != nil {
@ -163,10 +166,12 @@ func TestMigrate_ordering(t *testing.T) {
}
func TestMigrate_duplicateVersion(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Create migrations with duplicate version numbers
migrationsFs := fstest.MapFS{
@ -179,17 +184,15 @@ func TestMigrate_duplicateVersion(t *testing.T) {
}
// Should return error for duplicate version
err = migrate(db, migrationsFs)
err := migrate(db, migrationsFs)
if err == nil {
t.Fatal("expected error for duplicate version numbers, got nil")
}
}
func TestMigrate_initTableError(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
// Close the database connection to cause exec to fail
sqlDB, _ := db.DB()
@ -202,30 +205,34 @@ func TestMigrate_initTableError(t *testing.T) {
}
// Should return error for table initialization failure
err = migrate(db, migrationsFs)
err := migrate(db, migrationsFs)
if err == nil {
t.Fatal("expected error for table initialization failure, got nil")
}
}
func TestMigrate_readDirError(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Use filesystem that fails on ReadDir
err = migrate(db, errorFS{})
err := migrate(db, errorFS{})
if err == nil {
t.Fatal("expected error for ReadDir failure, got nil")
}
}
func TestMigrate_sqlError(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
// Create migration with invalid SQL
migrationsFs := fstest.MapFS{
@ -235,19 +242,21 @@ func TestMigrate_sqlError(t *testing.T) {
}
// Should return error for SQL execution failure
err = migrate(db, migrationsFs)
err := migrate(db, migrationsFs)
if err == nil {
t.Fatal("expected error for invalid SQL, got nil")
}
}
func TestMigrate_emptyFile(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
tests := []struct {
tests := []struct{
name string
data string
wantErr bool
@ -265,7 +274,7 @@ func TestMigrate_emptyFile(t *testing.T) {
},
}
err = migrate(db, migrationsFs)
err := migrate(db, migrationsFs)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
}
@ -274,10 +283,12 @@ func TestMigrate_emptyFile(t *testing.T) {
}
func TestMigrate_invalidFilename(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
tmpDB := t.TempDir() + "/test.db"
db := Open(tmpDB)
defer func() {
sqlDB, _ := db.DB()
sqlDB.Close()
}()
tests := []struct {
name string

View file

@ -0,0 +1,38 @@
/* Copyright (C) 2019, 2020, 2021, 2022, 2023, 2024, 2025 Dnote contributors
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package log
import (
"testing"
)
func TestSetLevel(t *testing.T) {
// Reset to default after test
defer SetLevel(LevelInfo)
SetLevel(LevelDebug)
if currentLevel != LevelDebug {
t.Errorf("Expected level %s, got %s", LevelDebug, currentLevel)
}
SetLevel(LevelError)
if currentLevel != LevelError {
t.Errorf("Expected level %s, got %s", LevelError, currentLevel)
}
}

View file

@ -19,156 +19,9 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/buildinfo"
"github.com/dnote/dnote/pkg/server/config"
"github.com/dnote/dnote/pkg/server/controllers"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/dnote/dnote/pkg/server/cmd"
)
func initDB(dbPath string) *gorm.DB {
db := database.Open(dbPath)
database.InitSchema(db)
database.Migrate(db)
return db
}
func initApp(cfg config.Config) app.App {
db := initDB(cfg.DBPath)
emailBackend, err := mailer.NewDefaultBackend(cfg.IsProd())
if err != nil {
emailBackend = &mailer.DefaultBackend{Enabled: false}
} else {
log.Info("Email backend configured")
}
return app.App{
DB: db,
Clock: clock.New(),
EmailTemplates: mailer.NewTemplates(),
EmailBackend: emailBackend,
HTTP500Page: cfg.HTTP500Page,
AppEnv: cfg.AppEnv,
WebURL: cfg.WebURL,
DisableRegistration: cfg.DisableRegistration,
Port: cfg.Port,
DBPath: cfg.DBPath,
AssetBaseURL: cfg.AssetBaseURL,
}
}
func startCmd(args []string) {
startFlags := flag.NewFlagSet("start", flag.ExitOnError)
startFlags.Usage = func() {
fmt.Printf(`Usage:
dnote-server start [flags]
Flags:
`)
startFlags.PrintDefaults()
}
appEnv := startFlags.String("appEnv", "", "Application environment (env: APP_ENV, default: PRODUCTION)")
port := startFlags.String("port", "", "Server port (env: PORT, default: 3001)")
webURL := startFlags.String("webUrl", "", "Full URL to server without trailing slash (env: WebURL, default: http://localhost:3001)")
dbPath := startFlags.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
disableRegistration := startFlags.Bool("disableRegistration", false, "Disable user registration (env: DisableRegistration, default: false)")
logLevel := startFlags.String("logLevel", "", "Log level: debug, info, warn, or error (env: LOG_LEVEL, default: info)")
startFlags.Parse(args)
cfg, err := config.New(config.Params{
AppEnv: *appEnv,
Port: *port,
WebURL: *webURL,
DBPath: *dbPath,
DisableRegistration: *disableRegistration,
LogLevel: *logLevel,
})
if err != nil {
fmt.Printf("Error: %s\n\n", err)
startFlags.Usage()
os.Exit(1)
}
// Set log level
log.SetLevel(cfg.LogLevel)
app := initApp(cfg)
defer func() {
sqlDB, err := app.DB.DB()
if err == nil {
sqlDB.Close()
}
}()
ctl := controllers.New(&app)
rc := controllers.RouteConfig{
WebRoutes: controllers.NewWebRoutes(&app, ctl),
APIRoutes: controllers.NewAPIRoutes(&app, ctl),
Controllers: ctl,
}
r, err := controllers.NewRouter(&app, rc)
if err != nil {
panic(errors.Wrap(err, "initializing router"))
}
log.WithFields(log.Fields{
"version": buildinfo.Version,
"port": cfg.Port,
}).Info("Dnote server starting")
if err := http.ListenAndServe(fmt.Sprintf(":%s", cfg.Port), r); err != nil {
log.ErrorWrap(err, "server failed")
os.Exit(1)
}
}
func versionCmd() {
fmt.Printf("dnote-server-%s\n", buildinfo.Version)
}
func rootCmd() {
fmt.Printf(`Dnote server - a simple command line notebook
Usage:
dnote-server [command] [flags]
Available commands:
start: Start the server (use 'dnote-server start --help' for flags)
version: Print the version
`)
}
func main() {
if len(os.Args) < 2 {
rootCmd()
return
}
cmd := os.Args[1]
switch cmd {
case "start":
startCmd(os.Args[2:])
case "version":
versionCmd()
default:
fmt.Printf("Unknown command %s\n", cmd)
rootCmd()
os.Exit(1)
}
cmd.Execute()
}