189 lines
6.8 KiB
Go
189 lines
6.8 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.misaka.ren/m1saka/dsh-web/internal/docker"
|
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
|
)
|
|
|
|
func previewFrame(stream byte, text string) []byte {
|
|
header := make([]byte, 8)
|
|
header[0] = stream
|
|
binary.BigEndian.PutUint32(header[4:], uint32(len(text)))
|
|
return append(header, []byte(text)...)
|
|
}
|
|
|
|
func TestProfileDumpFixedCommandAndRedaction(t *testing.T) {
|
|
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/v1.47/containers/container/exec":
|
|
var request struct {
|
|
Cmd []string
|
|
AttachStdout, AttachStderr, Tty bool
|
|
}
|
|
if json.NewDecoder(r.Body).Decode(&request) != nil {
|
|
t.Error("invalid exec request")
|
|
}
|
|
if r.Method != "POST" || !reflect.DeepEqual(request.Cmd, []string{"dsh", "--profile", "web", "--dump-config"}) || !request.AttachStdout || !request.AttachStderr || request.Tty {
|
|
t.Errorf("unsafe command: %+v", request)
|
|
}
|
|
writeJSON(w, map[string]string{"Id": "exec"})
|
|
case "/v1.47/exec/exec/start":
|
|
w.Write(previewFrame(2, "stderr-secret"))
|
|
w.Write(previewFrame(1, "provider:\n apiKey: direct-secret\n API_KEY: underscore-secret\n password: numeric-secret\n nested:\n - token: nested-secret\n arbitrary: resolved-credential\n endpoint: https://name:[email protected]/?token=secret\n enabled: true\n"))
|
|
case "/v1.47/exec/exec/json":
|
|
writeJSON(w, map[string]any{"Running": false, "ExitCode": 0})
|
|
default:
|
|
t.Errorf("unexpected Docker call %s", r.URL.Path)
|
|
w.WriteHeader(404)
|
|
}
|
|
}))
|
|
defer daemon.Close()
|
|
a := &API{docker: docker.New(daemon.URL)}
|
|
out, err := a.dumpProfile(context.Background(), "container")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, secret := range []string{"secret", "resolved-credential", "https://"} {
|
|
if strings.Contains(out, secret) {
|
|
t.Fatalf("secret leaked: %s", out)
|
|
}
|
|
}
|
|
if !strings.Contains(out, "[REDACTED]") || !strings.Contains(out, "enabled: true") {
|
|
t.Fatalf("missing structure: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestProfileDumpPluginSequence(t *testing.T) {
|
|
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/exec"):
|
|
writeJSON(w, map[string]string{"Id": "exec"})
|
|
case strings.HasSuffix(r.URL.Path, "/start"):
|
|
w.Write(previewFrame(1, "# bundle\n- id: plugin\n disabled: !!js '!ctx.get(\"profileContext\")'\n config:\n apiKey: private-secret\n enabled: true\n"))
|
|
default:
|
|
writeJSON(w, map[string]any{"Running": false, "ExitCode": 0})
|
|
}
|
|
}))
|
|
defer daemon.Close()
|
|
a := &API{docker: docker.New(daemon.URL)}
|
|
out, err := a.dumpProfile(context.Background(), "container")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(out, "private-secret") || strings.Contains(out, "ctx.get") || !strings.Contains(out, "enabled: true") || !strings.Contains(out, "[REDACTED]") {
|
|
t.Fatalf("invalid sequence redaction: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestProfileDumpRejectsUnsafeOutput(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, output string
|
|
exit int
|
|
}{
|
|
{"invalid", "apiKey: [secret", 0}, {"plain", "secret", 0},
|
|
{"documents", "a: b\n---\napiKey: secret", 0}, {"failure", "a: b", 1},
|
|
{"empty", "", 0},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/exec"):
|
|
writeJSON(w, map[string]string{"Id": "exec"})
|
|
case strings.HasSuffix(r.URL.Path, "/start"):
|
|
w.Write(previewFrame(1, tc.output))
|
|
default:
|
|
writeJSON(w, map[string]any{"Running": false, "ExitCode": tc.exit})
|
|
}
|
|
}))
|
|
defer daemon.Close()
|
|
a := &API{docker: docker.New(daemon.URL)}
|
|
if out, err := a.dumpProfile(context.Background(), "container"); err == nil || out != "" {
|
|
t.Fatalf("unsafe output returned: %q, %v", out, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreviewStreamBounds(t *testing.T) {
|
|
for _, data := range [][]byte{
|
|
[]byte("raw secret"), {1, 0}, previewFrame(3, "secret"),
|
|
previewFrame(1, strings.Repeat("x", previewLimit)),
|
|
previewFrame(1, "secret")[:10],
|
|
} {
|
|
if _, err := previewStdout(bytes.NewReader(data)); err == nil {
|
|
t.Fatal("accepted invalid stream")
|
|
}
|
|
}
|
|
data := append(previewFrame(2, "secret"), previewFrame(1, "safe")...)
|
|
got, err := previewStdout(bytes.NewReader(data))
|
|
if err != nil || string(got) != "safe" {
|
|
t.Fatalf("stdout: %q %v", got, err)
|
|
}
|
|
}
|
|
|
|
func previewFixture(t *testing.T) (*API, store.Instance, string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
i := store.Instance{ID: "instance", UID: "owner", ContainerID: "container", Status: "stopped"}
|
|
path := filepath.Join(root, "workspaces", i.UID, "instances", i.ID, "home", "profiles", "web")
|
|
if err := os.MkdirAll(path, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &API{root: root}, i, path
|
|
}
|
|
|
|
func TestProfilePreviewManifestAndErrors(t *testing.T) {
|
|
a, i, path := previewFixture(t)
|
|
if err := os.WriteFile(filepath.Join(path, "package.json"), []byte(`{"dsh":{"profile":{"bundles":["@deepseek/bundle"],"apiKey":"private-value"}},"scripts":{"secret":"ignored-value"}}`), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
a.profilePreview(w, httptest.NewRequest("GET", "/preview?cmd=cat&path=.credentials.yaml", nil), i)
|
|
if w.Code != 200 || !strings.Contains(w.Body.String(), "@deepseek/bundle") || strings.Contains(w.Body.String(), "private-value") || strings.Contains(w.Body.String(), "ignored-value") {
|
|
t.Fatalf("manifest response: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if w.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatal("preview must not be cached")
|
|
}
|
|
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "daemon-private-value", 500) }))
|
|
defer daemon.Close()
|
|
a.docker = docker.New(daemon.URL)
|
|
i.Status = "running"
|
|
w = httptest.NewRecorder()
|
|
a.profilePreview(w, httptest.NewRequest("GET", "/preview", nil), i)
|
|
if strings.Contains(w.Body.String(), "daemon-private-value") || !strings.Contains(w.Body.String(), "preview_error") {
|
|
t.Fatalf("unsafe error: %s", w.Body.String())
|
|
}
|
|
w = httptest.NewRecorder()
|
|
a.profilePreview(w, httptest.NewRequest("POST", "/preview", nil), i)
|
|
if w.Code != 405 {
|
|
t.Fatalf("method: %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestProfileBundlesRejectsCredentialSymlink(t *testing.T) {
|
|
a, i, path := previewFixture(t)
|
|
credentials := filepath.Join(path, "..", "..", ".credentials.yaml")
|
|
if err := os.WriteFile(credentials, []byte("private-value"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(credentials, filepath.Join(path, "package.json")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.profileBundles(i); err == nil {
|
|
t.Fatal("accepted credential symlink")
|
|
}
|
|
}
|