Skip to content

Commit d783b1b

Browse files
authored
Merge pull request #37 from smalex-z/user-management
user-management
2 parents dc09207 + 67b086b commit d783b1b

15 files changed

Lines changed: 819 additions & 5 deletions

File tree

.github/workflows/go-test.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
name: Go Unit Tests
22

3+
# User-management tests need Postgres.
4+
# Repository secret TEST_POSTGRES_PASSWORD
5+
36
on:
47
push:
58
branches: [ main ]
@@ -13,6 +16,20 @@ permissions:
1316
jobs:
1417
test:
1518
runs-on: ubuntu-latest
19+
services:
20+
postgres:
21+
image: postgres:16-alpine
22+
env:
23+
POSTGRES_USER: altsuite
24+
POSTGRES_PASSWORD: ${{ secrets.TEST_POSTGRES_PASSWORD }}
25+
POSTGRES_DB: altsuite
26+
ports:
27+
- 5432:5432
28+
options: >-
29+
--health-cmd "pg_isready -U altsuite -d altsuite"
30+
--health-interval 5s
31+
--health-timeout 5s
32+
--health-retries 5
1633
steps:
1734
- name: Checkout code
1835
uses: actions/checkout@v4
@@ -21,6 +38,8 @@ jobs:
2138
with:
2239
go-version: '1.24'
2340
- name: Run unit tests and generate coverage
41+
env:
42+
DATABASE_URL: postgres://altsuite:${{ secrets.TEST_POSTGRES_PASSWORD }}@localhost:5432/altsuite?sslmode=disable
2443
run: |
2544
cd api
2645
go test -v -race -coverprofile=coverage.out -coverpkg=./... ./... | tee test_output.txt

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ sudo systemctl stop altsuite
8787

8888
The API will be available at `http://localhost:8080`
8989

90+
**User management:** If Docker is installed, the installer automatically sets up a Postgres container and `/opt/altsuite/env` with `DATABASE_URL`, so the **Users** tab works after install. If you install without Docker, you can enable it later: run `deploy/altsuite-postgres` (or use your own Postgres), create `/opt/altsuite/env` with `DATABASE_URL=postgres://altsuite:PASSWORD@127.0.0.1:5432/altsuite?sslmode=disable`, then `sudo systemctl restart altsuite`. Without `DATABASE_URL`, the Users page shows "User management not configured".
91+
9092
**Security Note:** The installation configures passwordless sudo for specific operations (systemctl, apt-get, docker) limited to the `altsuite` user only. See `/etc/sudoers.d/altsuite` after installation.
9193

9294
### Development:
@@ -114,16 +116,23 @@ go mod download
114116
```
115117

116118
Start the server
117-
```
119+
```bash
118120
cd api
119121
go run main.go
120122
```
121123

124+
if getting errors, try:
125+
```bash
126+
go run .
127+
```
122128
## Requirements
123129

124130
- **Development**: Go 1.21+, Node.js 18+
125131
- **Production**: Linux system (systemd recommended)
126132

133+
## CI
134+
135+
The Go test workflow runs user-management tests against Postgres. Add a repository secret **`TEST_POSTGRES_PASSWORD`**
127136

128137
## Contributors
129138

api/db/db.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package db
2+
3+
import (
4+
"database/sql"
5+
"errors"
6+
"time"
7+
8+
"github.com/lib/pq"
9+
"golang.org/x/crypto/bcrypt"
10+
)
11+
12+
// ErrDuplicateUsername is returned when creating a user with an existing username.
13+
var ErrDuplicateUsername = errors.New("username already exists")
14+
15+
// User is a stored user (password hash never returned to API).
16+
type User struct {
17+
ID int64 `json:"id"`
18+
Username string `json:"username"`
19+
CreatedAt time.Time `json:"created_at"`
20+
}
21+
22+
const bcryptCost = 10
23+
24+
const schema = `
25+
CREATE TABLE IF NOT EXISTS users (
26+
id BIGSERIAL PRIMARY KEY,
27+
username TEXT NOT NULL UNIQUE,
28+
password_hash TEXT NOT NULL,
29+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
30+
);
31+
CREATE UNIQUE INDEX IF NOT EXISTS users_username_key ON users (username);
32+
`
33+
34+
// DB wraps the database connection and provides user operations.
35+
type DB struct {
36+
*sql.DB
37+
}
38+
39+
// Open connects to Postgres and runs migrations. databaseURL format: postgres://user:pass@host:5432/dbname?sslmode=disable
40+
func Open(databaseURL string) (*DB, error) {
41+
if databaseURL == "" {
42+
return nil, errors.New("database URL is required")
43+
}
44+
conn, err := sql.Open("postgres", databaseURL)
45+
if err != nil {
46+
return nil, err
47+
}
48+
if err := conn.Ping(); err != nil {
49+
conn.Close()
50+
return nil, err
51+
}
52+
d := &DB{conn}
53+
if _, err := d.Exec(schema); err != nil {
54+
d.Close()
55+
return nil, err
56+
}
57+
return d, nil
58+
}
59+
60+
// ListUsers returns all users (no password hashes).
61+
func (d *DB) ListUsers() ([]User, error) {
62+
rows, err := d.Query(`SELECT id, username, created_at FROM users ORDER BY username`)
63+
if err != nil {
64+
return nil, err
65+
}
66+
defer rows.Close()
67+
users := make([]User, 0)
68+
for rows.Next() {
69+
var u User
70+
if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt); err != nil {
71+
return nil, err
72+
}
73+
users = append(users, u)
74+
}
75+
return users, rows.Err()
76+
}
77+
78+
// CreateUser creates a new user with the given password (stored as bcrypt hash).
79+
func (d *DB) CreateUser(username, password string) (*User, error) {
80+
if username == "" || password == "" {
81+
return nil, errors.New("username and password are required")
82+
}
83+
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
84+
if err != nil {
85+
return nil, err
86+
}
87+
var u User
88+
err = d.QueryRow(
89+
`INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, created_at`,
90+
username, string(hash),
91+
).Scan(&u.ID, &u.Username, &u.CreatedAt)
92+
if err != nil {
93+
var pqErr *pq.Error
94+
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
95+
return nil, ErrDuplicateUsername
96+
}
97+
return nil, err
98+
}
99+
return &u, nil
100+
}
101+
102+
// GetUserByID returns a user by ID, or nil if not found.
103+
func (d *DB) GetUserByID(id int64) (*User, error) {
104+
var u User
105+
err := d.QueryRow(`SELECT id, username, created_at FROM users WHERE id = $1`, id).Scan(&u.ID, &u.Username, &u.CreatedAt)
106+
if err == sql.ErrNoRows {
107+
return nil, nil
108+
}
109+
if err != nil {
110+
return nil, err
111+
}
112+
return &u, nil
113+
}
114+
115+
// UpdatePassword sets a new password for the user (by ID).
116+
func (d *DB) UpdatePassword(id int64, newPassword string) error {
117+
if newPassword == "" {
118+
return errors.New("new password is required")
119+
}
120+
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
121+
if err != nil {
122+
return err
123+
}
124+
res, err := d.Exec(`UPDATE users SET password_hash = $1 WHERE id = $2`, string(hash), id)
125+
if err != nil {
126+
return err
127+
}
128+
n, _ := res.RowsAffected()
129+
if n == 0 {
130+
return errors.New("user not found")
131+
}
132+
return nil
133+
}

api/db/db_test.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package db
2+
3+
import (
4+
"os"
5+
"strconv"
6+
"testing"
7+
"time"
8+
)
9+
10+
func testDB(t *testing.T) *DB {
11+
url := os.Getenv("DATABASE_URL")
12+
if url == "" {
13+
t.Skip("DATABASE_URL not set (required for user management tests)")
14+
}
15+
d, err := Open(url)
16+
if err != nil {
17+
t.Fatalf("open test DB: %v", err)
18+
}
19+
t.Cleanup(func() { d.Close() })
20+
return d
21+
}
22+
23+
func TestOpen_EmptyURL(t *testing.T) {
24+
_, err := Open("")
25+
if err == nil {
26+
t.Error("expected error for empty database URL")
27+
}
28+
}
29+
30+
func TestListUsers_Succeeds(t *testing.T) {
31+
d := testDB(t)
32+
users, err := d.ListUsers()
33+
if err != nil {
34+
t.Fatalf("ListUsers: %v", err)
35+
}
36+
if users == nil {
37+
t.Error("ListUsers: expected non-nil slice")
38+
}
39+
}
40+
41+
func uniqueUsername(t *testing.T, prefix string) string {
42+
return prefix + "_" + strconv.FormatInt(time.Now().UnixNano(), 10)
43+
}
44+
45+
func TestCreateUser(t *testing.T) {
46+
d := testDB(t)
47+
u, err := d.CreateUser(uniqueUsername(t, "creat"), "testpass123")
48+
if err != nil {
49+
t.Fatalf("CreateUser: %v", err)
50+
}
51+
if u.ID <= 0 {
52+
t.Errorf("expected positive ID, got %d", u.ID)
53+
}
54+
if u.Username == "" {
55+
t.Error("expected non-empty username")
56+
}
57+
if u.CreatedAt.IsZero() {
58+
t.Error("expected CreatedAt to be set")
59+
}
60+
}
61+
62+
func TestCreateUser_EmptyUsername(t *testing.T) {
63+
d := testDB(t)
64+
_, err := d.CreateUser("", "pass")
65+
if err == nil {
66+
t.Error("expected error for empty username")
67+
}
68+
}
69+
70+
func TestCreateUser_EmptyPassword(t *testing.T) {
71+
d := testDB(t)
72+
_, err := d.CreateUser("u", "")
73+
if err == nil {
74+
t.Error("expected error for empty password")
75+
}
76+
}
77+
78+
func TestCreateUser_DuplicateUsername(t *testing.T) {
79+
d := testDB(t)
80+
name := uniqueUsername(t, "dup")
81+
_, err := d.CreateUser(name, "pass1")
82+
if err != nil {
83+
t.Fatalf("first CreateUser: %v", err)
84+
}
85+
_, err = d.CreateUser(name, "pass2")
86+
if err != ErrDuplicateUsername {
87+
t.Errorf("expected ErrDuplicateUsername, got %v", err)
88+
}
89+
}
90+
91+
func TestListUsers_AfterCreate(t *testing.T) {
92+
d := testDB(t)
93+
name := uniqueUsername(t, "list")
94+
_, err := d.CreateUser(name, "pass")
95+
if err != nil {
96+
t.Fatalf("CreateUser: %v", err)
97+
}
98+
users, err := d.ListUsers()
99+
if err != nil {
100+
t.Fatalf("ListUsers: %v", err)
101+
}
102+
var found bool
103+
for _, u := range users {
104+
if u.Username == name {
105+
found = true
106+
break
107+
}
108+
}
109+
if !found {
110+
t.Errorf("ListUsers: did not find %q in %v", name, users)
111+
}
112+
}
113+
114+
func TestGetUserByID(t *testing.T) {
115+
d := testDB(t)
116+
name := uniqueUsername(t, "getbyid")
117+
created, err := d.CreateUser(name, "pass")
118+
if err != nil {
119+
t.Fatalf("CreateUser: %v", err)
120+
}
121+
u, err := d.GetUserByID(created.ID)
122+
if err != nil {
123+
t.Fatalf("GetUserByID: %v", err)
124+
}
125+
if u == nil {
126+
t.Fatal("GetUserByID: expected user, got nil")
127+
}
128+
if u.ID != created.ID || u.Username != name {
129+
t.Errorf("GetUserByID: got %+v", u)
130+
}
131+
}
132+
133+
func TestGetUserByID_NotFound(t *testing.T) {
134+
d := testDB(t)
135+
u, err := d.GetUserByID(999999)
136+
if err != nil {
137+
t.Fatalf("GetUserByID: %v", err)
138+
}
139+
if u != nil {
140+
t.Errorf("expected nil for missing user, got %+v", u)
141+
}
142+
}
143+
144+
func TestUpdatePassword(t *testing.T) {
145+
d := testDB(t)
146+
name := uniqueUsername(t, "chgpw")
147+
created, err := d.CreateUser(name, "oldpass")
148+
if err != nil {
149+
t.Fatalf("CreateUser: %v", err)
150+
}
151+
err = d.UpdatePassword(created.ID, "newpass")
152+
if err != nil {
153+
t.Fatalf("UpdatePassword: %v", err)
154+
}
155+
// User should still be fetchable
156+
u, err := d.GetUserByID(created.ID)
157+
if err != nil || u == nil {
158+
t.Fatalf("GetUserByID after UpdatePassword: %v", err)
159+
}
160+
if u.Username != name {
161+
t.Errorf("username changed: %q", u.Username)
162+
}
163+
}
164+
165+
func TestUpdatePassword_EmptyPassword(t *testing.T) {
166+
d := testDB(t)
167+
created, err := d.CreateUser(uniqueUsername(t, "emptynewpw"), "pass")
168+
if err != nil {
169+
t.Fatalf("CreateUser: %v", err)
170+
}
171+
err = d.UpdatePassword(created.ID, "")
172+
if err == nil {
173+
t.Error("expected error for empty new password")
174+
}
175+
}
176+
177+
func TestUpdatePassword_NotFound(t *testing.T) {
178+
d := testDB(t)
179+
err := d.UpdatePassword(999999, "newpass")
180+
if err == nil {
181+
t.Error("expected error for non-existent user")
182+
}
183+
if err != nil && err.Error() != "user not found" {
184+
t.Errorf("expected 'user not found', got %v", err)
185+
}
186+
}

0 commit comments

Comments
 (0)