git » gofer » next » tree

[next] / server / peruser_test.go

package server

import (
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"blitiri.com.ar/go/gofer/config"
	"blitiri.com.ar/go/gofer/trace"
)

func TestValidUser(t *testing.T) {
	valid := []string{"alice", "bob@example.com", "a.b", "user-1", "...", "café"}
	for _, u := range valid {
		if err := validUser(u); err != nil {
			t.Errorf("validUser(%q) = %v, want nil", u, err)
		}
	}

	invalid := []string{"", ".", "..", "a/b", `a\b`, "x\x00y", "/etc"}
	for _, u := range invalid {
		if err := validUser(u); err == nil {
			t.Errorf("validUser(%q) = nil, want error", u)
		}
	}
}

func TestLoadAuthFileValidatesUsers(t *testing.T) {
	dir := t.TempDir()

	good := filepath.Join(dir, "good.yaml")
	os.WriteFile(good, []byte("plain:\n  alice: pw\nsha256:\n  bob: abc\n"), 0600)
	if _, err := LoadAuthFile(good); err != nil {
		t.Errorf("LoadAuthFile(good) = %v, want nil", err)
	}

	// An unsafe user name must make the whole file fail to load.
	bad := filepath.Join(dir, "bad.yaml")
	os.WriteFile(bad, []byte("plain:\n  \"../escape\": pw\n"), 0600)
	if _, err := LoadAuthFile(bad); err == nil {
		t.Errorf("LoadAuthFile(bad) = nil, want error")
	}

	// Malformed YAML fails to load.
	malformed := filepath.Join(dir, "malformed.yaml")
	os.WriteFile(malformed, []byte("plain:\n  - a list, not a map\n"), 0600)
	if _, err := LoadAuthFile(malformed); err == nil {
		t.Errorf("LoadAuthFile(malformed) = nil, want error")
	}
}

// serveUser drives an http.Handler with an authenticated user in the request
// context, mimicking what AuthWrapper sets up. An empty user means
// unauthenticated.
func serveUser(h http.Handler, method, target, body, user string) *http.Response {
	var br io.Reader
	if body != "" {
		br = strings.NewReader(body)
	}
	req := httptest.NewRequest(method, target, br)

	ctx := trace.NewContext(req.Context(), trace.New("test", target))
	if user != "" {
		ctx = withUser(ctx, user)
	}
	req = req.WithContext(ctx)

	w := httptest.NewRecorder()
	h.ServeHTTP(w, req)
	return w.Result()
}

func TestPerUserDir(t *testing.T) {
	root := t.TempDir()
	opts := config.DirOpts{
		PerUser: true,
		Put:     map[string]bool{"/": true},
		Delete:  map[string]bool{"/": true},
		Listing: map[string]bool{"/": true},
	}
	h := makeDir("/w/", root, opts)

	// PUT as alice creates <root>/alice/f.txt.
	resp := serveUser(h, "PUT", "http://x/w/f.txt", "alice-data", "alice")
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("alice PUT: status = %d, want 201", resp.StatusCode)
	}
	if got, _ := os.ReadFile(filepath.Join(root, "alice", "f.txt")); string(got) != "alice-data" {
		t.Errorf("alice file = %q, want %q", got, "alice-data")
	}

	// bob writing the same request path is isolated to his own directory.
	resp = serveUser(h, "PUT", "http://x/w/f.txt", "bob-data", "bob")
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("bob PUT: status = %d, want 201", resp.StatusCode)
	}
	if got, _ := os.ReadFile(filepath.Join(root, "bob", "f.txt")); string(got) != "bob-data" {
		t.Errorf("bob file = %q, want %q", got, "bob-data")
	}

	// alice GETs her own file, not bob's.
	resp = serveUser(h, "GET", "http://x/w/f.txt", "", "alice")
	if body, _ := io.ReadAll(resp.Body); string(body) != "alice-data" {
		t.Errorf("alice GET = %q, want %q", body, "alice-data")
	}

	// Unauthenticated request fails closed, writes nothing.
	resp = serveUser(h, "PUT", "http://x/w/anon.txt", "x", "")
	if resp.StatusCode != http.StatusForbidden {
		t.Errorf("anon PUT: status = %d, want 403", resp.StatusCode)
	}
	if _, err := os.Stat(filepath.Join(root, "anon.txt")); !os.IsNotExist(err) {
		t.Errorf("anon PUT wrote a file outside any user directory")
	}

	// Traversal stays within the user's own directory: alice cannot reach
	// the sibling top-level "bob" directory. "/w/../bob/x" resolves to
	// <root>/alice/bob/x, not <root>/bob/x.
	resp = serveUser(h, "PUT", "http://x/w/../bob/escape", "nope", "alice")
	if _, err := os.Stat(filepath.Join(root, "alice", "bob", "escape")); err != nil {
		t.Errorf("traversal target not under alice's dir: %v", err)
	}
	if got, _ := os.ReadFile(filepath.Join(root, "bob", "f.txt")); string(got) != "bob-data" {
		t.Errorf("alice traversal clobbered bob's dir: bob f.txt = %q", got)
	}

	// alice DELETEs her file; bob's remains.
	resp = serveUser(h, "DELETE", "http://x/w/f.txt", "", "alice")
	if resp.StatusCode != http.StatusNoContent {
		t.Errorf("alice DELETE: status = %d, want 204", resp.StatusCode)
	}
	if _, err := os.Stat(filepath.Join(root, "alice", "f.txt")); !os.IsNotExist(err) {
		t.Errorf("alice file still exists after DELETE")
	}
	if _, err := os.Stat(filepath.Join(root, "bob", "f.txt")); err != nil {
		t.Errorf("bob file gone after alice DELETE: %v", err)
	}
}