Add exact-image compatibility policy, experimental admission, paginated build history and shared reauthentication checks. Preserve uncertain Docker creation records, bound frontend resource waits and renew runtime sessions proactively. Lazy-load and split Monaco, add regression and opt-in live Docker fault tests, and record full race and real-browser acceptance results.
636 lines
22 KiB
Go
636 lines
22 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.misaka.ren/m1saka/dsh-web/internal/docker"
|
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
|
)
|
|
|
|
func runtimeTestAPI(t *testing.T, handler http.HandlerFunc) (*API, string) {
|
|
t.Helper()
|
|
s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(s.Close)
|
|
if err := s.CreateUser(store.User{ID: "owner", Username: "owner"}, "unused"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sid, _, err := s.Session("owner")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
a := &API{s: s, root: t.TempDir(), origin: "http://panel.test", docker: docker.New(server.URL)}
|
|
if err := a.initAuth(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
registerTestAccess(t, a, sid)
|
|
return a, sid
|
|
}
|
|
func runtimeRequest(method, path, body, sid string) *http.Request {
|
|
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
r.Host = "panel.test"
|
|
r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid})
|
|
addTestAccess(r, sid)
|
|
return r
|
|
}
|
|
|
|
func TestRuntimeProxyRejectsErrorState(t *testing.T) {
|
|
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("error-state proxy must not contact Docker")
|
|
})
|
|
if err := a.s.AddInstance(store.Instance{ID: "broken", UID: "owner", Status: "error", Created: time.Now()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
a.proxy(w, runtimeRequest("GET", "/dsh/broken/", "", sid))
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("status = %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// createRuntime holds the instance lock during creation; a concurrent DELETE
|
|
// must wait instead of removing the row while the container is still starting.
|
|
func TestInstanceDeleteWaitsForInFlightCreate(t *testing.T) {
|
|
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "GET" && strings.Contains(r.URL.Path, "/networks/") {
|
|
w.WriteHeader(404)
|
|
return
|
|
}
|
|
if r.Method == http.MethodGet && r.URL.Path == "/v1.47/containers/dsh-inst-creating/json" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/networks/") {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
t.Errorf("unexpected Docker call %s %s", r.Method, r.URL.Path)
|
|
})
|
|
if err := a.s.AddInstance(store.Instance{ID: "creating", UID: "owner", Status: "creating", Created: time.Now()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inst := a.instanceLocks.Acquire("creating")
|
|
done := make(chan *httptest.ResponseRecorder, 1)
|
|
go func() {
|
|
w := httptest.NewRecorder()
|
|
a.instance(w, runtimeRequest(http.MethodDelete, "/api/instances/creating", "", sid))
|
|
done <- w
|
|
}()
|
|
select {
|
|
case w := <-done:
|
|
t.Fatalf("delete interleaved with creation: %d %s", w.Code, w.Body.String())
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
inst.Release()
|
|
select {
|
|
case w := <-done:
|
|
if w.Code != 200 {
|
|
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("delete stalled after creation finished")
|
|
}
|
|
if _, err := a.s.GetInstance("creating"); err == nil {
|
|
t.Fatal("instance row retained")
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCreateLimitAndPolicy(t *testing.T) {
|
|
if os.Geteuid() != 0 {
|
|
t.Skip("chown requires root")
|
|
}
|
|
t.Setenv("HOST_DATA_ROOT", "/host/data")
|
|
t.Setenv("PANEL_CONTAINER_ID", "panel")
|
|
imageID := "sha256:" + strings.Repeat("a", 64)
|
|
var mu sync.Mutex
|
|
var bodies []map[string]any
|
|
var calls []string
|
|
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
calls = append(calls, r.Method+" "+r.URL.Path)
|
|
switch {
|
|
case strings.Contains(r.URL.Path, "/images/"):
|
|
writeJSON(w, map[string]any{"Id": imageID, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": "1.2.3", "dsh-mgr.recipe": imageRecipeRev}}})
|
|
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Error(err)
|
|
}
|
|
bodies = append(bodies, body)
|
|
writeJSON(w, map[string]string{"Id": fmt.Sprintf("container%d", len(bodies))})
|
|
default:
|
|
w.WriteHeader(204)
|
|
}
|
|
})
|
|
body := fmt.Sprintf("{\"name\":\"one\",\"image_id\":%q}", imageID)
|
|
seedVerifiedImage(t, a, imageID, "1.2.3")
|
|
var wg sync.WaitGroup
|
|
statuses := make(chan int, 4)
|
|
for n := 0; n < 4; n++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
w := httptest.NewRecorder()
|
|
a.instances(w, runtimeRequest("POST", "/api/instances", body, sid))
|
|
statuses <- w.Code
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(statuses)
|
|
successes, limits := 0, 0
|
|
for status := range statuses {
|
|
if status == 200 {
|
|
successes++
|
|
} else if status == 409 {
|
|
limits++
|
|
} else {
|
|
t.Errorf("status %d", status)
|
|
}
|
|
}
|
|
if successes != 2 || limits != 2 {
|
|
t.Fatalf("success=%d limit=%d", successes, limits)
|
|
}
|
|
instances, err := a.s.Instances("owner")
|
|
if err != nil || len(instances) != 2 {
|
|
t.Fatalf("instances=%v err=%v", instances, err)
|
|
}
|
|
for _, i := range instances {
|
|
if i.Version != "1.2.3" || i.ContainerID == "" || i.Status != "running" {
|
|
t.Errorf("%+v", i)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(a.root, "workspaces", "owner", "instances", i.ID, "home")); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
for _, b := range bodies {
|
|
h := b["HostConfig"].(map[string]any)
|
|
mounts := h["Mounts"].([]any)
|
|
if len(mounts) != 1 {
|
|
t.Fatalf("unexpected mounts: %v", mounts)
|
|
}
|
|
mount := mounts[0].(map[string]any)
|
|
if mount["Target"] != "/workspace" || !strings.HasSuffix(mount["Source"].(string), "/home") {
|
|
t.Fatal(mount)
|
|
}
|
|
if !strings.Contains(fmt.Sprint(b["Env"]), "DSH_HOME=/workspace") {
|
|
t.Fatal("wrong data root", b["Env"])
|
|
}
|
|
if b["User"] != "1000:1000" || h["ReadonlyRootfs"] != true || h["Memory"] != float64(2<<30) || h["NanoCpus"] != float64(2e9) || h["PidsLimit"] != float64(256) {
|
|
t.Errorf("policy %v", b)
|
|
}
|
|
if h["PortBindings"] != nil || h["PublishAllPorts"] != nil {
|
|
t.Error("ports published")
|
|
}
|
|
if fmt.Sprint(h["CapDrop"]) != "[ALL]" || !strings.Contains(fmt.Sprint(h["Tmpfs"]), "exec") {
|
|
t.Error(h)
|
|
}
|
|
if strings.Contains(fmt.Sprint(b["Cmd"]), "public-url") || !strings.Contains(fmt.Sprint(b["Cmd"]), "--trusted-host panel.test") {
|
|
t.Error(b["Cmd"])
|
|
}
|
|
if !strings.Contains(fmt.Sprint(h["Mounts"]), "/host/data/workspaces/owner") {
|
|
t.Error(h["Mounts"])
|
|
}
|
|
}
|
|
if !strings.Contains(strings.Join(calls, "\n"), "/connect") {
|
|
t.Fatal("panel not connected")
|
|
}
|
|
}
|
|
|
|
func TestRuntimeLifecycleAndOwnership(t *testing.T) {
|
|
t.Setenv("PANEL_CONTAINER_ID", "")
|
|
var calls []string
|
|
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls = append(calls, r.Method+" "+r.URL.Path)
|
|
if r.Method == "GET" && strings.Contains(r.URL.Path, "/networks/") {
|
|
w.WriteHeader(404)
|
|
return
|
|
}
|
|
if strings.HasSuffix(r.URL.Path, "/json") {
|
|
io.WriteString(w, "{\"State\":{\"Status\":\"running\"}}")
|
|
} else if strings.HasSuffix(r.URL.Path, "/logs") {
|
|
io.WriteString(w, "dsh web: http://localhost/?token=secret-value\nAuthorization: Bearer another-secret\n")
|
|
} else {
|
|
w.WriteHeader(204)
|
|
}
|
|
})
|
|
i := store.Instance{ID: "instance", UID: "owner", ContainerID: "container"}
|
|
if err := a.s.AddInstance(i); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := filepath.Join(a.root, "workspaces", "owner", "instances", i.ID, "home")
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, action := range []string{"start", "stop", "restart"} {
|
|
w := httptest.NewRecorder()
|
|
a.instance(w, runtimeRequest("POST", "/api/instances/instance/"+action, "", sid))
|
|
if w.Code != 200 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
w = httptest.NewRecorder()
|
|
a.instance(w, runtimeRequest("GET", "/api/instances/instance/"+action, "", sid))
|
|
if w.Code != 405 {
|
|
t.Fatal(w.Code)
|
|
}
|
|
}
|
|
w := httptest.NewRecorder()
|
|
a.instance(w, runtimeRequest("GET", "/api/instances/instance/logs", "", sid))
|
|
if strings.Contains(w.Body.String(), "secret-value") || strings.Contains(w.Body.String(), "another-secret") {
|
|
t.Fatal("credentials in logs")
|
|
}
|
|
foreign := store.Instance{ID: "foreign", UID: "someone", ContainerID: "other"}
|
|
a.s.AddInstance(foreign)
|
|
before := len(calls)
|
|
for _, path := range []string{"/api/instances/foreign/start", "/api/instances/foreign/logs"} {
|
|
w = httptest.NewRecorder()
|
|
a.instance(w, runtimeRequest("POST", path, "", sid))
|
|
if w.Code != 404 {
|
|
t.Fatal(w.Code)
|
|
}
|
|
}
|
|
w = httptest.NewRecorder()
|
|
a.proxy(w, runtimeRequest("GET", "/dsh/foreign/", "", sid))
|
|
if w.Code != 404 || len(calls) != before {
|
|
t.Fatal("owner check bypass")
|
|
}
|
|
w = httptest.NewRecorder()
|
|
a.instance(w, runtimeRequest("DELETE", "/api/instances/instance", "", sid))
|
|
if w.Code != 200 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
if _, err := os.Stat(dir); err != nil {
|
|
t.Fatal("data removed", err)
|
|
}
|
|
if _, err := a.s.GetInstance(i.ID); err == nil {
|
|
t.Fatal("row retained")
|
|
}
|
|
if !strings.Contains(strings.Join(calls, "\n"), "DELETE /v1.47/containers/container") {
|
|
t.Fatal(calls)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeTokenOriginAndExchange(t *testing.T) {
|
|
logs := "dsh web: http://localhost/?token=old\ndsh web: http://localhost/?token=latest%2Btoken\x1b[0m\n"
|
|
if got := latestRuntimeToken(logs); got != "latest+token" {
|
|
t.Fatal(got)
|
|
}
|
|
if got := latestRuntimeToken(logs + `{"uri":"/?token=old"}`); got != "latest+token" {
|
|
t.Fatalf("proxy log replaced startup token: %q", got)
|
|
}
|
|
if strings.Contains(redactRuntimeLogs(logs), "latest") || strings.Contains(redactRuntimeLogs(logs), "old") {
|
|
t.Fatal("token not redacted")
|
|
}
|
|
a := &API{origin: "http://panel.test"}
|
|
for _, tc := range []struct {
|
|
method, origin, upgrade string
|
|
want bool
|
|
}{{"GET", "", "", true}, {"POST", "", "", false}, {"POST", "http://panel.test", "", true}, {"POST", "http://evil.test", "", false}, {"GET", "", "websocket", false}, {"GET", "http://panel.test", "websocket", true}} {
|
|
r := runtimeRequest(tc.method, "/dsh/id/", "", "")
|
|
r.Header.Set("Origin", tc.origin)
|
|
r.Header.Set("Upgrade", tc.upgrade)
|
|
if a.runtimeOriginAllowed(r) != tc.want {
|
|
t.Errorf("%+v", tc)
|
|
}
|
|
}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "GET" || r.URL.Query().Get("token") != "latest+token" || r.Host != "panel.test" {
|
|
t.Error("invalid exchange")
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "server-secret", Path: "/"})
|
|
w.Header().Set("Location", "/")
|
|
w.WriteHeader(303)
|
|
}))
|
|
defer server.Close()
|
|
target, _ := url.Parse(server.URL)
|
|
cookie, err := exchangeRuntimeCookie(context.Background(), target, "panel.test", "latest+token")
|
|
if err != nil || cookie != "dsh_auth=server-secret" {
|
|
t.Fatalf("exchange failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeProxyHeadersRedirectAndSSE(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Cookie") != "dsh_auth=server-secret" || r.Header.Get("Authorization") != "" || r.Header.Get("X-CSRF-Token") != "" || r.URL.Query().Get("token") != "" {
|
|
t.Error("credential boundary violated")
|
|
}
|
|
if r.Host != "panel.test" {
|
|
t.Error(r.Host)
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "not-for-browser"})
|
|
if r.URL.Path == "/redirect" {
|
|
w.Header().Set("Location", "/next?token=not-for-browser")
|
|
w.WriteHeader(303)
|
|
return
|
|
}
|
|
if r.URL.Path != "/events" {
|
|
t.Error(r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
io.WriteString(w, "data: first\n\n")
|
|
w.(http.Flusher).Flush()
|
|
<-r.Context().Done()
|
|
}))
|
|
defer upstream.Close()
|
|
target, _ := url.Parse(upstream.URL)
|
|
proxy := httptest.NewServer(runtimeReverseProxy(target, "/dsh/id", "panel.test", "dsh_auth=server-secret"))
|
|
defer proxy.Close()
|
|
client := &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
for _, path := range []string{"redirect", "events"} {
|
|
req, _ := http.NewRequest("GET", proxy.URL+"/dsh/id/"+path+"?token=browser-token", nil)
|
|
req.Header.Set("Cookie", "dsh_session=panel-secret")
|
|
req.Header.Set("Authorization", "Bearer panel-secret")
|
|
req.Header.Set("X-CSRF-Token", "panel-secret")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Header.Get("Set-Cookie") != "" {
|
|
t.Fatal("cookie escaped")
|
|
}
|
|
if path == "redirect" {
|
|
if resp.Header.Get("Location") != "/dsh/id/next" {
|
|
t.Fatal(resp.Header.Get("Location"))
|
|
}
|
|
} else {
|
|
line, err := bufio.NewReader(resp.Body).ReadString('\n')
|
|
if err != nil || line != "data: first\n" {
|
|
t.Fatal("SSE did not flush", err)
|
|
}
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
func TestRuntimeProxyPreservesComboQuery(t *testing.T) {
|
|
const query = "?@deepseek-ai/dsh-client-modules/client.js,@deepseek-ai/dsh-client-ui/client.js&rev=abc"
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.RawQuery != query {
|
|
t.Errorf("combo query changed: %q", r.URL.RawQuery)
|
|
}
|
|
w.Header().Set("Location", "/plugins/?"+query+"&%74oken=secret")
|
|
w.WriteHeader(http.StatusTemporaryRedirect)
|
|
}))
|
|
defer upstream.Close()
|
|
target, _ := url.Parse(upstream.URL)
|
|
proxy := runtimeReverseProxy(target, "/dsh/id", "panel.test", "")
|
|
for _, suffix := range []string{"", "&token=secret", "&%74oken=secret&token=second"} {
|
|
w := httptest.NewRecorder()
|
|
proxy.ServeHTTP(w, httptest.NewRequest("GET", "http://panel.test/dsh/id/plugins/?"+query+suffix, nil))
|
|
if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != "/dsh/id/plugins/?"+query {
|
|
t.Fatalf("unexpected combo redirect: %d %q", w.Code, w.Header().Get("Location"))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuntimeProxyWebSocketUpgrade(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/socket" || r.Header.Get("Cookie") != "dsh_auth=secret" || r.Header.Get("Authorization") != "" {
|
|
t.Error("invalid upgraded request")
|
|
}
|
|
conn, rw, err := w.(http.Hijacker).Hijack()
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
rw.Flush()
|
|
data := make([]byte, 4)
|
|
if _, err := io.ReadFull(rw, data); err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
rw.Write(data)
|
|
rw.Flush()
|
|
}))
|
|
defer upstream.Close()
|
|
target, _ := url.Parse(upstream.URL)
|
|
proxy := httptest.NewServer(runtimeReverseProxy(target, "/dsh/id", "panel.test", "dsh_auth=secret"))
|
|
defer proxy.Close()
|
|
conn, err := net.DialTimeout("tcp", strings.TrimPrefix(proxy.URL, "http://"), time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Close()
|
|
conn.SetDeadline(time.Now().Add(3 * time.Second))
|
|
fmt.Fprint(conn, "GET /dsh/id/socket HTTP/1.1\r\nHost: panel.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nAuthorization: Bearer panel-secret\r\n\r\n")
|
|
reader := bufio.NewReader(conn)
|
|
resp, err := http.ReadResponse(reader, nil)
|
|
if err != nil || resp.StatusCode != 101 {
|
|
t.Fatalf("upgrade: %v %v", resp, err)
|
|
}
|
|
fmt.Fprint(conn, "ping")
|
|
data := make([]byte, 4)
|
|
if _, err := io.ReadFull(reader, data); err != nil || string(data) != "ping" {
|
|
t.Fatal("upgrade tunnel failed", err)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCreateRollbackAndImageRejection(t *testing.T) {
|
|
if os.Geteuid() != 0 {
|
|
t.Skip("chown requires root")
|
|
}
|
|
t.Setenv("PANEL_CONTAINER_ID", "")
|
|
t.Setenv("HOST_DATA_ROOT", "")
|
|
imageID := "sha256:" + strings.Repeat("b", 64)
|
|
for _, role := range []string{"foreign", "runtime"} {
|
|
t.Run(role, func(t *testing.T) {
|
|
var calls []string
|
|
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls = append(calls, r.Method+" "+r.URL.Path)
|
|
switch {
|
|
case r.Method == "GET" && strings.Contains(r.URL.Path, "/networks/"):
|
|
name := strings.TrimPrefix(r.URL.Path, "/v1.47/networks/")
|
|
writeJSON(w, map[string]any{"Id": name, "Name": name, "Driver": "bridge", "Labels": map[string]string{"dsh-mgr.instance": strings.TrimPrefix(name, "dsh-inst-")}})
|
|
case strings.Contains(r.URL.Path, "/images/"):
|
|
writeJSON(w, map[string]any{"Id": imageID, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": role, "dsh-mgr.version": "1", "dsh-mgr.recipe": imageRecipeRev}}})
|
|
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
|
writeJSON(w, map[string]string{"Id": "container"})
|
|
case strings.HasSuffix(r.URL.Path, "/start"):
|
|
w.WriteHeader(500)
|
|
io.WriteString(w, "token=must-not-leak")
|
|
default:
|
|
w.WriteHeader(204)
|
|
}
|
|
})
|
|
seedVerifiedImage(t, a, imageID, "1")
|
|
w := httptest.NewRecorder()
|
|
a.instances(w, runtimeRequest("POST", "/api/instances", fmt.Sprintf("{\"name\":\"test\",\"image_id\":%q}", imageID), sid))
|
|
want := 400
|
|
if role == "runtime" {
|
|
want = 502
|
|
}
|
|
if w.Code != want {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
if strings.Contains(w.Body.String(), "must-not-leak") {
|
|
t.Fatal("docker error leaked")
|
|
}
|
|
instances, err := a.s.Instances("owner")
|
|
if err != nil || len(instances) != 0 {
|
|
t.Fatal("reservation not rolled back", err)
|
|
}
|
|
if role == "foreign" && len(calls) != 1 {
|
|
t.Fatal("untrusted image used")
|
|
}
|
|
if role == "runtime" && !strings.Contains(strings.Join(calls, "\n"), "DELETE /v1.47/containers/container") {
|
|
t.Fatal("container not cleaned")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRuntimeProxyMissingNetwork(t *testing.T) {
|
|
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
io.WriteString(w, "{\"State\":{\"Running\":true},\"NetworkSettings\":{\"Networks\":null}}")
|
|
})
|
|
if err := a.s.AddInstance(store.Instance{ID: "instance", UID: "owner", ContainerID: "container"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
a.proxy(w, runtimeRequest("GET", "/dsh/instance/", "", sid))
|
|
if w.Code != 502 {
|
|
t.Fatal(w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCookieCacheRestart(t *testing.T) {
|
|
exchanges, logs := 0, 0
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
exchanges++
|
|
token := r.URL.Query().Get("token")
|
|
if token != "first" && token != "second" {
|
|
t.Error("wrong token")
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: token})
|
|
w.WriteHeader(303)
|
|
}))
|
|
defer upstream.Close()
|
|
target, _ := url.Parse(upstream.URL)
|
|
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
logs++
|
|
token := "first"
|
|
if r.URL.Query().Get("since") == "2.000000000" {
|
|
token = "second"
|
|
} else if r.URL.Query().Get("since") != "1.000000000" {
|
|
t.Error("missing startup filter")
|
|
}
|
|
io.WriteString(w, "dsh web: http://localhost/?token="+token+"\n")
|
|
})
|
|
i := store.Instance{ID: "cache-test", ContainerID: "container"}
|
|
defer runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
|
|
for n, started := range []string{"1970-01-01T00:00:01Z", "1970-01-01T00:00:01Z", "1970-01-01T00:00:02Z"} {
|
|
cookie, err := a.runtimeCookie(context.Background(), i, started, target, "panel.test")
|
|
want := "dsh_auth=first"
|
|
if n == 2 {
|
|
want = "dsh_auth=second"
|
|
}
|
|
if err != nil || cookie != want {
|
|
t.Fatal("cookie exchange", err)
|
|
}
|
|
}
|
|
if exchanges != 2 || logs != 2 {
|
|
t.Fatalf("exchanges=%d logs=%d", exchanges, logs)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCookieCurrentStartupLargeMultiplexedLogs(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("token") != "current" {
|
|
t.Error("stale token exchanged")
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "current"})
|
|
w.WriteHeader(303)
|
|
}))
|
|
defer upstream.Close()
|
|
target, _ := url.Parse(upstream.URL)
|
|
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("since") != "2.123456789" {
|
|
t.Error("logs not filtered by StartedAt")
|
|
io.WriteString(w, strings.Repeat("old logs\n", 200000))
|
|
return
|
|
}
|
|
for _, payload := range []string{strings.Repeat("startup noise\n", 100000), "http://localhost/?token=current\n"} {
|
|
header := [8]byte{1}
|
|
binary.BigEndian.PutUint32(header[4:], uint32(len(payload)))
|
|
w.Write(header[:])
|
|
io.WriteString(w, payload)
|
|
}
|
|
})
|
|
i := store.Instance{ID: "large-log", ContainerID: "container"}
|
|
defer runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
|
|
cookie, err := a.runtimeCookie(context.Background(), i, "1970-01-01T00:00:02.123456789Z", target, "panel.test")
|
|
if err != nil || cookie != "dsh_auth=current" {
|
|
t.Fatalf("cookie=%s err=%v", cookie, err)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCookieRejectsTruncatedLogs(t *testing.T) {
|
|
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
io.WriteString(w, "http://localhost/?token=partial\n"+strings.Repeat("x", 8<<20))
|
|
})
|
|
i := store.Instance{ID: "oversize", ContainerID: "container"}
|
|
defer runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
|
|
if _, err := a.runtimeCookie(context.Background(), i, "1970-01-01T00:00:01Z", nil, "panel.test"); err == nil {
|
|
t.Fatal("truncated log accepted")
|
|
}
|
|
}
|
|
|
|
func TestRuntimeCreateRejectsTrailingJSON(t *testing.T) {
|
|
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("invalid request reached Docker")
|
|
w.WriteHeader(500)
|
|
})
|
|
body := fmt.Sprintf(`{"name":"test","image_id":%q} {}`, "sha256:"+strings.Repeat("a", 64))
|
|
w := httptest.NewRecorder()
|
|
a.instances(w, runtimeRequest("POST", "/api/instances", body, sid))
|
|
if w.Code != 400 {
|
|
t.Fatal(w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRuntimeBlocksPendingUpgrade(t *testing.T) {
|
|
a, sid := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("pending upgrade reached Docker")
|
|
w.WriteHeader(500)
|
|
})
|
|
if err := a.s.AddInstance(store.Instance{ID: "pending", UID: "owner", ContainerID: "old"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.s.DB.Exec("CREATE TABLE upgrade_journal(instance_id TEXT,phase TEXT); INSERT INTO upgrade_journal VALUES('pending','snapshotting')"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tc := range []struct{ method, path string }{{"POST", "/api/instances/pending/start"}, {"POST", "/api/instances/pending/restart"}, {"DELETE", "/api/instances/pending"}, {"GET", "/dsh/pending/"}} {
|
|
w := httptest.NewRecorder()
|
|
r := runtimeRequest(tc.method, tc.path, "", sid)
|
|
if strings.HasPrefix(tc.path, "/dsh/") {
|
|
a.proxy(w, r)
|
|
} else {
|
|
a.instance(w, r)
|
|
}
|
|
if w.Code != 409 {
|
|
t.Fatalf("%s: %d %s", tc.path, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
}
|