Add server user management commands

This commit is contained in:
2026-06-08 15:03:41 -05:00
parent bba767c821
commit 73f1ae63cb
6 changed files with 563 additions and 1 deletions
+5
View File
@@ -86,6 +86,11 @@ func usage() {
Commands: Commands:
gitocean init [--config storage/config.json] gitocean init [--config storage/config.json]
gitocean server [--config storage/config.json] [--dsn DSN] [--addr :8080] [--storage storage] gitocean server [--config storage/config.json] [--dsn DSN] [--addr :8080] [--storage storage]
gitocean server users --dsn DSN list
gitocean server users --dsn DSN view USER
gitocean server users --dsn DSN add --email EMAIL --username USER --password PASSWORD [--admin]
gitocean server users --dsn DSN edit USER [--email EMAIL] [--username USER] [--password PASSWORD] [--admin true|false]
gitocean server users --dsn DSN remove USER --force
gitocean register [--server URL] gitocean register [--server URL]
gitocean login [--server URL] gitocean login [--server URL]
gitocean logout gitocean logout
+3
View File
@@ -16,6 +16,9 @@ import (
// ---------------- Server boot and migrations ---------------- // ---------------- Server boot and migrations ----------------
func runServer(args []string) error { func runServer(args []string) error {
if len(args) > 0 && args[0] == "users" {
return cliServerUsers(args[1:])
}
fs := flag.NewFlagSet("server", flag.ExitOnError) fs := flag.NewFlagSet("server", flag.ExitOnError)
configFile := fs.String("config", defaultServerConfigPath(), "server config file") configFile := fs.String("config", defaultServerConfigPath(), "server config file")
addr := fs.String("addr", "", "HTTP listen address") addr := fs.String("addr", "", "HTTP listen address")
+355
View File
@@ -0,0 +1,355 @@
package app
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
type serverUserRecord struct {
ID int64
Email string
Username string
IsAdmin bool
CreatedAt time.Time
}
type serverUserEdit struct {
Email *string
Username *string
Password *string
Admin *bool
}
func cliServerUsers(args []string) error {
dsn, args, err := extractRequiredDatabaseDSN(args)
if err != nil {
return err
}
if len(args) < 1 {
return errors.New("usage: gitocean server users --dsn DSN <list|view|add|edit|remove>")
}
db, err := openMySQLAndCreateDatabaseIfMissing(dsn)
if err != nil {
return err
}
defer db.Close()
if err := migrate(db); err != nil {
return err
}
switch args[0] {
case "list":
return serverUsersList(db)
case "view":
if len(args) != 2 {
return errors.New("usage: gitocean server users --dsn DSN view USER")
}
return serverUsersView(db, args[1])
case "add":
email, username, password, admin, err := parseServerUsersAddArgs(args[1:])
if err != nil {
return err
}
return serverUsersAdd(db, email, username, password, admin)
case "edit":
ref, edit, err := parseServerUsersEditArgs(args[1:])
if err != nil {
return err
}
return serverUsersEditUser(db, ref, edit)
case "remove":
ref, err := parseServerUsersRemoveArgs(args[1:])
if err != nil {
return err
}
return serverUsersRemove(db, ref)
default:
return fmt.Errorf("unknown server users command %q", args[0])
}
}
func extractRequiredDatabaseDSN(args []string) (string, []string, error) {
out := make([]string, 0, len(args))
var dsn string
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--dsn" || arg == "-dsn" || arg == "--database-url":
if i+1 >= len(args) {
return "", nil, fmt.Errorf("%s requires a value", arg)
}
i++
dsn = args[i]
case strings.HasPrefix(arg, "--dsn="):
dsn = strings.TrimPrefix(arg, "--dsn=")
case strings.HasPrefix(arg, "-dsn="):
dsn = strings.TrimPrefix(arg, "-dsn=")
case strings.HasPrefix(arg, "--database-url="):
dsn = strings.TrimPrefix(arg, "--database-url=")
default:
out = append(out, arg)
}
}
if strings.TrimSpace(dsn) == "" {
return "", nil, errors.New("database URL is required; pass --dsn DSN")
}
return dsn, out, nil
}
func parseServerUsersAddArgs(args []string) (email, username, password string, admin bool, err error) {
for i := 0; i < len(args); i++ {
switch args[i] {
case "--email":
if i+1 >= len(args) {
return "", "", "", false, errors.New("--email requires a value")
}
i++
email = args[i]
case "--username":
if i+1 >= len(args) {
return "", "", "", false, errors.New("--username requires a value")
}
i++
username = args[i]
case "--password":
if i+1 >= len(args) {
return "", "", "", false, errors.New("--password requires a value")
}
i++
password = args[i]
case "--admin":
admin = true
default:
return "", "", "", false, fmt.Errorf("unknown flag %s", args[i])
}
}
if email == "" {
email = prompt("Email: ")
}
if username == "" {
username = prompt("Username: ")
}
if password == "" {
password = prompt("Password: ")
}
return email, username, password, admin, nil
}
func parseServerUsersEditArgs(args []string) (string, serverUserEdit, error) {
var ref string
var edit serverUserEdit
for i := 0; i < len(args); i++ {
switch args[i] {
case "--email":
if i+1 >= len(args) {
return "", edit, errors.New("--email requires a value")
}
i++
v := args[i]
edit.Email = &v
case "--username":
if i+1 >= len(args) {
return "", edit, errors.New("--username requires a value")
}
i++
v := args[i]
edit.Username = &v
case "--password":
if i+1 >= len(args) {
return "", edit, errors.New("--password requires a value")
}
i++
v := args[i]
edit.Password = &v
case "--admin":
if i+1 >= len(args) {
return "", edit, errors.New("--admin requires true or false")
}
i++
v, err := parseFlexibleBool(args[i])
if err != nil {
return "", edit, err
}
edit.Admin = &v
default:
if strings.HasPrefix(args[i], "-") {
return "", edit, fmt.Errorf("unknown flag %s", args[i])
}
if ref != "" {
return "", edit, errors.New("usage: gitocean server users --dsn DSN edit USER [--email EMAIL] [--username USERNAME] [--password PASSWORD] [--admin true|false]")
}
ref = args[i]
}
}
if ref == "" {
return "", edit, errors.New("usage: gitocean server users --dsn DSN edit USER [--email EMAIL] [--username USERNAME] [--password PASSWORD] [--admin true|false]")
}
if edit.Email == nil && edit.Username == nil && edit.Password == nil && edit.Admin == nil {
return "", edit, errors.New("no user changes provided")
}
return ref, edit, nil
}
func parseServerUsersRemoveArgs(args []string) (string, error) {
force := false
var ref string
for _, arg := range args {
switch arg {
case "--force", "-force":
force = true
default:
if strings.HasPrefix(arg, "-") {
return "", fmt.Errorf("unknown flag %s", arg)
}
if ref != "" {
return "", errors.New("usage: gitocean server users --dsn DSN remove USER --force")
}
ref = arg
}
}
if ref == "" || !force {
return "", errors.New("usage: gitocean server users --dsn DSN remove USER --force")
}
return ref, nil
}
func parseFlexibleBool(s string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "t", "yes", "y":
return true, nil
case "0", "false", "f", "no", "n":
return false, nil
default:
return false, fmt.Errorf("invalid boolean %q", s)
}
}
func serverUsersList(db *sql.DB) error {
rows, err := db.Query(`SELECT id, email, username, is_admin, created_at FROM users ORDER BY username`)
if err != nil {
return err
}
defer rows.Close()
fmt.Printf("%-6s %-24s %-34s %-7s %s\n", "ID", "USERNAME", "EMAIL", "ADMIN", "CREATED")
for rows.Next() {
var u serverUserRecord
if err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.IsAdmin, &u.CreatedAt); err != nil {
return err
}
printServerUserRow(u)
}
return rows.Err()
}
func serverUsersView(db *sql.DB, ref string) error {
u, err := loadServerUser(db, ref)
if err != nil {
return err
}
fmt.Printf("ID: %d\n", u.ID)
fmt.Printf("Username: %s\n", u.Username)
fmt.Printf("Email: %s\n", u.Email)
fmt.Printf("Admin: %v\n", u.IsAdmin)
fmt.Printf("Created: %s\n", u.CreatedAt.Format(time.RFC3339))
return nil
}
func serverUsersAdd(db *sql.DB, email, username, password string, admin bool) error {
email = strings.ToLower(strings.TrimSpace(email))
username = strings.ToLower(strings.TrimSpace(username))
if err := createUserDirect(db, email, username, password, admin); err != nil {
return err
}
fmt.Printf("Created user %s <%s> admin=%v\n", username, email, admin)
return nil
}
func serverUsersEditUser(db *sql.DB, ref string, edit serverUserEdit) error {
set := []string{}
args := []any{}
if edit.Email != nil {
email := strings.ToLower(strings.TrimSpace(*edit.Email))
if !strings.Contains(email, "@") || len(email) > 255 {
return errors.New("invalid email")
}
set = append(set, "email = ?")
args = append(args, email)
}
if edit.Username != nil {
username := strings.ToLower(strings.TrimSpace(*edit.Username))
if !usernameRE.MatchString(username) || isReservedName(username) {
return errors.New("invalid or reserved username")
}
set = append(set, "username = ?")
args = append(args, username)
}
if edit.Password != nil {
if len(*edit.Password) < 8 {
return errors.New("password must be at least 8 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(*edit.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
set = append(set, "password_hash = ?")
args = append(args, string(hash))
}
if edit.Admin != nil {
set = append(set, "is_admin = ?")
args = append(args, *edit.Admin)
}
where, whereArgs := serverUserWhere(ref)
args = append(args, whereArgs...)
res, err := db.Exec("UPDATE users SET "+strings.Join(set, ", ")+" WHERE "+where, args...)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
fmt.Printf("Updated user %s\n", ref)
return nil
}
func serverUsersRemove(db *sql.DB, ref string) error {
where, args := serverUserWhere(ref)
res, err := db.Exec("DELETE FROM users WHERE "+where, args...)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return sql.ErrNoRows
}
fmt.Printf("Removed user %s\n", ref)
return nil
}
func loadServerUser(db *sql.DB, ref string) (serverUserRecord, error) {
where, args := serverUserWhere(ref)
var u serverUserRecord
err := db.QueryRow(`SELECT id, email, username, is_admin, created_at FROM users WHERE `+where, args...).Scan(&u.ID, &u.Email, &u.Username, &u.IsAdmin, &u.CreatedAt)
return u, err
}
func serverUserWhere(ref string) (string, []any) {
ref = strings.TrimSpace(ref)
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
return "id = ?", []any{id}
}
ref = strings.ToLower(ref)
if strings.Contains(ref, "@") {
return "email = ?", []any{ref}
}
return "username = ?", []any{ref}
}
func printServerUserRow(u serverUserRecord) {
fmt.Printf("%-6d %-24s %-34s %-7v %s\n", u.ID, u.Username, u.Email, u.IsAdmin, u.CreatedAt.Format("2006-01-02 15:04"))
}
+195
View File
@@ -0,0 +1,195 @@
package app
import (
"database/sql"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestExtractRequiredDatabaseDSN(t *testing.T) {
dsn, args, err := extractRequiredDatabaseDSN([]string{"list", "--dsn", "db", "extra"})
if err != nil {
t.Fatal(err)
}
if dsn != "db" || strings.Join(args, ",") != "list,extra" {
t.Fatalf("dsn=%q args=%#v", dsn, args)
}
dsn, args, err = extractRequiredDatabaseDSN([]string{"--database-url=mysql", "view", "alice"})
if err != nil || dsn != "mysql" || len(args) != 2 || args[0] != "view" {
t.Fatalf("dsn=%q args=%#v err=%v", dsn, args, err)
}
if _, _, err := extractRequiredDatabaseDSN([]string{"list"}); err == nil || !strings.Contains(err.Error(), "database URL") {
t.Fatalf("expected missing dsn error, got %v", err)
}
if _, _, err := extractRequiredDatabaseDSN([]string{"--dsn"}); err == nil || !strings.Contains(err.Error(), "requires") {
t.Fatalf("expected missing dsn value error, got %v", err)
}
}
func TestParseServerUsersArgs(t *testing.T) {
email, username, password, admin, err := parseServerUsersAddArgs([]string{"--email", "a@example.com", "--username", "alice", "--password", "password123", "--admin"})
if err != nil {
t.Fatal(err)
}
if email != "a@example.com" || username != "alice" || password != "password123" || !admin {
t.Fatalf("unexpected add parse: %q %q %q %v", email, username, password, admin)
}
ref, edit, err := parseServerUsersEditArgs([]string{"alice", "--email", "new@example.com", "--username", "newalice", "--password", "password456", "--admin", "false"})
if err != nil {
t.Fatal(err)
}
if ref != "alice" || edit.Email == nil || *edit.Email != "new@example.com" || edit.Admin == nil || *edit.Admin {
t.Fatalf("unexpected edit parse: ref=%q edit=%#v", ref, edit)
}
if _, _, err := parseServerUsersEditArgs([]string{"alice"}); err == nil || !strings.Contains(err.Error(), "no user changes") {
t.Fatalf("expected no changes error, got %v", err)
}
ref, err = parseServerUsersRemoveArgs([]string{"alice", "--force"})
if err != nil || ref != "alice" {
t.Fatalf("remove parse ref=%q err=%v", ref, err)
}
if _, err := parseServerUsersRemoveArgs([]string{"alice"}); err == nil || !strings.Contains(err.Error(), "--force") {
t.Fatalf("expected force error, got %v", err)
}
}
func TestParseFlexibleBool(t *testing.T) {
for _, v := range []string{"true", "1", "yes", "y"} {
got, err := parseFlexibleBool(v)
if err != nil || !got {
t.Fatalf("%q => %v %v", v, got, err)
}
}
for _, v := range []string{"false", "0", "no", "n"} {
got, err := parseFlexibleBool(v)
if err != nil || got {
t.Fatalf("%q => %v %v", v, got, err)
}
}
if _, err := parseFlexibleBool("wat"); err == nil {
t.Fatal("expected invalid bool")
}
}
func TestServerUserWhere(t *testing.T) {
where, args := serverUserWhere("42")
if where != "id = ?" || args[0] != int64(42) {
t.Fatalf("id where=%q args=%#v", where, args)
}
where, args = serverUserWhere("Alice@Example.com")
if where != "email = ?" || args[0] != "alice@example.com" {
t.Fatalf("email where=%q args=%#v", where, args)
}
where, args = serverUserWhere("Alice")
if where != "username = ?" || args[0] != "alice" {
t.Fatalf("username where=%q args=%#v", where, args)
}
}
func TestServerUsersListViewAddEditRemove(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
now := time.Date(2026, 1, 2, 3, 4, 0, 0, time.UTC)
mock.ExpectQuery("SELECT id, email, username, is_admin, created_at FROM users ORDER BY username").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin", "created_at"}).AddRow(int64(1), "alice@example.com", "alice", true, now))
out := captureStdout(t, func() {
if err := serverUsersList(db); err != nil {
t.Fatal(err)
}
})
if !strings.Contains(out, "alice") || !strings.Contains(out, "ADMIN") {
t.Fatalf("unexpected list output: %s", out)
}
mock.ExpectQuery("SELECT id, email, username, is_admin, created_at FROM users WHERE username = ").WithArgs("alice").WillReturnRows(sqlmock.NewRows([]string{"id", "email", "username", "is_admin", "created_at"}).AddRow(int64(1), "alice@example.com", "alice", true, now))
out = captureStdout(t, func() {
if err := serverUsersView(db, "Alice"); err != nil {
t.Fatal(err)
}
})
if !strings.Contains(out, "Username: alice") || !strings.Contains(out, "Admin: true") {
t.Fatalf("unexpected view output: %s", out)
}
mock.ExpectExec("INSERT INTO users").WithArgs("bob@example.com", "bob", sqlmock.AnyArg(), false).WillReturnResult(sqlmock.NewResult(2, 1))
out = captureStdout(t, func() {
if err := serverUsersAdd(db, "Bob@Example.com", "Bob", "password123", false); err != nil {
t.Fatal(err)
}
})
if !strings.Contains(out, "Created user bob") {
t.Fatalf("unexpected add output: %s", out)
}
admin := false
email := "new@example.com"
edit := serverUserEdit{Email: &email, Admin: &admin}
mock.ExpectExec("UPDATE users SET email = \\?, is_admin = \\? WHERE username = ").WithArgs("new@example.com", false, "alice").WillReturnResult(sqlmock.NewResult(0, 1))
out = captureStdout(t, func() {
if err := serverUsersEditUser(db, "alice", edit); err != nil {
t.Fatal(err)
}
})
if !strings.Contains(out, "Updated user alice") {
t.Fatalf("unexpected edit output: %s", out)
}
mock.ExpectExec("DELETE FROM users WHERE username = ").WithArgs("alice").WillReturnResult(sqlmock.NewResult(0, 1))
out = captureStdout(t, func() {
if err := serverUsersRemove(db, "alice"); err != nil {
t.Fatal(err)
}
})
if !strings.Contains(out, "Removed user alice") {
t.Fatalf("unexpected remove output: %s", out)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestServerUsersEditValidationAndNotFound(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
badEmail := "bad"
if err := serverUsersEditUser(db, "alice", serverUserEdit{Email: &badEmail}); err == nil || !strings.Contains(err.Error(), "invalid email") {
t.Fatalf("expected invalid email, got %v", err)
}
shortPassword := "short"
if err := serverUsersEditUser(db, "alice", serverUserEdit{Password: &shortPassword}); err == nil || !strings.Contains(err.Error(), "password") {
t.Fatalf("expected password error, got %v", err)
}
admin := true
mock.ExpectExec("UPDATE users SET is_admin = ").WithArgs(true, "missing").WillReturnResult(sqlmock.NewResult(0, 0))
if err := serverUsersEditUser(db, "missing", serverUserEdit{Admin: &admin}); err != sql.ErrNoRows {
t.Fatalf("expected sql.ErrNoRows, got %v", err)
}
mock.ExpectExec("DELETE FROM users WHERE username = ").WithArgs("missing").WillReturnResult(sqlmock.NewResult(0, 0))
if err := serverUsersRemove(db, "missing"); err != sql.ErrNoRows {
t.Fatalf("expected sql.ErrNoRows remove, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestCliServerUsersRequiresDSN(t *testing.T) {
if err := cliServerUsers([]string{"list"}); err == nil || !strings.Contains(err.Error(), "database URL") {
t.Fatalf("expected required dsn, got %v", err)
}
if err := runServer([]string{"users", "list"}); err == nil || !strings.Contains(err.Error(), "database URL") {
t.Fatalf("expected runServer users dsn error, got %v", err)
}
if err := runServer([]string{"--config", filepath.Join(t.TempDir(), "missing.json")}); err == nil || !strings.Contains(err.Error(), "mysql_dsn") {
t.Fatalf("expected normal server dsn error, got %v", err)
}
}
+2 -1
View File
@@ -15,7 +15,7 @@ go test -cover ./...
Current default coverage snapshot after Phase 4 implementation: Current default coverage snapshot after Phase 4 implementation:
```text ```text
internal/app 64.3% internal/app 65.3%
internal/backup 42.3% internal/backup 42.3%
internal/config 79.5% internal/config 79.5%
internal/dbutil 8.6% internal/dbutil 8.6%
@@ -62,6 +62,7 @@ Default tests now cover:
- MySQL identifier quoting and duplicate-column detection - MySQL identifier quoting and duplicate-column detection
- Git utility behavior with temp bare repos - Git utility behavior with temp bare repos
- CLI parsing, dispatch errors, human-readable output, and JSON opt-ins - CLI parsing, dispatch errors, human-readable output, and JSON opt-ins
- Direct server user management parsing and DB operations
- CLI HTTP workflows for auth, repos, PRs, tokens, admin, and storage checks - CLI HTTP workflows for auth, repos, PRs, tokens, admin, and storage checks
- API auth handlers, token handlers, admin handlers - API auth handlers, token handlers, admin handlers
- Repository create/get/search/update/delete/fork/collaborator flows - Repository create/get/search/update/delete/fork/collaborator flows
+3
View File
@@ -112,6 +112,7 @@ Covers user workflows through the actual CLI binary or `app.Run`:
- `repo collaborator add/remove` - `repo collaborator add/remove`
- `pr create -> comment -> diff -> merge` - `pr create -> comment -> diff -> merge`
- `token list/revoke/prune` - `token list/revoke/prune`
- `server users view/list/add/edit/remove --dsn DSN`
- `backup create/restore` - `backup create/restore`
## Coverage targets ## Coverage targets
@@ -241,6 +242,7 @@ Split tests by behavior area:
- Repo refs/view/search JSON flag behavior - Repo refs/view/search JSON flag behavior
- PR create/list/view parser combinations - PR create/list/view parser combinations
- Token/admin command validation - Token/admin command validation
- Server-side direct user management command validation: `gitocean server users --dsn DSN view|list|add|edit|remove`
- Human-readable output by default - Human-readable output by default
- `--json` output where supported - `--json` output where supported
@@ -443,5 +445,6 @@ go test -cover ./...
- [ ] Optional e2e CLI workflow tests added. - [ ] Optional e2e CLI workflow tests added.
- [x] `go test ./...` passes. - [x] `go test ./...` passes.
- [x] `go vet ./...` passes. - [x] `go vet ./...` passes.
- [x] Server-side direct user management commands added and tested.
- [x] Coverage report reviewed. - [x] Coverage report reviewed.
- [x] Any intentionally uncovered environment-dependent paths documented. - [x] Any intentionally uncovered environment-dependent paths documented.