From 73f1ae63cb768aef4dc74ff86623e3d0aa44dec8 Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Mon, 8 Jun 2026 14:10:16 -0500 Subject: [PATCH] Add server user management commands --- internal/app/core.go | 5 + internal/app/server.go | 3 + internal/app/server_users.go | 355 ++++++++++++++++++++++++++++++ internal/app/server_users_test.go | 195 ++++++++++++++++ notes/PHASE_4_COVERAGE_NOTES.md | 3 +- plans/PHASE_4_PLAN.md | 3 + 6 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 internal/app/server_users.go create mode 100644 internal/app/server_users_test.go diff --git a/internal/app/core.go b/internal/app/core.go index 57917cf..bf8ba76 100644 --- a/internal/app/core.go +++ b/internal/app/core.go @@ -86,6 +86,11 @@ func usage() { Commands: gitocean init [--config storage/config.json] 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 login [--server URL] gitocean logout diff --git a/internal/app/server.go b/internal/app/server.go index d1ce15d..3f96d5d 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -16,6 +16,9 @@ import ( // ---------------- Server boot and migrations ---------------- func runServer(args []string) error { + if len(args) > 0 && args[0] == "users" { + return cliServerUsers(args[1:]) + } fs := flag.NewFlagSet("server", flag.ExitOnError) configFile := fs.String("config", defaultServerConfigPath(), "server config file") addr := fs.String("addr", "", "HTTP listen address") diff --git a/internal/app/server_users.go b/internal/app/server_users.go new file mode 100644 index 0000000..0b5d057 --- /dev/null +++ b/internal/app/server_users.go @@ -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 ") + } + 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")) +} diff --git a/internal/app/server_users_test.go b/internal/app/server_users_test.go new file mode 100644 index 0000000..52dfeb2 --- /dev/null +++ b/internal/app/server_users_test.go @@ -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) + } +} diff --git a/notes/PHASE_4_COVERAGE_NOTES.md b/notes/PHASE_4_COVERAGE_NOTES.md index c3ff048..ed113b3 100644 --- a/notes/PHASE_4_COVERAGE_NOTES.md +++ b/notes/PHASE_4_COVERAGE_NOTES.md @@ -15,7 +15,7 @@ go test -cover ./... Current default coverage snapshot after Phase 4 implementation: ```text -internal/app 64.3% +internal/app 65.3% internal/backup 42.3% internal/config 79.5% internal/dbutil 8.6% @@ -62,6 +62,7 @@ Default tests now cover: - MySQL identifier quoting and duplicate-column detection - Git utility behavior with temp bare repos - 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 - API auth handlers, token handlers, admin handlers - Repository create/get/search/update/delete/fork/collaborator flows diff --git a/plans/PHASE_4_PLAN.md b/plans/PHASE_4_PLAN.md index fa60b51..7f94c19 100644 --- a/plans/PHASE_4_PLAN.md +++ b/plans/PHASE_4_PLAN.md @@ -112,6 +112,7 @@ Covers user workflows through the actual CLI binary or `app.Run`: - `repo collaborator add/remove` - `pr create -> comment -> diff -> merge` - `token list/revoke/prune` +- `server users view/list/add/edit/remove --dsn DSN` - `backup create/restore` ## Coverage targets @@ -241,6 +242,7 @@ Split tests by behavior area: - Repo refs/view/search JSON flag behavior - PR create/list/view parser combinations - 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 - `--json` output where supported @@ -443,5 +445,6 @@ go test -cover ./... - [ ] Optional e2e CLI workflow tests added. - [x] `go test ./...` passes. - [x] `go vet ./...` passes. +- [x] Server-side direct user management commands added and tested. - [x] Coverage report reviewed. - [x] Any intentionally uncovered environment-dependent paths documented.