- 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.
603 lines
15 KiB
Go
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)
|
|
})
|
|
}
|