package webui import ( "context" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "banger/internal/api" "banger/internal/model" "banger/internal/paths" ) type fakeBackend struct { layout paths.Layout config model.DaemonConfig summary api.DashboardSummary vms []model.VMRecord images []model.Image vm model.VMRecord image model.Image ports api.VMPortsResult createOp api.VMCreateOperation } func (f fakeBackend) Config() model.DaemonConfig { return f.config } func (f fakeBackend) Layout() paths.Layout { return f.layout } func (f fakeBackend) DashboardSummary(context.Context) (api.DashboardSummary, error) { return f.summary, nil } func (f fakeBackend) ListVMs(context.Context) ([]model.VMRecord, error) { return f.vms, nil } func (f fakeBackend) FindVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } func (f fakeBackend) GetVMStats(context.Context, string) (model.VMRecord, model.VMStats, error) { return f.vm, f.vm.Stats, nil } func (f fakeBackend) BeginVMCreate(context.Context, api.VMCreateParams) (api.VMCreateOperation, error) { return f.createOp, nil } func (f fakeBackend) VMCreateStatus(context.Context, string) (api.VMCreateOperation, error) { return f.createOp, nil } func (f fakeBackend) StartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } func (f fakeBackend) StopVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } func (f fakeBackend) RestartVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } func (f fakeBackend) DeleteVM(context.Context, string) (model.VMRecord, error) { return f.vm, nil } func (f fakeBackend) SetVM(context.Context, api.VMSetParams) (model.VMRecord, error) { return f.vm, nil } func (f fakeBackend) PortsVM(context.Context, string) (api.VMPortsResult, error) { return f.ports, nil } func (f fakeBackend) ListImages(context.Context) ([]model.Image, error) { return f.images, nil } func (f fakeBackend) FindImage(context.Context, string) (model.Image, error) { return f.image, nil } func (f fakeBackend) RegisterImage(context.Context, api.ImageRegisterParams) (model.Image, error) { return f.image, nil } func (f fakeBackend) PromoteImage(context.Context, string) (model.Image, error) { return f.image, nil } func (f fakeBackend) DeleteImage(context.Context, string) (model.Image, error) { return f.image, nil } func TestDashboardPageRendersSummaryAndTables(t *testing.T) { backend := fakeBackend{ layout: paths.Layout{StateDir: t.TempDir()}, config: model.DaemonConfig{SSHKeyPath: "/tmp/id"}, summary: api.DashboardSummary{ Host: api.HostSummary{CPUCount: 8, TotalMemoryBytes: 16 << 30, StateFilesystemFreeBytes: 9 << 30, StateFilesystemTotalBytes: 20 << 30}, Sudo: api.SudoStatus{Available: true, Command: "sudo -v"}, Banger: api.BangerSummary{ VMCount: 1, RunningVMCount: 1, ImageCount: 1, ManagedImageCount: 1, ConfiguredVCPUCount: 2, ConfiguredMemoryBytes: 1 << 30, ConfiguredDiskBytes: 8 << 30, UsedWorkDiskBytes: 3 << 30, }, }, vms: []model.VMRecord{{ID: "vm-1", Name: "smth", State: model.VMStateRunning, CreatedAt: model.Now(), Runtime: model.VMRuntime{GuestIP: "172.16.0.2"}, Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}}}, images: []model.Image{{ID: "img-1", Name: "void", Managed: true, RootfsPath: "/tmp/rootfs.ext4", CreatedAt: model.Now()}}, } req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() NewHandler(backend).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := rec.Body.String() for _, want := range []string{"vCPU", "2 / 8", "1G / 16G", "8G / 20G", "9G free", "smth", "void", "Create VM"} { if !strings.Contains(body, want) { t.Fatalf("body missing %q\n%s", want, body) } } if len(rec.Result().Cookies()) == 0 { t.Fatal("expected csrf cookie to be set") } } func TestVMActionRejectsMissingCSRF(t *testing.T) { backend := fakeBackend{ layout: paths.Layout{StateDir: t.TempDir()}, summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}}, vm: model.VMRecord{ID: "vm-1", Name: "smth"}, } req := httptest.NewRequest(http.MethodPost, "/vms/vm-1/start", strings.NewReader("")) req.Header.Set("Origin", "http://example.com") rec := httptest.NewRecorder() NewHandler(backend).ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rec.Code) } } func TestFSAPIListsEntries(t *testing.T) { dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, "nested"), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } if err := os.WriteFile(filepath.Join(dir, "rootfs.ext4"), []byte("data"), 0o644); err != nil { t.Fatalf("write: %v", err) } backend := fakeBackend{ layout: paths.Layout{StateDir: dir}, summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}}, } req := httptest.NewRequest(http.MethodGet, "/api/fs?path="+url.QueryEscape(dir)+"&kind=file", nil) rec := httptest.NewRecorder() NewHandler(backend).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } data, err := io.ReadAll(rec.Body) if err != nil { t.Fatalf("ReadAll: %v", err) } body := string(data) for _, want := range []string{"rootfs.ext4", "nested"} { if !strings.Contains(body, want) { t.Fatalf("body missing %q\n%s", want, body) } } } func TestVMShowPageRendersRunningActions(t *testing.T) { backend := fakeBackend{ layout: paths.Layout{StateDir: t.TempDir()}, config: model.DaemonConfig{SSHKeyPath: "/tmp/id"}, summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true, Command: "sudo -v"}}, vm: model.VMRecord{ ID: "vm-1", Name: "smth", State: model.VMStateRunning, Runtime: model.VMRuntime{ GuestIP: "172.16.0.2", }, Spec: model.VMSpec{ VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30, }, Stats: model.VMStats{ CPUPercent: 12.5, RSSBytes: 64 << 20, SystemOverlayBytes: 2 << 20, WorkDiskBytes: 32 << 20, }, }, image: model.Image{ID: "img-1", Name: "void"}, ports: api.VMPortsResult{ Name: "smth", Ports: []api.VMPort{ {Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "devserver"}, }, }, } req := httptest.NewRequest(http.MethodGet, "/vms/vm-1", nil) rec := httptest.NewRecorder() NewHandler(backend).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := rec.Body.String() for _, want := range []string{"Stop", "Restart", "href=\"http://172.16.0.2:4096\"", "data-confirm=\"Stop VM smth?\"", "data-confirm=\"Delete VM smth?\""} { if !strings.Contains(body, want) { t.Fatalf("body missing %q\n%s", want, body) } } for _, unwanted := range []string{"root@172.16.0.2"} { if strings.Contains(body, unwanted) { t.Fatalf("body unexpectedly contains %q\n%s", unwanted, body) } } } func TestVMListShowsImageNameAndLink(t *testing.T) { backend := fakeBackend{ layout: paths.Layout{StateDir: t.TempDir()}, summary: api.DashboardSummary{Sudo: api.SudoStatus{Available: true}}, vms: []model.VMRecord{ {ID: "vm-1", Name: "smth", ImageID: "img-1", State: model.VMStateRunning, CreatedAt: model.Now(), Spec: model.VMSpec{VCPUCount: 2, MemoryMiB: 1024, WorkDiskSizeBytes: 8 << 30}}, }, images: []model.Image{ {ID: "img-1", Name: "void"}, }, } req := httptest.NewRequest(http.MethodGet, "/vms", nil) rec := httptest.NewRecorder() NewHandler(backend).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } body := rec.Body.String() for _, want := range []string{">void", "href=\"/images/img-1\""} { if !strings.Contains(body, want) { t.Fatalf("body missing %q\n%s", want, body) } } }