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
}