package lease_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/lease/leasetest"
)
// lockFixture points the package's S3 clients at leasetest's local store,
// with credentials the AWS chain resolves without touching the machine's.
func lockFixture(t *testing.T) *leasetest.VersionedBucket {
t.Helper()
bucket := leasetest.NewVersionedBucket()
bucket.AddBucket("data")
handler, _ := leasetest.NewS3Server(bucket)
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
t.Setenv("AWS_SESSION_TOKEN", "")
t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
t.Setenv("AWS_CONFIG_FILE", t.TempDir()+"/absent")
t.Setenv("AWS_SHARED_CREDENTIALS_FILE", t.TempDir()+"/absent")
t.Setenv("CARLOS_S3_ENDPOINT", srv.URL)
return bucket
}
func TestLockStateFollowsAcquireAndRelease(t *testing.T) {
lockFixture(t)
ctx := context.Background()
held, err := lease.Acquire(ctx, "data", "instances/h.example.com", "test-region")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = held.Release(context.Background()) })
got, _, expires, err := lease.LockState(ctx, "data", "instances/h.example.com", "test-region")
if err != nil || !got || !expires.After(time.Now()) {
t.Fatalf("after acquire: held %v expires %v err %v, want held with an expiry in the future", got, expires, err)
}
// ReplicaSource's prefix carries a trailing slash; the lock is the same.
if got, _, _, err := lease.LockState(ctx, "data", "instances/h.example.com/", "test-region"); err != nil || !got {
t.Fatalf("trailing slash: held %v err %v", got, err)
}
if err := held.Release(ctx); err != nil {
t.Fatal(err)
}
if got, owner, expires, err := lease.LockState(ctx, "data", "instances/h.example.com", "test-region"); err != nil || got || owner != "" || !expires.IsZero() {
t.Fatalf("after release: held %v owner %q expires %v err %v", got, owner, expires, err)
}
}
func TestLockStateAbsentPrefixIsNotHeld(t *testing.T) {
lockFixture(t)
got, owner, expires, err := lease.LockState(context.Background(), "data", "instances/never.example.com/inc-1", "test-region")
if err != nil || got || owner != "" || !expires.IsZero() {
t.Fatalf("absent: held %v owner %q expires %v err %v", got, owner, expires, err)
}
}
// ยง5.3 step 4: a lock counts as released only once it has expired by more
// than the lease's renewal margin, so a holder whose clock runs a little
// behind is still counted as holding.
func TestLockStateExpiredWithinTheMarginIsHeld(t *testing.T) {
bucket := lockFixture(t)
ctx := context.Background()
put := func(loc string, expires time.Time) {
bucket.Put("data", loc+"/lock.json", []byte(`{"generation":3,"expires_at":"`+expires.UTC().Format(time.RFC3339Nano)+`","owner":"box-a"}`))
}
recent := time.Now().Add(-lease.RenewMargin / 2)
put("instances/recent.example.com", recent)
got, owner, expires, err := lease.LockState(ctx, "data", "instances/recent.example.com", "test-region")
if err != nil || !got || owner != "box-a" || !expires.Equal(recent) {
t.Fatalf("expired within the margin: held %v owner %q expires %v err %v", got, owner, expires, err)
}
old := time.Now().Add(-2 * lease.RenewMargin)
put("instances/old.example.com", old)
got, owner, expires, err = lease.LockState(ctx, "data", "instances/old.example.com", "test-region")
if err != nil || got || owner != "box-a" || !expires.Equal(old) {
t.Fatalf("expired past the margin: held %v owner %q expires %v err %v", got, owner, expires, err)
}
}
// Anything the reader cannot positively call absent or released is an
// error, never "not held".
func TestLockStateFailsClosed(t *testing.T) {
bucket := lockFixture(t)
ctx := context.Background()
bucket.Put("data", "instances/garbled.example.com/lock.json", []byte(`not json`))
bucket.Put("data", "instances/noexpiry.example.com/lock.json", []byte(`{"generation":1,"owner":"x"}`))
for _, c := range []struct{ name, bucket, location string }{
{"garbled body", "data", "instances/garbled.example.com"},
{"no expiry", "data", "instances/noexpiry.example.com"},
{"missing bucket", "nobucket", "instances/h.example.com"},
{"empty location", "data", ""},
{"absolute location", "data", "/instances/h.example.com"},
{"dot-dot location", "data", "instances/../h.example.com"},
{"double slash", "data", "instances//h.example.com"},
{"empty bucket", "", "instances/h.example.com"},
} {
got, _, _, err := lease.LockState(ctx, c.bucket, c.location, "test-region")
if err == nil || got {
t.Fatalf("%s: held %v err %v, want an error", c.name, got, err)
}
}
}
// faultLockServer answers every request with status and body, as an S3
// endpoint (or something pretending to be one) might.
func faultLockServer(t *testing.T, status int, body string) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if body != "" {
w.Header().Set("Content-Type", "application/xml")
}
w.WriteHeader(status)
io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
lockFixture(t)
t.Setenv("CARLOS_S3_ENDPOINT", srv.URL)
}
// Absence is an authority-bearing answer ("the lease is free"), so only S3's
// explicit, parseable NoSuchKey counts. Any other 404 is an error.
func TestLockStateAbsenceNeedsAnExplicitNoSuchKey(t *testing.T) {
ctx := context.Background()
faultLockServer(t, http.StatusNotFound, `NoSuchKeyThe specified key does not exist.`)
if got, _, _, err := lease.LockState(ctx, "data", "instances/h.example.com", "test-region"); err != nil || got {
t.Fatalf("explicit NoSuchKey: held %v err %v, want absent", got, err)
}
for name, c := range map[string]struct {
status int
body string
}{
"malformed 404": {http.StatusNotFound, `this is not an S3 error body`},
"truncated 404": {http.StatusNotFound, `NoSuchKey`},
"empty 404": {http.StatusNotFound, ``},
"unrecognised 404": {http.StatusNotFound, `NoSuchThingx`},
"NotFound 404": {http.StatusNotFound, `NotFoundx`},
"NoSuchVersion": {http.StatusNotFound, `NoSuchVersionx`},
"NoSuchBucket": {http.StatusNotFound, `NoSuchBucketx`},
"NoSuchKey on 200": {http.StatusOK, `NoSuchKey`},
"NoSuchKey on 403": {http.StatusForbidden, `NoSuchKey`},
} {
t.Run(name, func(t *testing.T) {
faultLockServer(t, c.status, c.body)
if got, _, _, err := lease.LockState(ctx, "data", "instances/h.example.com", "test-region"); err == nil || got {
t.Fatalf("held %v err %v, want an error", got, err)
}
})
}
}