flnxi-Wu/flnxi-agent-go/internal/api/handlers_test.go
2026-06-01 10:21:13 +08:00

360 lines
11 KiB
Go

package api
import (
"context"
"errors"
"strings"
"testing"
"time"
"flnxi-agent/internal/docker"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
)
func TestResolveUpdateImageRejectsDirectImageWithoutFixedTag(t *testing.T) {
tests := []struct {
name string
image string
wantErr string
}{
{name: "no tag", image: "registry.local:5000/material/flnxi-material", wantErr: "fixed version tag"},
{name: "latest", image: "registry.local:5000/material/flnxi-material:latest", wantErr: "latest is not allowed"},
{name: "empty tag", image: "registry.local:5000/material/flnxi-material:", wantErr: "fixed version tag"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := resolveUpdateImage(UpdateRequest{Image: tt.image})
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("resolveUpdateImage() error = %v, want contains %q", err, tt.wantErr)
}
})
}
}
func TestResolveUpdateImageAcceptsDirectImageWithFixedTag(t *testing.T) {
image := "registry.local:5000/material/flnxi-material:1.2.3"
got, err := resolveUpdateImage(UpdateRequest{Image: image})
if err != nil {
t.Fatalf("resolveUpdateImage() unexpected error: %v", err)
}
if got != image {
t.Fatalf("resolveUpdateImage() = %q, want %q", got, image)
}
}
func TestResolveUpdateImageRejectsLatestVersion(t *testing.T) {
t.Setenv(envMaterialImageRepository, "registry.local:5000/material/flnxi-material")
_, err := resolveUpdateImage(UpdateRequest{Version: "LATEST"})
if err == nil || !strings.Contains(err.Error(), "latest is not allowed") {
t.Fatalf("resolveUpdateImage() error = %v, want latest rejection", err)
}
}
func TestResolveInstallImagesRequiresFixedMaterialTag(t *testing.T) {
tests := []struct {
name string
image string
wantErr string
}{
{name: "missing", image: "", wantErr: envMaterialImage},
{name: "no tag", image: "registry.local:5000/material/flnxi-material", wantErr: "fixed version tag"},
{name: "latest", image: "registry.local:5000/material/flnxi-material:latest", wantErr: "latest is not allowed"},
{name: "fixed tag", image: "registry.local:5000/material/flnxi-material:1.0.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, _, _, err := resolveInstallImages(InstallRequest{MaterialImage: tt.image})
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("resolveInstallImages() error = %v, want contains %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("resolveInstallImages() unexpected error: %v", err)
}
if got != tt.image {
t.Fatalf("resolveInstallImages() material = %q, want %q", got, tt.image)
}
})
}
}
func TestResolveInstallLicenseConfigRequiresMaterialRuntimeSecrets(t *testing.T) {
secrets := InstallSecrets{LocalEncryptionKey: strings.Repeat("b", 32)}
_, err := resolveInstallLicenseConfig(InstallRequest{}, secrets)
if err == nil {
t.Fatal("resolveInstallLicenseConfig() expected error")
}
for _, want := range []string{
envLicenseServerURL,
envLicenseProjectCode,
envLicenseRequestSigningSecret,
envLicensePublicKey,
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("resolveInstallLicenseConfig() error = %q, want %q", err.Error(), want)
}
}
}
func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) {
req := InstallRequest{LicenseKey: "FLNXI-TEST"}
secrets := InstallSecrets{
DbUser: "flnxi",
DbPassword: "db-pass",
DbName: "flnxi",
MinioRootUser: "minio",
MinioRootPassword: "minio-pass",
S3Bucket: "materials",
NextAuthSecret: "next-secret",
LocalEncryptionKey: strings.Repeat("b", 32),
}
licenseConfig := InstallLicenseConfig{
ServerURL: "https://license.example.test",
ProjectCode: "flnxi-sucai",
RequestSigningSecret: strings.Repeat("a", 32),
PublicKey: "-----BEGIN PUBLIC KEY-----\\nabc\\n-----END PUBLIC KEY-----",
LocalEncryptionKey: secrets.LocalEncryptionKey,
}
env := envSliceToMap(materialAppEnv(req, secrets, licenseConfig, "192.168.1.20", 4320))
for _, want := range []string{
envLicenseServerURL,
envLicenseProjectCode,
envLicenseRequestSigningSecret,
envLicensePublicKey,
envLocalEncryptionKey,
"DATABASE_URL",
"S3_PUBLIC_DOMAIN",
"NEXTAUTH_URL",
"APP_PUBLIC_URL",
} {
if env[want] == "" {
t.Fatalf("materialAppEnv() missing %s in %v", want, env)
}
}
if env["APP_PUBLIC_URL"] != "http://192.168.1.20:4320" {
t.Fatalf("APP_PUBLIC_URL = %q, want host URL", env["APP_PUBLIC_URL"])
}
}
func TestRunInstallStopsBeforePullWhenLicenseConfigMissing(t *testing.T) {
resetInstallStatusForTest()
fake := &fakeInstallDocker{}
runInstall(fake, InstallRequest{MaterialImage: "registry.local:5000/material/flnxi-material:1.0.1"})
statusMutex.RLock()
status := *installStatus
statusMutex.RUnlock()
if status.Status != "failed" {
t.Fatalf("install status = %q, want failed", status.Status)
}
if !strings.Contains(status.Error, "许可证配置缺失") {
t.Fatalf("install error = %q, want license config error", status.Error)
}
if len(fake.pulledImages) != 0 {
t.Fatalf("pulled images = %v, want none before license config is valid", fake.pulledImages)
}
if len(fake.createdContainers) != 0 {
t.Fatalf("created containers = %v, want none before license config is valid", fake.createdContainers)
}
}
func TestRunUpdateRestoresOldContainerAfterHealthCheckFailure(t *testing.T) {
resetUpdateStatusForTest()
old := &docker.ContainerInfo{
ID: "old-container",
ImageID: "sha256:old",
State: "running",
Config: &container.Config{
Image: "registry.local:5000/material/flnxi-material:1.0.0",
User: "1000",
Env: []string{"A=B"},
ExposedPorts: nat.PortSet{"3000/tcp": struct{}{}},
},
HostConfig: &container.HostConfig{
Binds: []string{"/data/material:/app/data"},
NetworkMode: "flnxi-network",
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
PortBindings: nat.PortMap{"3000/tcp": []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: "3000"}}},
},
}
fake := &fakeUpdateDocker{container: old, healthErr: errors.New("not healthy")}
runUpdate(fake, UpdateRequest{Image: "registry.local:5000/material/flnxi-material:2.0.0"})
statusMutex.RLock()
status := *updateStatus
statusMutex.RUnlock()
if status.Status != "failed" {
t.Fatalf("update status = %q, want failed", status.Status)
}
if !strings.Contains(status.Error, "已恢复旧版本容器") {
t.Fatalf("update error = %q, want rollback success message", status.Error)
}
if fake.container == nil {
t.Fatal("container was not restored")
}
if fake.container.Config.Image != old.Config.Image {
t.Fatalf("restored image = %q, want %q", fake.container.Config.Image, old.Config.Image)
}
if fake.container.Config.User != old.Config.User {
t.Fatalf("restored user = %q, want %q", fake.container.Config.User, old.Config.User)
}
if len(fake.createdImages) != 2 {
t.Fatalf("created images = %v, want new image then old image", fake.createdImages)
}
if fake.createdImages[0] != "registry.local:5000/material/flnxi-material:2.0.0" {
t.Fatalf("first created image = %q, want new image", fake.createdImages[0])
}
if fake.createdImages[1] != old.Config.Image {
t.Fatalf("second created image = %q, want old image", fake.createdImages[1])
}
if !contains(fake.removedIDs, "old-container") || !contains(fake.removedIDs, "new-container") {
t.Fatalf("removed IDs = %v, want old and failed new containers removed", fake.removedIDs)
}
}
func resetUpdateStatusForTest() {
statusMutex.Lock()
updateStatus = &UpdateStatus{Status: "running", Step: "初始化", Progress: 0}
statusMutex.Unlock()
}
func resetInstallStatusForTest() {
statusMutex.Lock()
installStatus = &InstallStatus{Status: "running", Step: "初始化", Progress: 0}
statusMutex.Unlock()
}
func envSliceToMap(values []string) map[string]string {
result := make(map[string]string, len(values))
for _, value := range values {
parts := strings.SplitN(value, "=", 2)
if len(parts) != 2 {
continue
}
result[parts[0]] = parts[1]
}
return result
}
type fakeInstallDocker struct {
pulledImages []string
createdContainers []string
}
func (f *fakeInstallDocker) PullImageWithProgress(ctx context.Context, image string, authStr string, onProgress func(current, total int64, status string)) error {
f.pulledImages = append(f.pulledImages, image)
if onProgress != nil {
onProgress(1, 1, "done")
}
return nil
}
func (f *fakeInstallDocker) ExecOnHost(ctx context.Context, cmd string) error {
return nil
}
func (f *fakeInstallDocker) CreateNetworkIfNotExists(ctx context.Context, name string) error {
return nil
}
func (f *fakeInstallDocker) CreateAndStartContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, name string) (string, error) {
f.createdContainers = append(f.createdContainers, name)
return name + "-id", nil
}
type fakeUpdateDocker struct {
container *docker.ContainerInfo
healthErr error
pulledImages []string
createdImages []string
removedIDs []string
stoppedIDs []string
startedIDs []string
}
func (f *fakeUpdateDocker) GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error) {
return f.container, nil
}
func (f *fakeUpdateDocker) PullImage(ctx context.Context, image string, authStr string) error {
f.pulledImages = append(f.pulledImages, image)
return nil
}
func (f *fakeUpdateDocker) StopContainer(ctx context.Context, containerID string) error {
f.stoppedIDs = append(f.stoppedIDs, containerID)
if f.container != nil && f.container.ID == containerID {
f.container.State = "exited"
}
return nil
}
func (f *fakeUpdateDocker) RemoveContainer(ctx context.Context, containerID string) error {
f.removedIDs = append(f.removedIDs, containerID)
if f.container != nil && f.container.ID == containerID {
f.container = nil
}
return nil
}
func (f *fakeUpdateDocker) StartContainer(ctx context.Context, containerID string) error {
f.startedIDs = append(f.startedIDs, containerID)
if f.container != nil && f.container.ID == containerID {
f.container.State = "running"
}
return nil
}
func (f *fakeUpdateDocker) CreateAndStartContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, name string) (string, error) {
f.createdImages = append(f.createdImages, config.Image)
id := "new-container"
if strings.Contains(config.Image, ":1.0.0") {
id = "restored-container"
}
f.container = &docker.ContainerInfo{
ID: id,
ImageID: "sha256:" + id,
State: "running",
Config: config,
HostConfig: hostConfig,
}
return id, nil
}
func (f *fakeUpdateDocker) WaitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error {
return f.healthErr
}
func (f *fakeUpdateDocker) RemoveImage(ctx context.Context, imageID string) error {
return nil
}
func (f *fakeUpdateDocker) PruneImages(ctx context.Context) error {
return nil
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
var _ updateDockerClient = (*fakeUpdateDocker)(nil)
var _ installDockerClient = (*fakeInstallDocker)(nil)