Skip to content
quarry Artifact [ea7af990c4]
Login

Artifact [ea7af990c4]

Artifact ea7af990c461e6b31f78f7444ab2f2f615212d62c8dbfc5cf23c7975b9a7ef85:


package api

import (
	"context"
	"errors"
	"os"
	"strings"
	"testing"

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

// A repository's uploads do not go to the forge's own bucket.
//
// bucketOf falls back to the forge binding when a repository has none,
// so uploads landed in the operator's storage without anybody being
// told: dmestas attached files to an announcement on quarry and found
// them in his personal R2 (9b8b8fb6). The disclosure half — a repo
// admin handed the endpoint, bucket name and key id — was fixed by
// forgeAnswer. This is the half that decided where the BYTES went.
//
// The refusal names the remedy, because a refusal that does not is the
// other bug this forge keeps finding.
func TestUploadsRefuseTheForgeBucketAndSayWhatToDo(t *testing.T) {
	var d Deps
	// A forge with no bucket store at all still refuses, and says so
	// in its own words rather than falling through to a nil map.
	_, _, _, err := d.boundBucket("alpha")
	if err == nil {
		t.Fatal("FAIL: a forge with no bucket store accepted an upload")
	}
	if !strings.Contains(err.Error(), "binds no buckets") {
		t.Errorf("FAIL: the refusal does not say the forge binds none: %v", err)
	}
}

// The refusal for the forge-fallback case is ErrInvalid and names the
// settings page, so the caller learns what to do rather than that
// something is wrong.
func TestTheForgeBucketRefusalIsActionable(t *testing.T) {
	// Asserted on the message the code produces, because the whole
	// point of this refusal is its words: the previous behaviour was
	// silent success into the wrong account.
	b, rerr := os.ReadFile("attachwrite.go")
	if rerr != nil {
		t.Fatal(rerr)
	}
	src := string(b)
	for _, want := range []string{
		"has no bucket of its own",
		"do not go to the",
		"bind a bucket under Settings > Attachments",
		"ops.ErrInvalid",
	} {
		if !strings.Contains(src, want) {
			t.Errorf("FAIL: the forge-bucket refusal is missing %q; a refusal that does not "+
				"name the remedy is the other half of this class of bug", want)
		}
	}
	if !strings.Contains(src, "if info.Forge {") {
		t.Error("FAIL: boundBucket no longer refuses the forge binding, so a repository's " +
			"uploads go to the operator's storage again")
	}
	// And marks are deliberately left alone.
	if !strings.Contains(src, "Marks are untouched") {
		t.Error("FAIL: the note explaining why avatars still use the forge bucket is gone; " +
			"somebody will remove forgeBucketForAvatar next")
	}
	_ = errors.Is
	_ = ops.ErrInvalid
}

// THE READ AND THE WRITE MUST ASK THE SAME QUESTION.
//
// bucket.limit answered with the binding bucketOf RESOLVED, and bucketOf
// falls back to the forge's when a repository has none. begin then
// refuses that fallback. So the uploader's own question — "will an upload
// work here?" — was answered yes, with a size limit attached, by the
// surface that exists to answer it, and then refused by the surface that
// does the work (G-130).
//
// The privileged composer path had already compensated by also reading
// bucket.get's `forge` flag; this one had nothing to read but `bound`, so
// the fix landed on one path and its neighbour was left. That is the same
// shape as the contributors count and the landing reply count: a fact one
// surface derives correctly and the one beside it does not.
func TestBucketLimitDoesNotOfferAnUploadThatBeginRefuses(t *testing.T) {
	buckets := newMemBuckets()
	// Only the FORGE has storage. A repository with none inherits it —
	// which is exactly the case begin refuses.
	if err := buckets.BindBucket(oltp.ForgeBucket, oltp.BucketBinding{
		Endpoint: "https://s3.example", Bucket: "forge-storage", Region: "auto",
		AccessKey: "AKIA", Secret: "shh", LimitBytes: 1 << 20,
	}); err != nil {
		t.Fatal(err)
	}
	d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"demo": {}}), Buckets: buckets}
	r := ops.NewRegistry()
	if err := RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	ctx := context.Background()

	out, err := r.Invoke(ctx, "bucket.limit", bucketLimitIn{Repo: "demo"})
	if err != nil {
		t.Fatal(err)
	}
	got := out.(bucketLimitOut)
	if got.Bound {
		t.Errorf("bucket.limit offers an upload that begin will refuse: %+v\n"+
			"the read answers the binding's question, not the uploader's", got)
	}
	if got.Limit != 0 {
		t.Errorf("a repository that cannot accept uploads was handed a size limit: %d", got.Limit)
	}
	if got.Note == "" {
		t.Error("the read says no without saying why, which is the silent shape this forge keeps removing")
	}

	// The write side must refuse the same case, or this test proves
	// nothing about the two agreeing.
	if _, _, _, err := d.boundBucket("demo"); err == nil {
		t.Fatal("boundBucket admitted the forge fallback, so there was no disagreement to fix")
	}

	// And a repository with storage of its own is bound, and answers
	// with its own limit — the fix must not turn everything unbound.
	if err := buckets.BindBucket("demo", oltp.BucketBinding{
		Endpoint: "https://s3.example", Bucket: "demo-storage", Region: "auto",
		AccessKey: "AKIA", Secret: "shh", LimitBytes: 4096,
	}); err != nil {
		t.Fatal(err)
	}
	mine, err := r.Invoke(ctx, "bucket.limit", bucketLimitIn{Repo: "demo"})
	if err != nil {
		t.Fatal(err)
	}
	if m := mine.(bucketLimitOut); !m.Bound || m.Limit != 4096 {
		t.Errorf("a repository with its own bucket is not reported bound with its own limit: %+v", m)
	}
}

// ONE ANSWER TO "WILL AN UPLOAD WORK HERE".
//
// bucket.get answered bound:true for a repository inheriting the forge's
// binding, while bucket.limit answered bound:false and attachment.begin
// refused (G-130, ticket b23f85a0). Three readers of one struct, and
// the one named for the question was the wrong one. bucket.limit had
// been corrected to re-derive "bound and not forge"; the composer had
// grown the same reading; the settings page told the admin their files
// went to forge storage. The fix is that the struct says the right
// thing once, in forgeAnswer, and nobody re-derives it.
//
// Both directions, or an agreement test proves only that everything
// says no.
func TestBucketGetLimitAndBeginAgreeOnWhetherAnUploadWorks(t *testing.T) {
	buckets := newMemBuckets()
	if err := buckets.BindBucket(oltp.ForgeBucket, oltp.BucketBinding{
		Endpoint: "https://s3.example", Bucket: "forge-storage", Region: "auto",
		AccessKey: "AKIA", Secret: "shh", LimitBytes: 1 << 20,
	}); err != nil {
		t.Fatal(err)
	}
	// "own" bound a bucket itself; "inherit" has none and falls back.
	if err := buckets.BindBucket("own", oltp.BucketBinding{
		Endpoint: "https://s3.example", Bucket: "own-storage", Region: "auto",
		AccessKey: "AKIA2", Secret: "shh2", LimitBytes: 2 << 20,
	}); err != nil {
		t.Fatal(err)
	}
	d := Deps{Version: "v", Repos: NewRepos(map[string]*Repo{"own": {}, "inherit": {}}), Buckets: buckets}
	r := ops.NewRegistry()
	if err := RegisterAll(r, d); err != nil {
		t.Fatal(err)
	}
	ctx := context.Background()

	for repo, want := range map[string]bool{"own": true, "inherit": false} {
		get, err := r.Invoke(ctx, "bucket.get", bucketGetIn{Repo: repo})
		if err != nil {
			t.Fatal(err)
		}
		lim, err := r.Invoke(ctx, "bucket.limit", bucketLimitIn{Repo: repo})
		if err != nil {
			t.Fatal(err)
		}
		_, _, _, berr := d.boundBucket(repo)
		g, l := get.(BucketInfo), lim.(bucketLimitOut)
		if g.Bound != want || l.Bound != want || (berr == nil) != want {
			t.Errorf("FAIL %s: bucket.get bound=%v, bucket.limit bound=%v, begin admits=%v; want all %v",
				repo, g.Bound, l.Bound, berr == nil, want)
		} else {
			t.Logf("ok %s: get, limit and begin all say uploads work = %v", repo, want)
		}
		if !want {
			// Unbound-because-forge says so, and says why, in the same
			// words the upload refusal uses.
			if !g.Forge || g.Note == "" || l.Note != g.Note || berr == nil || !strings.Contains(berr.Error(), g.Note) {
				t.Errorf("FAIL %s: the three refusals do not share one reason: get={forge:%v note:%q} limit.note=%q begin=%v",
					repo, g.Forge, g.Note, l.Note, berr)
			} else {
				t.Logf("ok %s: one reason, three surfaces", repo)
			}
		}
	}
}