upaas/internal/models/models_test.go
sneak 3f9d83c436 Initial commit with server startup infrastructure
Core infrastructure:
- Uber fx dependency injection
- Chi router with middleware stack
- SQLite database with embedded migrations
- Embedded templates and static assets
- Structured logging with slog

Features implemented:
- Authentication (login, logout, session management, argon2id hashing)
- App management (create, edit, delete, list)
- Deployment pipeline (clone, build, deploy, health check)
- Webhook processing for Gitea
- Notifications (ntfy, Slack)
- Environment variables, labels, volumes per app
- SSH key generation for deploy keys

Server startup:
- Server.Run() starts HTTP server on configured port
- Server.Shutdown() for graceful shutdown
- SetupRoutes() wires all handlers with chi router
2025-12-29 15:46:03 +07:00

802 lines
18 KiB
Go

package models_test
import (
"context"
"database/sql"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
)
// Test constants to satisfy goconst linter.
const (
testHash = "hash"
testBranch = "main"
testValue = "value"
testEventType = "push"
)
func setupTestDB(t *testing.T) (*database.Database, func()) {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalVars, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
logr, err := logger.New(fx.Lifecycle(nil), logger.Params{
Globals: globalVars,
})
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
testDB, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: logr,
Config: cfg,
})
require.NoError(t, err)
// t.TempDir() automatically cleans up after test
cleanup := func() {}
return testDB, cleanup
}
// User Tests.
func TestUserCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "testuser"
user.PasswordHash = "hashed_password"
err := user.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, user.ID)
assert.NotZero(t, user.CreatedAt)
found, err := models.FindUser(context.Background(), testDB, user.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, "testuser", found.Username)
}
func TestUserUpdate(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "original"
user.PasswordHash = "hash1"
err := user.Save(context.Background())
require.NoError(t, err)
user.Username = "updated"
user.PasswordHash = "hash2"
err = user.Save(context.Background())
require.NoError(t, err)
found, err := models.FindUser(context.Background(), testDB, user.ID)
require.NoError(t, err)
assert.Equal(t, "updated", found.Username)
assert.Equal(t, "hash2", found.PasswordHash)
}
func TestUserDelete(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "todelete"
user.PasswordHash = testHash
err := user.Save(context.Background())
require.NoError(t, err)
err = user.Delete(context.Background())
require.NoError(t, err)
found, err := models.FindUser(context.Background(), testDB, user.ID)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestUserFindByUsername(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "findme"
user.PasswordHash = testHash
err := user.Save(context.Background())
require.NoError(t, err)
found, err := models.FindUserByUsername(
context.Background(), testDB, "findme",
)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, user.ID, found.ID)
}
func TestUserFindByUsernameNotFound(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
found, err := models.FindUserByUsername(
context.Background(), testDB, "nonexistent",
)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestUserExists(t *testing.T) {
t.Parallel()
t.Run("returns false when no users", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
exists, err := models.UserExists(context.Background(), testDB)
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("returns true when user exists", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
user := models.NewUser(testDB)
user.Username = "admin"
user.PasswordHash = testHash
err := user.Save(context.Background())
require.NoError(t, err)
exists, err := models.UserExists(context.Background(), testDB)
require.NoError(t, err)
assert.True(t, exists)
})
}
// App Tests.
func TestAppCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
assert.NotZero(t, app.CreatedAt)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, "test-app", found.Name)
assert.Equal(t, models.AppStatusPending, found.Status)
}
func TestAppUpdate(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
app.Name = "updated"
app.Status = models.AppStatusRunning
app.ContainerID = sql.NullString{String: "container123", Valid: true}
err := app.Save(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
assert.Equal(t, "updated", found.Name)
assert.Equal(t, models.AppStatusRunning, found.Status)
assert.Equal(t, "container123", found.ContainerID.String)
}
func TestAppDelete(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
err := app.Delete(context.Background())
require.NoError(t, err)
found, err := models.FindApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
assert.Nil(t, found)
}
func TestAppFindByWebhookSecret(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
found, err := models.FindAppByWebhookSecret(
context.Background(), testDB, app.WebhookSecret,
)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, app.ID, found.ID)
}
func TestAllApps(t *testing.T) {
t.Parallel()
t.Run("returns empty list when no apps", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
apps, err := models.AllApps(context.Background(), testDB)
require.NoError(t, err)
assert.Empty(t, apps)
})
t.Run("returns apps ordered by name", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
names := []string{"zebra", "alpha", "mike"}
for idx, name := range names {
app := models.NewApp(testDB)
app.ID = name + "-id"
app.Name = name
app.RepoURL = "git@example.com:user/" + name + ".git"
app.Branch = testBranch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + strconv.Itoa(idx)
app.SSHPrivateKey = "private"
app.SSHPublicKey = "public"
err := app.Save(context.Background())
require.NoError(t, err)
}
apps, err := models.AllApps(context.Background(), testDB)
require.NoError(t, err)
require.Len(t, apps, 3)
assert.Equal(t, "alpha", apps[0].Name)
assert.Equal(t, "mike", apps[1].Name)
assert.Equal(t, "zebra", apps[2].Name)
})
}
// EnvVar Tests.
func TestEnvVarCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds env vars", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
// Create app first.
app := createTestApp(t, testDB)
envVar := models.NewEnvVar(testDB)
envVar.AppID = app.ID
envVar.Key = "DATABASE_URL"
envVar.Value = "postgres://localhost/db"
err := envVar.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, envVar.ID)
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, envVars, 1)
assert.Equal(t, "DATABASE_URL", envVars[0].Key)
})
t.Run("deletes env var", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
envVar := models.NewEnvVar(testDB)
envVar.AppID = app.ID
envVar.Key = "TO_DELETE"
envVar.Value = testValue
err := envVar.Save(context.Background())
require.NoError(t, err)
err = envVar.Delete(context.Background())
require.NoError(t, err)
envVars, err := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
assert.Empty(t, envVars)
})
}
// Label Tests.
func TestLabelCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds labels", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "traefik.enable"
label.Value = "true"
err := label.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, label.ID)
labels, err := models.FindLabelsByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, labels, 1)
assert.Equal(t, "traefik.enable", labels[0].Key)
})
}
// Volume Tests.
func TestVolumeCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds volumes", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
volume := models.NewVolume(testDB)
volume.AppID = app.ID
volume.HostPath = "/data/app"
volume.ContainerPath = "/app/data"
volume.ReadOnly = true
err := volume.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, volume.ID)
volumes, err := models.FindVolumesByAppID(
context.Background(), testDB, app.ID,
)
require.NoError(t, err)
require.Len(t, volumes, 1)
assert.Equal(t, "/data/app", volumes[0].HostPath)
assert.True(t, volumes[0].ReadOnly)
})
}
// WebhookEvent Tests.
func TestWebhookEventCRUD(t *testing.T) {
t.Parallel()
t.Run("creates and finds webhook events", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
event := models.NewWebhookEvent(testDB)
event.AppID = app.ID
event.EventType = testEventType
event.Branch = testBranch
event.CommitSHA = sql.NullString{String: "abc123", Valid: true}
event.Matched = true
err := event.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, event.ID)
events, err := models.FindWebhookEventsByAppID(
context.Background(), testDB, app.ID, 10,
)
require.NoError(t, err)
require.Len(t, events, 1)
assert.Equal(t, "push", events[0].EventType)
assert.True(t, events[0].Matched)
})
}
// Deployment Tests.
func TestDeploymentCreateAndFind(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
deployment.CommitSHA = sql.NullString{String: "abc123def456", Valid: true}
deployment.Status = models.DeploymentStatusBuilding
err := deployment.Save(context.Background())
require.NoError(t, err)
assert.NotZero(t, deployment.ID)
assert.NotZero(t, deployment.StartedAt)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, models.DeploymentStatusBuilding, found.Status)
}
func TestDeploymentAppendLog(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
deployment.Status = models.DeploymentStatusBuilding
err := deployment.Save(context.Background())
require.NoError(t, err)
err = deployment.AppendLog(context.Background(), "Building image...")
require.NoError(t, err)
err = deployment.AppendLog(context.Background(), "Image built successfully")
require.NoError(t, err)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
assert.Contains(t, found.Logs.String, "Building image...")
assert.Contains(t, found.Logs.String, "Image built successfully")
}
func TestDeploymentMarkFinished(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deployment := models.NewDeployment(testDB)
deployment.AppID = app.ID
deployment.Status = models.DeploymentStatusBuilding
err := deployment.Save(context.Background())
require.NoError(t, err)
err = deployment.MarkFinished(context.Background(), models.DeploymentStatusSuccess)
require.NoError(t, err)
found, err := models.FindDeployment(context.Background(), testDB, deployment.ID)
require.NoError(t, err)
assert.Equal(t, models.DeploymentStatusSuccess, found.Status)
assert.True(t, found.FinishedAt.Valid)
}
func TestDeploymentFindByAppID(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
for idx := range 5 {
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.Status = models.DeploymentStatusSuccess
deploy.CommitSHA = sql.NullString{
String: "commit" + strconv.Itoa(idx),
Valid: true,
}
err := deploy.Save(context.Background())
require.NoError(t, err)
}
deployments, err := models.FindDeploymentsByAppID(context.Background(), testDB, app.ID, 3)
require.NoError(t, err)
assert.Len(t, deployments, 3)
}
func TestDeploymentFindLatest(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
for idx := range 3 {
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.CommitSHA = sql.NullString{
String: "commit" + strconv.Itoa(idx),
Valid: true,
}
deploy.Status = models.DeploymentStatusSuccess
err := deploy.Save(context.Background())
require.NoError(t, err)
}
latest, err := models.LatestDeploymentForApp(context.Background(), testDB, app.ID)
require.NoError(t, err)
require.NotNil(t, latest)
assert.Equal(t, "commit2", latest.CommitSHA.String)
}
// App Helper Methods Tests.
func TestAppGetEnvVars(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
env1 := models.NewEnvVar(testDB)
env1.AppID = app.ID
env1.Key = "KEY1"
env1.Value = "value1"
_ = env1.Save(context.Background())
env2 := models.NewEnvVar(testDB)
env2.AppID = app.ID
env2.Key = "KEY2"
env2.Value = "value2"
_ = env2.Save(context.Background())
envVars, err := app.GetEnvVars(context.Background())
require.NoError(t, err)
assert.Len(t, envVars, 2)
}
func TestAppGetLabels(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "label.key"
label.Value = "label.value"
_ = label.Save(context.Background())
labels, err := app.GetLabels(context.Background())
require.NoError(t, err)
assert.Len(t, labels, 1)
}
func TestAppGetVolumes(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
vol := models.NewVolume(testDB)
vol.AppID = app.ID
vol.HostPath = "/host"
vol.ContainerPath = "/container"
_ = vol.Save(context.Background())
volumes, err := app.GetVolumes(context.Background())
require.NoError(t, err)
assert.Len(t, volumes, 1)
}
func TestAppGetDeployments(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.Status = models.DeploymentStatusSuccess
_ = deploy.Save(context.Background())
deployments, err := app.GetDeployments(context.Background(), 10)
require.NoError(t, err)
assert.Len(t, deployments, 1)
}
func TestAppGetWebhookEvents(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
event := models.NewWebhookEvent(testDB)
event.AppID = app.ID
event.EventType = testEventType
event.Branch = testBranch
event.Matched = true
_ = event.Save(context.Background())
events, err := app.GetWebhookEvents(context.Background(), 10)
require.NoError(t, err)
assert.Len(t, events, 1)
}
// Cascade Delete Tests.
//nolint:funlen // Test function with many assertions - acceptable for integration tests
func TestCascadeDelete(t *testing.T) {
t.Parallel()
t.Run("deleting app cascades to related records", func(t *testing.T) {
t.Parallel()
testDB, cleanup := setupTestDB(t)
defer cleanup()
app := createTestApp(t, testDB)
// Create related records.
env := models.NewEnvVar(testDB)
env.AppID = app.ID
env.Key = "KEY"
env.Value = "value"
_ = env.Save(context.Background())
label := models.NewLabel(testDB)
label.AppID = app.ID
label.Key = "key"
label.Value = "value"
_ = label.Save(context.Background())
vol := models.NewVolume(testDB)
vol.AppID = app.ID
vol.HostPath = "/host"
vol.ContainerPath = "/container"
_ = vol.Save(context.Background())
event := models.NewWebhookEvent(testDB)
event.AppID = app.ID
event.EventType = testEventType
event.Branch = testBranch
event.Matched = true
_ = event.Save(context.Background())
deploy := models.NewDeployment(testDB)
deploy.AppID = app.ID
deploy.Status = models.DeploymentStatusSuccess
_ = deploy.Save(context.Background())
// Delete app.
err := app.Delete(context.Background())
require.NoError(t, err)
// Verify cascades.
envVars, _ := models.FindEnvVarsByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, envVars)
labels, _ := models.FindLabelsByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, labels)
volumes, _ := models.FindVolumesByAppID(
context.Background(), testDB, app.ID,
)
assert.Empty(t, volumes)
events, _ := models.FindWebhookEventsByAppID(
context.Background(), testDB, app.ID, 10,
)
assert.Empty(t, events)
deployments, _ := models.FindDeploymentsByAppID(
context.Background(), testDB, app.ID, 10,
)
assert.Empty(t, deployments)
})
}
// Helper function to create a test app.
func createTestApp(t *testing.T, testDB *database.Database) *models.App {
t.Helper()
app := models.NewApp(testDB)
app.ID = "test-app-" + t.Name()
app.Name = "test-app"
app.RepoURL = "git@example.com:user/repo.git"
app.Branch = testBranch
app.DockerfilePath = "Dockerfile"
app.WebhookSecret = "secret-" + t.Name()
app.SSHPrivateKey = "private"
app.SSHPublicKey = "public"
err := app.Save(context.Background())
require.NoError(t, err)
return app
}