upaas/internal/models/models_test.go
sneak 5fb0b111fc Use ULID for app IDs and Docker label for container lookup
- Replace UUID with ULID for app ID generation (lexicographically sortable)
- Remove container_id column from apps table (migration 002)
- Add upaas.id Docker label to identify containers by app ID
- Implement FindContainerByAppID in Docker client to query by label
- Update handlers and deploy service to use label-based container lookup
- Show system-managed upaas.id label in UI with editing disabled

Container association is now determined dynamically via Docker label
rather than stored in the database, making the system more resilient
to container recreation or external changes.
2025-12-29 16:06:40 +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.ImageID = sql.NullString{String: "image123", 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, "image123", found.ImageID.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
}