Files
dsh-web/internal/api/config_test.go
T

405 lines
13 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
)
func TestConfigPasswordBackupImport(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
hash, _ := bcrypt.GenerateFromPassword([]byte("original-password"), bcrypt.MinCost)
if _, err := a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id=?", string(hash), i.UID); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(home, ".credentials.yaml"), "secret: original")
for _, body := range []string{`{}`, `{"password":"wrong"}`} {
if w := configCall(a, i, "POST", "backups", body); w.Code < 400 {
t.Fatal("accepted wrong password")
}
}
w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var out struct {
ID string `json:"id"`
}
json.Unmarshal(w.Body.Bytes(), &out)
if _, err := os.Stat(filepath.Join(a.root, "backup.key")); !os.IsNotExist(err) {
t.Fatal("new backup persisted key")
}
encrypted, err := os.ReadFile(filepath.Join(a.root, "backups", i.UID, i.ID, out.ID+".dshbak"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(encrypted, []byte("secret: original")) {
t.Fatal("plaintext archive")
}
hash, _ = bcrypt.GenerateFromPassword([]byte("changed-password"), bcrypt.MinCost)
a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id=?", string(hash), i.UID)
configWrite(t, filepath.Join(home, ".credentials.yaml"), "secret: changed")
w = configCall(a, i, "POST", "restore", `{"backup_id":"`+out.ID+`","password":"wrong"}`)
if w.Code != 400 {
t.Fatal(w.Code, w.Body.String())
}
entries, _ := os.ReadDir(filepath.Dir(home))
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "pre-restore-") {
t.Fatal("bad password created snapshot")
}
}
var body bytes.Buffer
form := multipart.NewWriter(&body)
part, _ := form.CreateFormFile("file", "external.dshbak")
part.Write(encrypted)
form.WriteField("password", "original-password")
form.Close()
req := httptest.NewRequest("POST", "/api/instances/one/backups", &body)
req.Header.Set("Content-Type", form.FormDataContentType())
w = httptest.NewRecorder()
a.config(w, req, i, "backups")
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
w = configCall(a, i, "POST", "restore", `{"backup_id":"`+out.ID+`","password":"original-password"}`)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
restored, _ := os.ReadFile(filepath.Join(home, ".credentials.yaml"))
if string(restored) != "secret: original" {
t.Fatal(string(restored))
}
}
func configTestAPI(t *testing.T, running bool, failures ...string) (*API, store.Instance, string) {
t.Helper()
var mu sync.Mutex
httpFailure := false
backupRestart := running
failure := ""
if len(failures) != 0 {
failure = failures[0]
}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if httpFailure {
httpFailure = false
running = false
w.WriteHeader(503)
return
}
if r.URL.Query().Get("token") == "test-token" {
http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"})
w.WriteHeader(303)
return
}
w.WriteHeader(200)
}))
t.Cleanup(upstream.Close)
transport := runtimeTransport
runtimeTransport = transport.Clone()
runtimeTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://"))
}
t.Cleanup(func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport })
a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch {
case strings.HasSuffix(r.URL.Path, "/stop"):
running = false
w.WriteHeader(204)
case strings.HasSuffix(r.URL.Path, "/start"):
if backupRestart {
backupRestart = false
running = true
w.WriteHeader(204)
return
}
running = failure != "reconcile"
httpFailure = failure == "readiness"
failure = ""
w.WriteHeader(204)
case strings.Contains(r.URL.Path, "/exec"), strings.HasSuffix(r.URL.Path, "/restart"):
t.Error("restore must use entrypoint prestart, not exec/restart")
w.WriteHeader(500)
case strings.HasSuffix(r.URL.Path, "/logs"):
fmt.Fprint(w, "http://runtime/?token=test-token")
default:
writeJSON(w, map[string]any{"State": map[string]any{"Running": running}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}})
}
})
hash, _ := bcrypt.GenerateFromPassword([]byte("original-password"), bcrypt.MinCost)
a.s.DB.Exec("UPDATE users SET pass_hash=?,quota=10737418240 WHERE id='owner'", string(hash))
i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", Status: "exited", Created: time.Now()}
if e := a.s.AddInstance(i); e != nil {
t.Fatal(e)
}
home := filepath.Join(a.root, "workspaces", "owner", "instances", "one", "home")
if e := os.MkdirAll(home, 0750); e != nil {
t.Fatal(e)
}
return a, i, home
}
func configCall(a *API, i store.Instance, method, action, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := httptest.NewRequest(method, "/api/instances/one/"+action, strings.NewReader(body))
a.config(w, r, i, strings.Split(action, "?")[0])
return w
}
func configWrite(t *testing.T, path, content string) {
t.Helper()
if e := os.WriteFile(path, []byte(content), 0600); e != nil {
t.Fatal(e)
}
}
func TestConfigProfile(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
for _, tc := range []struct {
method, body string
code int
}{
{"GET", "", 200}, {"PUT", `{"content":"foo: ["}`, 400}, {"PUT", `{"content":"foo: bar\n"}`, 200}, {"GET", "", 200},
} {
w := configCall(a, i, tc.method, "profile", tc.body)
if w.Code != tc.code {
t.Fatalf("%s: %d %s", tc.method, w.Code, w.Body.String())
}
}
for _, path := range []string{"profiles", "profiles/web", "profiles/web/cordis.patch.yml"} {
st, e := os.Stat(filepath.Join(home, path))
if e != nil {
t.Fatal(e)
}
stat := st.Sys().(*syscall.Stat_t)
if stat.Uid != 1000 || stat.Gid != 1000 {
t.Fatalf("ownership of %s: %+v", path, stat)
}
}
w := configCall(a, i, "GET", "profile", "")
if !strings.Contains(w.Body.String(), "foo: bar") {
t.Fatal(w.Body.String())
}
}
func TestConfigProfileSymlinks(t *testing.T) {
for _, level := range []string{"profiles", "web", "leaf", "home"} {
t.Run(level, func(t *testing.T) {
a, i, home := configTestAPI(t, false)
secret := "credential-secret-never-return"
configWrite(t, filepath.Join(home, ".credentials.yaml"), secret)
if e := os.MkdirAll(filepath.Join(home, "profiles", "web"), 0750); e != nil {
t.Fatal(e)
}
var link, target string
switch level {
case "profiles":
link = filepath.Join(home, "profiles")
target = "private"
if e := os.MkdirAll(filepath.Join(home, "private", "web"), 0750); e != nil {
t.Fatal(e)
}
configWrite(t, filepath.Join(home, "private", "web", "cordis.patch.yml"), secret)
case "web":
link = filepath.Join(home, "profiles", "web")
target = "../private"
if e := os.Mkdir(filepath.Join(home, "private"), 0750); e != nil {
t.Fatal(e)
}
configWrite(t, filepath.Join(home, "private", "cordis.patch.yml"), secret)
case "leaf":
link = filepath.Join(home, "profiles", "web", "cordis.patch.yml")
target = "../../.credentials.yaml"
case "home":
link = home
target = home + "-other"
if e := os.Rename(home, target); e != nil {
t.Fatal(e)
}
}
if level != "home" {
if e := os.RemoveAll(link); e != nil {
t.Fatal(e)
}
}
if e := os.Symlink(target, link); e != nil {
t.Fatal(e)
}
for _, method := range []string{"GET", "PUT"} {
w := configCall(a, i, method, "profile", `{"content":"foo: bar"}`)
if w.Code != 400 || strings.Contains(w.Body.String(), secret) {
t.Fatalf("%s: %d %s", method, w.Code, w.Body.String())
}
}
})
}
}
func TestConfigRunningRejected(t *testing.T) {
a, i, _ := configTestAPI(t, true)
for _, tc := range []struct{ method, action string }{{"PUT", "profile"}} {
w := configCall(a, i, tc.method, tc.action, `{}`)
if w.Code != 409 {
t.Fatalf("%s: %d", tc.action, w.Code)
}
}
}
func TestConfigBackupRestore(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
const secret = "private-credential-token"
configWrite(t, filepath.Join(home, ".credentials.yaml"), secret)
if w := configCall(a, i, "PUT", "profile", `{"content":"foo: old"}`); w.Code != 200 {
t.Fatal(w.Body.String())
}
w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`)
var result struct {
ID string `json:"id"`
}
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || !backupIDPattern.MatchString(result.ID) {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
w = configCall(a, i, "GET", "backups?download="+result.ID, "")
if w.Code != 200 || !bytes.HasPrefix(w.Body.Bytes(), []byte("DSHPASS")) || bytes.Contains(w.Body.Bytes(), []byte(secret)) {
t.Fatalf("unsafe download: %d", w.Code)
}
if e := os.RemoveAll(filepath.Join(home, "profiles")); e != nil {
t.Fatal(e)
}
configWrite(t, filepath.Join(home, ".credentials.yaml"), "changed")
body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID)
for n := 0; n < 3; n++ {
w = configCall(a, i, "POST", "restore", body)
if w.Code != 200 {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
}
w = configCall(a, i, "POST", "restore", body)
if w.Code != 409 {
t.Fatalf("snapshot retention: %d", w.Code)
}
got, e := os.ReadFile(filepath.Join(home, ".credentials.yaml"))
if e != nil || string(got) != secret {
t.Fatalf("restore: %q %v", got, e)
}
for _, p := range []string{"profiles", "profiles/web", "profiles/web/cordis.patch.yml"} {
st, e := os.Stat(filepath.Join(home, p))
if e != nil {
t.Fatal(e)
}
if st.Sys().(*syscall.Stat_t).Uid != 1000 {
t.Fatalf("not owned by runtime: %s", p)
}
}
state, e := a.s.GetInstance(i.ID)
if e != nil || state.Status != "exited" {
t.Fatalf("state: %+v %v", state, e)
}
id := store.ID()
if e := os.Symlink(filepath.Join(a.root, "backup.key"), filepath.Join(a.root, "backups", i.UID, i.ID, id+".dshbak")); e != nil {
t.Fatal(e)
}
w = configCall(a, i, "GET", "backups?download="+id, "")
if w.Code == 200 {
t.Fatal("symlink download exposed key")
}
}
func TestConfigRestoreInvalidArchive(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
a, i, home := configTestAPI(t, false)
configWrite(t, filepath.Join(home, "cordis.patch.yml"), "original")
id := store.ID()
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
if e := os.MkdirAll(dir, 0700); e != nil {
t.Fatal(e)
}
configWrite(t, filepath.Join(dir, id+".dshbak"), "corrupt")
w := configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q}`, id))
if w.Code != 400 || !strings.Contains(w.Body.String(), "invalid backup") {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
got, e := os.ReadFile(filepath.Join(home, "cordis.patch.yml"))
if e != nil || string(got) != "original" {
t.Fatalf("rollback: %q %v", got, e)
}
a.s.SetInstance(i.ID, "error")
w = configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q}`, id))
if w.Code != 409 {
t.Fatalf("recovery guard: %d", w.Code)
}
}
func TestConfigRestoreOrchestration(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("requires chown")
}
for _, running := range []bool{false, true} {
for _, failure := range []string{"", "reconcile", "readiness"} {
t.Run(fmt.Sprintf("running=%v/%s", running, failure), func(t *testing.T) {
a, i, home := configTestAPI(t, running, failure)
configWrite(t, filepath.Join(home, ".credentials.yaml"), "archive")
if err := os.MkdirAll(filepath.Join(home, "pets", "nested"), 0750); err != nil {
t.Fatal(err)
}
configWrite(t, filepath.Join(home, "pets", "nested", "pet.json"), "{}")
w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var out struct{ ID string }
json.Unmarshal(w.Body.Bytes(), &out)
var kind string
var schema int
if err := a.s.DB.QueryRow("SELECT kind,schema FROM backups WHERE id=?", out.ID).Scan(&kind, &schema); err != nil || kind != "manual" || schema != 2 {
t.Fatalf("index: %s %d %v", kind, schema, err)
}
configWrite(t, filepath.Join(home, ".credentials.yaml"), "before-restore")
w = configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, out.ID))
wantCode, wantContent := 200, "archive"
if failure != "" {
wantCode, wantContent = 400, "before-restore"
}
if w.Code != wantCode {
t.Fatal(w.Code, w.Body.String())
}
b, _ := os.ReadFile(filepath.Join(home, ".credentials.yaml"))
if string(b) != wantContent {
t.Fatalf("home: %s", b)
}
c, err := a.docker.Inspect(context.Background(), i.ContainerID)
if err != nil || c.State.Running != running {
t.Fatalf("runtime state: %+v %v", c, err)
}
st, err := os.Stat(filepath.Join(home, "pets", "nested", "pet.json"))
if err != nil || st.Sys().(*syscall.Stat_t).Uid != 1000 {
t.Fatalf("resource ownership: %v", err)
}
})
}
}
}