upaas/internal/service/app/app_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

603 lines
15 KiB
Go

package app_test
import (
"context"
"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"
"git.eeqj.de/sneak/upaas/internal/service/app"
)
func setupTestService(t *testing.T) (*app.Service, func()) {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalsInst},
)
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars",
}
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: loggerInst,
Config: cfg,
})
require.NoError(t, err)
svc, err := app.New(fx.Lifecycle(nil), app.ServiceParams{
Logger: loggerInst,
Database: dbInst,
})
require.NoError(t, err)
// t.TempDir() automatically cleans up after test
cleanup := func() {}
return svc, cleanup
}
// deleteItemTestHelper is a generic helper for testing delete operations.
// It creates an app, adds an item, verifies it exists, deletes it, and verifies it's gone.
func deleteItemTestHelper(
t *testing.T,
appName string,
addItem func(ctx context.Context, svc *app.Service, appID string) error,
getCount func(ctx context.Context, application *models.App) (int, error),
deleteItem func(ctx context.Context, svc *app.Service, application *models.App) error,
) {
t.Helper()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: appName,
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = addItem(context.Background(), svc, createdApp.ID)
require.NoError(t, err)
count, err := getCount(context.Background(), createdApp)
require.NoError(t, err)
require.Equal(t, 1, count)
err = deleteItem(context.Background(), svc, createdApp)
require.NoError(t, err)
count, err = getCount(context.Background(), createdApp)
require.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestCreateAppWithGeneratedKeys(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
input := app.CreateAppInput{
Name: "test-app",
RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "main",
DockerfilePath: "Dockerfile",
}
createdApp, err := svc.CreateApp(context.Background(), input)
require.NoError(t, err)
require.NotNil(t, createdApp)
assert.Equal(t, "test-app", createdApp.Name)
assert.Equal(t, "git@gitea.example.com:user/repo.git", createdApp.RepoURL)
assert.Equal(t, "main", createdApp.Branch)
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
assert.NotEmpty(t, createdApp.ID)
assert.NotEmpty(t, createdApp.WebhookSecret)
assert.NotEmpty(t, createdApp.SSHPrivateKey)
assert.NotEmpty(t, createdApp.SSHPublicKey)
assert.Contains(t, createdApp.SSHPrivateKey, "-----BEGIN OPENSSH PRIVATE KEY-----")
assert.Contains(t, createdApp.SSHPublicKey, "ssh-ed25519")
assert.Equal(t, models.AppStatusPending, createdApp.Status)
}
func TestCreateAppDefaults(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
input := app.CreateAppInput{
Name: "test-app-defaults",
RepoURL: "git@gitea.example.com:user/repo.git",
}
createdApp, err := svc.CreateApp(context.Background(), input)
require.NoError(t, err)
assert.Equal(t, "main", createdApp.Branch)
assert.Equal(t, "Dockerfile", createdApp.DockerfilePath)
}
func TestCreateAppOptionalFields(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
input := app.CreateAppInput{
Name: "test-app-full",
RepoURL: "git@gitea.example.com:user/repo.git",
Branch: "develop",
DockerNetwork: "my-network",
NtfyTopic: "https://ntfy.sh/my-topic",
SlackWebhook: "https://hooks.slack.com/services/xxx",
}
createdApp, err := svc.CreateApp(context.Background(), input)
require.NoError(t, err)
assert.True(t, createdApp.DockerNetwork.Valid)
assert.Equal(t, "my-network", createdApp.DockerNetwork.String)
assert.True(t, createdApp.NtfyTopic.Valid)
assert.Equal(t, "https://ntfy.sh/my-topic", createdApp.NtfyTopic.String)
assert.True(t, createdApp.SlackWebhook.Valid)
}
func TestUpdateApp(testingT *testing.T) {
testingT.Parallel()
testingT.Run("updates app fields", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "original-name",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
Name: "updated-name",
RepoURL: "git@example.com:user/new-repo.git",
Branch: "develop",
DockerfilePath: "docker/Dockerfile",
DockerNetwork: "prod-network",
})
require.NoError(t, err)
// Reload and verify
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
require.NoError(t, err)
assert.Equal(t, "updated-name", reloaded.Name)
assert.Equal(t, "git@example.com:user/new-repo.git", reloaded.RepoURL)
assert.Equal(t, "develop", reloaded.Branch)
assert.Equal(t, "docker/Dockerfile", reloaded.DockerfilePath)
assert.Equal(t, "prod-network", reloaded.DockerNetwork.String)
})
testingT.Run("clears optional fields when empty", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "test-clear",
RepoURL: "git@example.com:user/repo.git",
NtfyTopic: "https://ntfy.sh/topic",
SlackWebhook: "https://slack.com/hook",
})
require.NoError(t, err)
err = svc.UpdateApp(context.Background(), createdApp, app.UpdateAppInput{
Name: "test-clear",
RepoURL: "git@example.com:user/repo.git",
Branch: "main",
})
require.NoError(t, err)
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
require.NoError(t, err)
assert.False(t, reloaded.NtfyTopic.Valid)
assert.False(t, reloaded.SlackWebhook.Valid)
})
}
func TestDeleteApp(testingT *testing.T) {
testingT.Parallel()
testingT.Run("deletes app and returns nil on lookup", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "to-delete",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.DeleteApp(context.Background(), createdApp)
require.NoError(t, err)
deleted, err := svc.GetApp(context.Background(), createdApp.ID)
require.NoError(t, err)
assert.Nil(t, deleted)
})
}
func TestGetApp(testingT *testing.T) {
testingT.Parallel()
testingT.Run("finds existing app", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "findable-app",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
found, err := svc.GetApp(context.Background(), created.ID)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, created.ID, found.ID)
assert.Equal(t, "findable-app", found.Name)
})
testingT.Run("returns nil for non-existent app", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
found, err := svc.GetApp(context.Background(), "non-existent-id")
require.NoError(t, err)
assert.Nil(t, found)
})
}
func TestGetAppByWebhookSecret(testingT *testing.T) {
testingT.Parallel()
testingT.Run("finds app by webhook secret", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
created, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "webhook-app",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
found, err := svc.GetAppByWebhookSecret(context.Background(), created.WebhookSecret)
require.NoError(t, err)
require.NotNil(t, found)
assert.Equal(t, created.ID, found.ID)
})
testingT.Run("returns nil for invalid secret", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
found, err := svc.GetAppByWebhookSecret(context.Background(), "invalid-secret")
require.NoError(t, err)
assert.Nil(t, found)
})
}
func TestListApps(testingT *testing.T) {
testingT.Parallel()
testingT.Run("returns empty list when no apps", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
apps, err := svc.ListApps(context.Background())
require.NoError(t, err)
assert.Empty(t, apps)
})
testingT.Run("returns all apps ordered by name", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
_, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "charlie",
RepoURL: "git@example.com:user/c.git",
})
require.NoError(t, err)
_, err = svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "alpha",
RepoURL: "git@example.com:user/a.git",
})
require.NoError(t, err)
_, err = svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "bravo",
RepoURL: "git@example.com:user/b.git",
})
require.NoError(t, err)
apps, err := svc.ListApps(context.Background())
require.NoError(t, err)
require.Len(t, apps, 3)
assert.Equal(t, "alpha", apps[0].Name)
assert.Equal(t, "bravo", apps[1].Name)
assert.Equal(t, "charlie", apps[2].Name)
})
}
func TestEnvVarsAddAndRetrieve(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "env-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.AddEnvVar(
context.Background(),
createdApp.ID,
"DATABASE_URL",
"postgres://localhost/db",
)
require.NoError(t, err)
err = svc.AddEnvVar(
context.Background(),
createdApp.ID,
"API_KEY",
"secret123",
)
require.NoError(t, err)
envVars, err := createdApp.GetEnvVars(context.Background())
require.NoError(t, err)
require.Len(t, envVars, 2)
keys := make(map[string]string)
for _, envVar := range envVars {
keys[envVar.Key] = envVar.Value
}
assert.Equal(t, "postgres://localhost/db", keys["DATABASE_URL"])
assert.Equal(t, "secret123", keys["API_KEY"])
}
func TestEnvVarsDelete(t *testing.T) {
t.Parallel()
deleteItemTestHelper(t, "env-delete-test",
func(ctx context.Context, svc *app.Service, appID string) error {
return svc.AddEnvVar(ctx, appID, "TO_DELETE", "value")
},
func(ctx context.Context, application *models.App) (int, error) {
envVars, err := application.GetEnvVars(ctx)
return len(envVars), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
envVars, err := application.GetEnvVars(ctx)
if err != nil {
return err
}
return svc.DeleteEnvVar(ctx, envVars[0].ID)
},
)
}
func TestLabels(testingT *testing.T) {
testingT.Parallel()
testingT.Run("adds and retrieves labels", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "label-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.AddLabel(context.Background(), createdApp.ID, "traefik.enable", "true")
require.NoError(t, err)
err = svc.AddLabel(
context.Background(),
createdApp.ID,
"com.example.env",
"production",
)
require.NoError(t, err)
labels, err := createdApp.GetLabels(context.Background())
require.NoError(t, err)
require.Len(t, labels, 2)
})
testingT.Run("deletes label", func(t *testing.T) {
t.Parallel()
deleteItemTestHelper(t, "label-delete-test",
func(ctx context.Context, svc *app.Service, appID string) error {
return svc.AddLabel(ctx, appID, "to.delete", "value")
},
func(ctx context.Context, application *models.App) (int, error) {
labels, err := application.GetLabels(ctx)
return len(labels), err
},
func(ctx context.Context, svc *app.Service, application *models.App) error {
labels, err := application.GetLabels(ctx)
if err != nil {
return err
}
return svc.DeleteLabel(ctx, labels[0].ID)
},
)
})
}
func TestVolumesAddAndRetrieve(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.AddVolume(
context.Background(),
createdApp.ID,
"/host/data",
"/app/data",
false,
)
require.NoError(t, err)
err = svc.AddVolume(
context.Background(),
createdApp.ID,
"/host/config",
"/app/config",
true,
)
require.NoError(t, err)
volumes, err := createdApp.GetVolumes(context.Background())
require.NoError(t, err)
require.Len(t, volumes, 2)
// Find readonly volume
var readonlyVolume *models.Volume
for _, vol := range volumes {
if vol.ReadOnly {
readonlyVolume = vol
break
}
}
require.NotNil(t, readonlyVolume)
assert.Equal(t, "/host/config", readonlyVolume.HostPath)
assert.Equal(t, "/app/config", readonlyVolume.ContainerPath)
}
func TestVolumesDelete(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "volume-delete-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
err = svc.AddVolume(
context.Background(),
createdApp.ID,
"/host/path",
"/container/path",
false,
)
require.NoError(t, err)
volumes, err := createdApp.GetVolumes(context.Background())
require.NoError(t, err)
require.Len(t, volumes, 1)
err = svc.DeleteVolume(context.Background(), volumes[0].ID)
require.NoError(t, err)
volumes, err = createdApp.GetVolumes(context.Background())
require.NoError(t, err)
assert.Empty(t, volumes)
}
func TestUpdateAppStatus(testingT *testing.T) {
testingT.Parallel()
testingT.Run("updates app status", func(t *testing.T) {
t.Parallel()
svc, cleanup := setupTestService(t)
defer cleanup()
createdApp, err := svc.CreateApp(context.Background(), app.CreateAppInput{
Name: "status-test",
RepoURL: "git@example.com:user/repo.git",
})
require.NoError(t, err)
assert.Equal(t, models.AppStatusPending, createdApp.Status)
err = svc.UpdateAppStatus(
context.Background(),
createdApp,
models.AppStatusBuilding,
)
require.NoError(t, err)
reloaded, err := svc.GetApp(context.Background(), createdApp.ID)
require.NoError(t, err)
assert.Equal(t, models.AppStatusBuilding, reloaded.Status)
})
}