package api import ( "bytes" "context" "encoding/binary" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "sync" "testing" "testing/iotest" "git.misaka.ren/m1saka/dsh-web/internal/docker" "git.misaka.ren/m1saka/dsh-web/internal/store" ) func runtimeTestFrame(payload []byte) []byte { var h [8]byte h[0] = 1 binary.BigEndian.PutUint32(h[4:], uint32(len(payload))) return append(h[:], payload...) } func TestRuntimeTokenFromStreamLargeRawAndFramed(t *testing.T) { for _, tc := range []struct { name string data []byte token string }{ {"raw", append(bytes.Repeat([]byte("x"), 9<<20), []byte("\ndsh web: http://runtime/?token=raw-tail\n")...), "raw-tail"}, {"framed", runtimeTestFrame(append(bytes.Repeat([]byte("x"), 9<<20), []byte("\ndsh web: http://runtime/?token=framed-tail\n")...)), "framed-tail"}, } { t.Run(tc.name, func(t *testing.T) { got, err := runtimeTokenFromStream(bytes.NewReader(tc.data)) if err != nil || got != tc.token { t.Fatalf("token=%q err=%v", got, err) } }) } } func TestRuntimeTokenFromStreamSplitLatestAndTruncatedFrame(t *testing.T) { data := []byte("dsh web: http://runtime/?token=first-token\ndsh web: http://runtime/?token=second-token\n") got, err := runtimeTokenFromStream(iotest.OneByteReader(bytes.NewReader(data))) if err != nil || got != "second-token" { t.Fatalf("token=%q err=%v", got, err) } framed := runtimeTestFrame([]byte("dsh web: http://runtime/?token=truncated")) framed = framed[:len(framed)-2] if _, err := runtimeTokenFromStream(bytes.NewReader(framed)); err == nil { t.Fatal("truncated frame accepted") } } func TestRuntimeCookiePersistenceAndHostExchange(t *testing.T) { var mu sync.Mutex logs, exchanges := 0, 0 var seen []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() if r.URL.Path == "/" { exchanges++ token := r.URL.Query().Get("token") seen = append(seen, token) w.Header().Set("Set-Cookie", "session="+token+"-"+r.Host+"; Max-Age=60") w.WriteHeader(http.StatusSeeOther) return } if strings.HasSuffix(r.URL.Path, "/logs") { logs++ io.WriteString(w, "dsh web: http://runtime/?token=persist-token\n") return } w.WriteHeader(http.StatusNoContent) })) defer upstream.Close() t.Setenv("DOCKER_HOST", upstream.URL) a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {}) a.docker = docker.New(upstream.URL) i := store.Instance{ID: "persist", UID: "owner", ContainerID: "container"} if err := a.s.AddInstance(i); err != nil { t.Fatal(err) } target, _ := url.Parse(upstream.URL) ctx := context.Background() if got, err := a.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" { t.Fatalf("first cookie=%q err=%v", got, err) } b := New(a.s, a.root) b.docker = docker.New(upstream.URL) if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" { t.Fatalf("persisted cookie=%q err=%v", got, err) } if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "localhost"); err != nil || got != "session=persist-token-localhost" { t.Fatalf("localhost cookie=%q err=%v", got, err) } if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" { t.Fatalf("host cookie=%q err=%v", got, err) } proxy := runtimeReverseProxy(target, "/dsh/persist", "panel.test", "session=persist-token-panel.test") b.invalidateRejectedRuntimeCookie(proxy, i, "2025-01-01T00:00:00Z", "panel.test", "session=persist-token-panel.test") if err := proxy.ModifyResponse(&http.Response{StatusCode: 401, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("denied"))}); err != nil { t.Fatal(err) } if got, err := b.runtimeCookie(ctx, i, "2025-01-01T00:00:00Z", target, "panel.test"); err != nil || got != "session=persist-token-panel.test" { t.Fatalf("reexchange: %q %v", got, err) } if _, err := b.runtimeCookie(ctx, i, "2025-01-02T00:00:00Z", target, "panel.test"); err != nil { t.Fatalf("changed start time: %v", err) } mu.Lock() defer mu.Unlock() if logs != 2 { t.Fatalf("log reads=%d, want 2", logs) } if exchanges != 4 { t.Fatalf("exchanges=%d, want 4", exchanges) } for _, token := range seen { if token != "persist-token" { t.Fatalf("exchanged token=%q", token) } } } func TestRuntimeAuthDiskEncryptedAndTamperRejected(t *testing.T) { a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {}) entry := runtimeAuthEntry{Container: "container", Started: "2025-01-01T00:00:00Z", Token: "super-secret-token", Host: "panel.test", Cookies: "session=super-secret-token"} key := entry.Container + "/" + entry.Started if err := a.saveRuntimeAuth("instance", key, entry); err != nil { t.Fatal(err) } path := a.runtimeAuthPath("instance", key) st, err := os.Stat(path) if err != nil || st.Mode().Perm() != 0600 { t.Fatalf("stat=%v mode=%v", err, st.Mode().Perm()) } data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if bytes.Contains(data, []byte(entry.Token)) || bytes.Contains(data, []byte(entry.Cookies)) { t.Fatal("runtime token stored in plaintext") } data[len(data)/2] ^= 1 if err := os.WriteFile(path, data, 0600); err != nil { t.Fatal(err) } if _, ok := a.loadRuntimeAuth("instance", key); ok { t.Fatal("tampered runtime auth accepted") } } func TestRuntimeCookieSaveFailureDoesNotCache(t *testing.T) { var mu sync.Mutex logs := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/logs") { mu.Lock() logs++ mu.Unlock() io.WriteString(w, "dsh web: http://runtime/?token=save-failure-token\n") return } w.Header().Set("Set-Cookie", "session=save-failure; Max-Age=60") w.WriteHeader(http.StatusSeeOther) })) defer upstream.Close() t.Setenv("DOCKER_HOST", upstream.URL) a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {}) badRoot := filepath.Join(t.TempDir(), "not-a-directory") a.docker = docker.New(upstream.URL) if err := os.WriteFile(badRoot, []byte("x"), 0600); err != nil { t.Fatal(err) } a.root = badRoot i := store.Instance{ID: "save-failure", UID: "owner", ContainerID: "container"} target, _ := url.Parse(upstream.URL) for n := 0; n < 2; n++ { if _, err := a.runtimeCookie(context.Background(), i, "2025-01-01T00:00:00Z", target, "panel.test"); err == nil { t.Fatal("save failure accepted") } } mu.Lock() defer mu.Unlock() if logs != 2 { t.Fatalf("log reads=%d after save failures, want 2", logs) } }