Skip to content
quarry Artifact [de42098b2f]
Login

Artifact [de42098b2f]

Artifact de42098b2f0fe3a7a2bc0bae8ee79adf215a62374156531c80860d139c92ca5b:


package api

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// memBuckets is a BucketStore in memory, secret included, so a test
// can prove the secret reaches the store and never leaves the ops.
type memBuckets struct {
	mu   sync.Mutex
	rows map[string]oltp.BucketBinding
}

func newMemBuckets() *memBuckets { return &memBuckets{rows: map[string]oltp.BucketBinding{}} }

func (m *memBuckets) BindBucket(repo string, b oltp.BucketBinding) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	if b.Secret == "" {
		return errors.New("a binding needs a secret")
	}
	have := m.rows[repo]
	b.ProbedAt, b.ProbeErr = have.ProbedAt, have.ProbeErr
	m.rows[repo] = b
	return nil
}

func (m *memBuckets) Bucket(repo string) (oltp.BucketBinding, bool, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	b, ok := m.rows[repo]
	return b, ok, nil
}

func (m *memBuckets) UnbindBucket(repo string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	delete(m.rows, repo)
	return nil
}

func (m *memBuckets) SetBucketProbe(repo string, at time.Time, probeErr string, corsOK bool) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	b, ok := m.rows[repo]
	if !ok {
		return nil
	}
	b.ProbedAt, b.ProbeErr, b.CorsOK = at.UTC().Format(time.RFC3339), probeErr, corsOK
	m.rows[repo] = b
	return nil
}

// fakeS3 answers presigned PUT and DELETE like a bucket would; refuse
// flips it to a 403 with S3's own error shape. A preflight is refused
// the way R2 refuses one with no rule — 403 and no CORS headers —
// unless cors names the one origin the bucket allows.
func fakeS3(t *testing.T) (*httptest.Server, *bool) {
	srv, refuse, _ := fakeS3CORS(t)
	return srv, refuse
}

func fakeS3CORS(t *testing.T) (*httptest.Server, *bool, *string) {
	t.Helper()
	refuse := false
	cors := ""
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodOptions {
			if origin := r.Header.Get("Origin"); cors == "" || origin != cors ||
				r.Header.Get("Access-Control-Request-Method") != http.MethodPut {
				w.WriteHeader(http.StatusForbidden)
				return
			}
			w.Header().Set("Access-Control-Allow-Origin", cors)
			w.Header().Set("Access-Control-Allow-Methods", "PUT, HEAD, GET")
			w.Header().Set("Access-Control-Allow-Headers", "content-type, content-length")
			w.WriteHeader(http.StatusNoContent)
			return
		}
		if refuse || r.URL.Query().Get("X-Amz-Signature") == "" {
			w.WriteHeader(http.StatusForbidden)
			w.Write([]byte(`<Error><Code>SignatureDoesNotMatch</Code></Error>`))
			return
		}
		if r.Method == http.MethodDelete {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		w.WriteHeader(http.StatusOK)
	}))
	t.Cleanup(srv.Close)
	return srv, &refuse, &cors
}

func bucketRegistry(t *testing.T, mutate func(*Deps)) (*ops.Registry, *memBuckets, *fakeEvents) {
	t.Helper()
	store := newMemBuckets()
	events := &fakeEvents{}
	d := Deps{Version: "v", Repos: testRepos(wikiIndex(t)), Chat: &fakeChat{}, Buckets: store, Events: events}
	if mutate != nil {
		mutate(&d)
	}
	r := ops.NewRegistry()
	if err := RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	return r, store, events
}

// A binding is probed before it is written, the secret reaches the
// store and never comes back on the wire, an edit without the secret
// keeps the stored one, and the trail hears a settings change.
func TestBucketSetProbesThenStoresWithoutReturningTheSecret(t *testing.T) {
	srv, _ := fakeS3(t)
	r, store, events := bucketRegistry(t, nil)
	out, err := r.Invoke(context.Background(), "bucket.set", bucketSetIn{
		Repo: "test", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK", Secret: "s3cretkey"})
	if err != nil {
		t.Fatalf("FAIL: %v", err)
	}
	wire, _ := json.Marshal(out)
	if strings.Contains(string(wire), "s3cretkey") {
		t.Fatalf("FAIL: the secret came back on the wire: %s", wire)
	}
	got := out.(BucketInfo)
	if !got.Bound || got.Region != "auto" || got.Limit != oltp.DefaultBucketLimit || got.ProbedAt == "" || got.ProbeErr != "" {
		t.Fatalf("FAIL: out = %+v", got)
	}
	if store.rows["test"].Secret != "s3cretkey" {
		t.Fatalf("FAIL: the secret did not reach the store: %+v", store.rows["test"])
	}
	if len(events.seen) != 1 || !strings.HasSuffix(events.seen[0], "repo.settings") {
		t.Fatalf("FAIL: events %v", events.seen)
	}

	out, err = r.Invoke(context.Background(), "bucket.set", bucketSetIn{
		Repo: "test", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK", PublicBase: "https://files.example/", Limit: 5 << 20})
	if err != nil {
		t.Fatalf("FAIL: edit without the secret: %v", err)
	}
	if got := out.(BucketInfo); got.PublicBase != "https://files.example" || got.Limit != 5<<20 {
		t.Fatalf("FAIL: edit = %+v", got)
	}
	if store.rows["test"].Secret != "s3cretkey" {
		t.Fatal("FAIL: the stored secret was not kept")
	}
	got = mustInvoke(t, r, "bucket.get", bucketGetIn{Repo: "test"}).(BucketInfo)
	if wire, _ := json.Marshal(got); strings.Contains(string(wire), "s3cret") || !got.Bound {
		t.Fatalf("FAIL: get = %s", wire)
	}
	t.Log("ok: probed, stored, secret kept out of every answer")
}

// A probe that fails is a refusal and nothing is stored; a first
// binding with no secret is refused before any request is made; an
// endpoint that is not https is refused unless it is loopback.
func TestBucketSetRefusesWhatWillNotWork(t *testing.T) {
	srv, refuse := fakeS3(t)
	r, store, _ := bucketRegistry(t, nil)
	*refuse = true
	_, err := r.Invoke(context.Background(), "bucket.set", bucketSetIn{
		Repo: "test", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK", Secret: "k"})
	if !errors.Is(err, ops.ErrInvalid) || !strings.Contains(err.Error(), "SignatureDoesNotMatch") {
		t.Fatalf("FAIL: want the probe's refusal, got %v", err)
	}
	if _, ok := store.rows["test"]; ok {
		t.Fatal("FAIL: a binding that failed its probe was stored")
	}
	*refuse = false
	for _, in := range []bucketSetIn{
		{Repo: "test", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK"},
		{Repo: "test", Endpoint: "http://bucket.example", Bucket: "files", AccessKey: "AK", Secret: "k"},
		{Repo: "test", Endpoint: "https://user:[email protected]", Bucket: "files", AccessKey: "AK", Secret: "k"},
		{Repo: "test", Endpoint: "https://bucket.example/path", Bucket: "files", AccessKey: "AK", Secret: "k"},
	} {
		if _, err := r.Invoke(context.Background(), "bucket.set", in); !errors.Is(err, ops.ErrInvalid) {
			t.Errorf("FAIL: %+v: want ErrInvalid, got %v", in, err)
		}
	}
	if _, err := r.Invoke(context.Background(), "bucket.set", bucketSetIn{Repo: "nope", Endpoint: srv.URL, Bucket: "b", AccessKey: "a", Secret: "s"}); !errors.Is(err, ops.ErrNotFound) {
		t.Errorf("FAIL: unknown repo: %v", err)
	}
	t.Log("ok: failed probe, missing secret and bad endpoints are refused")
}

// A mirror accepts no writes, uploads included; a companion store
// uses its parent's bucket and reads it back with inherited set.
func TestBucketMirrorAndCompanionRules(t *testing.T) {
	srv, _ := fakeS3(t)
	ms := memMirrors{}
	_ = ms.SetMirror("mirrored", "https://upstream.example/home", 3600)
	r, _, _ := bucketRegistry(t, func(d *Deps) {
		d.Repos = NewRepos(map[string]*Repo{
			"test": {Index: wikiIndex(t)}, "mirrored": {Index: wikiIndex(t)}, "test-forum": {Index: wikiIndex(t)}})
		d.Mirrors = ms
		d.Companions = &fakeCompanions{bound: map[string]string{"test": "test-forum"}}
	})
	_, err := r.Invoke(context.Background(), "bucket.set", bucketSetIn{
		Repo: "mirrored", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK", Secret: "k"})
	if !errors.Is(err, ops.ErrForbidden) {
		t.Fatalf("FAIL: a mirror bound a bucket: %v", err)
	}
	_, err = r.Invoke(context.Background(), "bucket.set", bucketSetIn{
		Repo: "test-forum", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK", Secret: "k"})
	if !errors.Is(err, ops.ErrInvalid) {
		t.Fatalf("FAIL: a companion bound a bucket: %v", err)
	}
	got := mustInvoke(t, r, "bucket.get", bucketGetIn{Repo: "test-forum"}).(BucketInfo)
	if got.Bound || got.Inherited != "test" {
		t.Fatalf("FAIL: unbound companion = %+v", got)
	}
	mustInvoke(t, r, "bucket.set", bucketSetIn{Repo: "test", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK", Secret: "k"})
	got = mustInvoke(t, r, "bucket.get", bucketGetIn{Repo: "test-forum"}).(BucketInfo)
	if !got.Bound || got.Inherited != "test" || got.Bucket != "files" || got.Repo != "test-forum" {
		t.Fatalf("FAIL: companion reads its parent's binding: %+v", got)
	}
	t.Log("ok: mirror refused, companion refused and inherits")
}

// Probing again records the new outcome — pass or fail — as an
// answer, and unbinding forgets the row and says so.
func TestBucketProbeAndUnset(t *testing.T) {
	srv, refuse := fakeS3(t)
	r, store, _ := bucketRegistry(t, nil)
	if _, err := r.Invoke(context.Background(), "bucket.probe", bucketProbeIn{Repo: "test"}); !errors.Is(err, ops.ErrNotFound) {
		t.Fatalf("FAIL: probing nothing: %v", err)
	}
	mustInvoke(t, r, "bucket.set", bucketSetIn{Repo: "test", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK", Secret: "k"})
	*refuse = true
	got := mustInvoke(t, r, "bucket.probe", bucketProbeIn{Repo: "test"}).(BucketInfo)
	if got.ProbeErr == "" || got.ProbedAt == "" {
		t.Fatalf("FAIL: a failed probe is an answer: %+v", got)
	}
	*refuse = false
	if got = mustInvoke(t, r, "bucket.probe", bucketProbeIn{Repo: "test"}).(BucketInfo); got.ProbeErr != "" {
		t.Fatalf("FAIL: a passing probe clears the error: %+v", got)
	}
	un := mustInvoke(t, r, "bucket.unset", bucketUnsetIn{Repo: "test"}).(bucketUnsetOut)
	if !un.Unbound {
		t.Fatal("FAIL: unset did not say it unbound")
	}
	if _, ok := store.rows["test"]; ok {
		t.Fatal("FAIL: the row survived unset")
	}
	if un = mustInvoke(t, r, "bucket.unset", bucketUnsetIn{Repo: "test"}).(bucketUnsetOut); un.Unbound {
		t.Fatal("FAIL: unsetting nothing claimed to unbind")
	}
	t.Log("ok: probe records both outcomes; unset forgets the row")
}

// Without a store the ops exist and answer unavailable, so the
// documented surface is the same on every forge.
func TestBucketOpsWithoutAStore(t *testing.T) {
	r, _, _ := bucketRegistry(t, func(d *Deps) { d.Buckets = nil })
	if _, err := r.Invoke(context.Background(), "bucket.get", bucketGetIn{Repo: "test"}); !errors.Is(err, ops.ErrUnavailable) {
		t.Fatalf("FAIL: %v", err)
	}
	t.Log("ok: unwired ops answer unavailable")
}

func mustInvoke(t *testing.T, r *ops.Registry, name string, in any) any {
	t.Helper()
	out, err := r.Invoke(context.Background(), name, in)
	if err != nil {
		t.Fatalf("FAIL: %s: %v", name, err)
	}
	return out
}

// The browser's preflight is probed beside the forge's PUT and
// recorded on its own: a bucket with no CORS rule for the forge is
// still bound (the form uploads through the forge) but answers corsOk
// false; once the rule is there, a probe flips it. A forge with no
// origin cannot ask and records false.
func TestBucketProbeRecordsThePreflightWithoutRefusingTheBinding(t *testing.T) {
	srv, _, cors := fakeS3CORS(t)
	r, store, _ := bucketRegistry(t, func(d *Deps) { d.Origin = "https://forge.example" })
	got := mustInvoke(t, r, "bucket.set", bucketSetIn{Repo: "test", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK", Secret: "k"}).(BucketInfo)
	if !got.Bound || got.ProbeErr != "" || got.CorsOK {
		t.Fatalf("FAIL: a bucket without the rule should bind with corsOk false: %+v", got)
	}
	if store.rows["test"].CorsOK {
		t.Fatal("FAIL: the store says the preflight passed")
	}
	*cors = "https://forge.example"
	if got = mustInvoke(t, r, "bucket.probe", bucketProbeIn{Repo: "test"}).(BucketInfo); !got.CorsOK || got.ProbeErr != "" {
		t.Fatalf("FAIL: with the rule in place the probe still says corsOk false: %+v", got)
	}
	if got = mustInvoke(t, r, "bucket.get", bucketGetIn{Repo: "test"}).(BucketInfo); !got.CorsOK {
		t.Fatalf("FAIL: bucket.get does not answer corsOk: %+v", got)
	}
	*cors = "https://elsewhere.example"
	if got = mustInvoke(t, r, "bucket.probe", bucketProbeIn{Repo: "test"}).(BucketInfo); got.CorsOK {
		t.Fatalf("FAIL: a rule for another origin counted as ours: %+v", got)
	}
	*cors = "https://forge.example"
	r2, _, _ := bucketRegistry(t, nil)
	if got = mustInvoke(t, r2, "bucket.set", bucketSetIn{Repo: "test", Endpoint: srv.URL, Bucket: "files", AccessKey: "AK", Secret: "k"}).(BucketInfo); got.CorsOK {
		t.Fatalf("FAIL: a forge with no origin claimed the preflight passed: %+v", got)
	}
	t.Log("ok: the preflight is recorded beside the probe and never refuses the binding")
}

// The CSP question: a bound repository's origins are its endpoint and
// public base as scheme+host, a companion answers with its parent's,
// and an unbound one answers nothing.
func TestBucketOriginsForTheCSP(t *testing.T) {
	srv, _ := fakeS3(t)
	d := Deps{Buckets: newMemBuckets(), Companions: &fakeCompanions{bound: map[string]string{"test": "test-forum"}}}
	if got := d.BucketOrigins("test"); got != nil {
		t.Fatalf("FAIL: unbound answered %v", got)
	}
	_ = d.Buckets.BindBucket("test", oltp.BucketBinding{Endpoint: srv.URL, Bucket: "files", Region: "auto",
		AccessKey: "AK", Secret: "k", PublicBase: "https://files.example.org/prefix"})
	want := srv.URL + " https://files.example.org"
	if got := strings.Join(d.BucketOrigins("test"), " "); got != want {
		t.Fatalf("FAIL: origins %q, want %q", got, want)
	}
	if got := strings.Join(d.BucketOrigins("test-forum"), " "); got != want {
		t.Fatalf("FAIL: the companion's origins %q, want its parent's %q", got, want)
	}
	if got := (Deps{}).BucketOrigins("test"); got != nil {
		t.Fatalf("FAIL: a forge with no store answered %v", got)
	}
	t.Log("ok: origins are the binding's hosts, companions inherit, unbound is nothing")
}