Skip to content
quarry Artifact [df0a295576]
Login

Artifact [df0a295576]

Artifact df0a295576dc05ee4f7773ea2689d30174f3db91a701ab3dc19c153b49dbbec4:


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) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	b, ok := m.rows[repo]
	if !ok {
		return nil
	}
	b.ProbedAt, b.ProbeErr = at.UTC().Format(time.RFC3339), probeErr
	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.
func fakeS3(t *testing.T) (*httptest.Server, *bool) {
	t.Helper()
	refuse := false
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		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
}

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
}