Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/go-test.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
name: Go Unit Tests

# User-management tests need Postgres.
# Repository secret TEST_POSTGRES_PASSWORD

on:
push:
branches: [ main ]
Expand All @@ -13,6 +16,20 @@ permissions:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: altsuite
POSTGRES_PASSWORD: ${{ secrets.TEST_POSTGRES_PASSWORD }}
POSTGRES_DB: altsuite
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U altsuite -d altsuite"
--health-interval 5s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand All @@ -21,6 +38,8 @@ jobs:
with:
go-version: '1.24'
- name: Run unit tests and generate coverage
env:
DATABASE_URL: postgres://altsuite:${{ secrets.TEST_POSTGRES_PASSWORD }}@localhost:5432/altsuite?sslmode=disable
run: |
cd api
go test -v -race -coverprofile=coverage.out -coverpkg=./... ./... | tee test_output.txt
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ sudo systemctl stop altsuite

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

**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".

**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.

### Development:
Expand Down Expand Up @@ -114,16 +116,23 @@ go mod download
```

Start the server
```
```bash
cd api
go run main.go
```

if getting errors, try:
```bash
go run .
```
## Requirements

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

## CI

The Go test workflow runs user-management tests against Postgres. Add a repository secret **`TEST_POSTGRES_PASSWORD`**

## Contributors

Expand Down
133 changes: 133 additions & 0 deletions api/db/db.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package db

import (
"database/sql"
"errors"
"time"

"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)

// ErrDuplicateUsername is returned when creating a user with an existing username.
var ErrDuplicateUsername = errors.New("username already exists")

// User is a stored user (password hash never returned to API).
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
}

const bcryptCost = 10

const schema = `
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS users_username_key ON users (username);
`

// DB wraps the database connection and provides user operations.
type DB struct {
*sql.DB
}

// Open connects to Postgres and runs migrations. databaseURL format: postgres://user:pass@host:5432/dbname?sslmode=disable
func Open(databaseURL string) (*DB, error) {
if databaseURL == "" {
return nil, errors.New("database URL is required")
}
conn, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, err
}
if err := conn.Ping(); err != nil {
conn.Close()
return nil, err
}
d := &DB{conn}
if _, err := d.Exec(schema); err != nil {
d.Close()
return nil, err
}
return d, nil
}

// ListUsers returns all users (no password hashes).
func (d *DB) ListUsers() ([]User, error) {
rows, err := d.Query(`SELECT id, username, created_at FROM users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0)
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}

// CreateUser creates a new user with the given password (stored as bcrypt hash).
func (d *DB) CreateUser(username, password string) (*User, error) {
if username == "" || password == "" {
return nil, errors.New("username and password are required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return nil, err
}
var u User
err = d.QueryRow(
`INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id, username, created_at`,
username, string(hash),
).Scan(&u.ID, &u.Username, &u.CreatedAt)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
return nil, ErrDuplicateUsername
}
return nil, err
}
return &u, nil
}

// GetUserByID returns a user by ID, or nil if not found.
func (d *DB) GetUserByID(id int64) (*User, error) {
var u User
err := d.QueryRow(`SELECT id, username, created_at FROM users WHERE id = $1`, id).Scan(&u.ID, &u.Username, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}

// UpdatePassword sets a new password for the user (by ID).
func (d *DB) UpdatePassword(id int64, newPassword string) error {
if newPassword == "" {
return errors.New("new password is required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return err
}
res, err := d.Exec(`UPDATE users SET password_hash = $1 WHERE id = $2`, string(hash), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return errors.New("user not found")
}
return nil
}
186 changes: 186 additions & 0 deletions api/db/db_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package db

import (
"os"
"strconv"
"testing"
"time"
)

func testDB(t *testing.T) *DB {
url := os.Getenv("DATABASE_URL")
if url == "" {
t.Skip("DATABASE_URL not set (required for user management tests)")
}
d, err := Open(url)
if err != nil {
t.Fatalf("open test DB: %v", err)
}
t.Cleanup(func() { d.Close() })
return d
}

func TestOpen_EmptyURL(t *testing.T) {
_, err := Open("")
if err == nil {
t.Error("expected error for empty database URL")
}
}

func TestListUsers_Succeeds(t *testing.T) {
d := testDB(t)
users, err := d.ListUsers()
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if users == nil {
t.Error("ListUsers: expected non-nil slice")
}
}

func uniqueUsername(t *testing.T, prefix string) string {
return prefix + "_" + strconv.FormatInt(time.Now().UnixNano(), 10)
}

func TestCreateUser(t *testing.T) {
d := testDB(t)
u, err := d.CreateUser(uniqueUsername(t, "creat"), "testpass123")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if u.ID <= 0 {
t.Errorf("expected positive ID, got %d", u.ID)
}
if u.Username == "" {
t.Error("expected non-empty username")
}
if u.CreatedAt.IsZero() {
t.Error("expected CreatedAt to be set")
}
}

func TestCreateUser_EmptyUsername(t *testing.T) {
d := testDB(t)
_, err := d.CreateUser("", "pass")
if err == nil {
t.Error("expected error for empty username")
}
}

func TestCreateUser_EmptyPassword(t *testing.T) {
d := testDB(t)
_, err := d.CreateUser("u", "")
if err == nil {
t.Error("expected error for empty password")
}
}

func TestCreateUser_DuplicateUsername(t *testing.T) {
d := testDB(t)
name := uniqueUsername(t, "dup")
_, err := d.CreateUser(name, "pass1")
if err != nil {
t.Fatalf("first CreateUser: %v", err)
}
_, err = d.CreateUser(name, "pass2")
if err != ErrDuplicateUsername {
t.Errorf("expected ErrDuplicateUsername, got %v", err)
}
}

func TestListUsers_AfterCreate(t *testing.T) {
d := testDB(t)
name := uniqueUsername(t, "list")
_, err := d.CreateUser(name, "pass")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
users, err := d.ListUsers()
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
var found bool
for _, u := range users {
if u.Username == name {
found = true
break
}
}
if !found {
t.Errorf("ListUsers: did not find %q in %v", name, users)
}
}

func TestGetUserByID(t *testing.T) {
d := testDB(t)
name := uniqueUsername(t, "getbyid")
created, err := d.CreateUser(name, "pass")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
u, err := d.GetUserByID(created.ID)
if err != nil {
t.Fatalf("GetUserByID: %v", err)
}
if u == nil {
t.Fatal("GetUserByID: expected user, got nil")
}
if u.ID != created.ID || u.Username != name {
t.Errorf("GetUserByID: got %+v", u)
}
}

func TestGetUserByID_NotFound(t *testing.T) {
d := testDB(t)
u, err := d.GetUserByID(999999)
if err != nil {
t.Fatalf("GetUserByID: %v", err)
}
if u != nil {
t.Errorf("expected nil for missing user, got %+v", u)
}
}

func TestUpdatePassword(t *testing.T) {
d := testDB(t)
name := uniqueUsername(t, "chgpw")
created, err := d.CreateUser(name, "oldpass")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
err = d.UpdatePassword(created.ID, "newpass")
if err != nil {
t.Fatalf("UpdatePassword: %v", err)
}
// User should still be fetchable
u, err := d.GetUserByID(created.ID)
if err != nil || u == nil {
t.Fatalf("GetUserByID after UpdatePassword: %v", err)
}
if u.Username != name {
t.Errorf("username changed: %q", u.Username)
}
}

func TestUpdatePassword_EmptyPassword(t *testing.T) {
d := testDB(t)
created, err := d.CreateUser(uniqueUsername(t, "emptynewpw"), "pass")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
err = d.UpdatePassword(created.ID, "")
if err == nil {
t.Error("expected error for empty new password")
}
}

func TestUpdatePassword_NotFound(t *testing.T) {
d := testDB(t)
err := d.UpdatePassword(999999, "newpass")
if err == nil {
t.Error("expected error for non-existent user")
}
if err != nil && err.Error() != "user not found" {
t.Errorf("expected 'user not found', got %v", err)
}
}
Loading
Loading