package moves import ( "context" "errors" "math" "net/http" "net/http/httptest" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/carlosframework/platform/internal/lease" "github.com/carlosframework/platform/internal/lease/leasetest" ) type fixedLease struct{ until time.Time } func (f fixedLease) SafeUntil() (time.Time, error) { return f.until, nil } type failedLease struct{ err error } func (f failedLease) SafeUntil() (time.Time, error) { return time.Time{}, f.err } // ยง7.3 "sign only before SafeUntil, and sign at the check": the guard // leaves room for the signature's whole valid life and the clock skew. func TestGuardedRefusesWithinTheSignatureLife(t *testing.T) { t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) g := Guarded{Lease: fixedLease{t0.Add(30 * time.Minute)}, Now: func() time.Time { return t0 }, Skew: time.Minute} ran := 0 var got time.Time if err := g.Before(func(at time.Time) error { ran++; got = at; return nil }); err != nil || ran != 1 || !got.Equal(t0) { t.Fatalf("29 minutes of room: err %v ran %d signAt %v", err, ran, got) } g.Now = func() time.Time { return t0.Add(14*time.Minute + 1*time.Second) } if err := g.Before(func(time.Time) error { ran++; return nil }); !errors.Is(err, ErrLeaseUnsafe) || ran != 1 { t.Fatalf("inside the 15 min + skew window: err %v ran %d", err, ran) } if StagingLeasePrefix("uk", "a.go.example.com") != "control/staging-leases/uk/a.go.example.com" { t.Fatal(StagingLeasePrefix("uk", "a.go.example.com")) } // The real settings: a fresh staging lease (45 min TTL) is usable for // its first 29 minutes minus the renewal margin; the ordinary 15 min // lease would never pass the guard, which is why StagingLeaseTTL exists. if StagingLeaseTTL <= SignatureLife+DefaultSkew+15*time.Minute { t.Fatalf("StagingLeaseTTL %v leaves no room for the signature life", StagingLeaseTTL) } } // A lease that cannot answer SafeUntil (lost, spent) is unsafe, and fn // never runs. func TestGuardedRefusesALostLease(t *testing.T) { g := Guarded{Lease: failedLease{lease.ErrLost}, Now: time.Now} err := g.Before(func(time.Time) error { t.Fatal("ran on a lost lease"); return nil }) if !errors.Is(err, ErrLeaseUnsafe) { t.Fatalf("lost lease: %v", err) } } // The boundary is exact: now + life + skew == safe is allowed, one // nanosecond more is not. A zero Skew means DefaultSkew. func TestGuardedBoundary(t *testing.T) { t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) safe := t0.Add(SignatureLife + DefaultSkew) ok := Guarded{Lease: fixedLease{safe}, Now: func() time.Time { return t0 }} if err := ok.Before(func(time.Time) error { return nil }); err != nil { t.Fatalf("exactly at the bound: %v", err) } late := Guarded{Lease: fixedLease{safe}, Now: func() time.Time { return t0.Add(1) }} if err := late.Before(func(time.Time) error { return nil }); !errors.Is(err, ErrLeaseUnsafe) { t.Fatalf("one ns past the bound: %v", err) } // fn's own error comes back untouched. boom := errors.New("boom") if err := ok.Before(func(time.Time) error { return boom }); err != boom { t.Fatalf("fn error: %v", err) } } // The real lease settings, by arithmetic: lease.Held's SafeUntil is // Until minus the renewal margin, so a fresh lease's SafeUntil is at most // TTL ahead. A fresh StagingLeaseTTL lease passes the guard; a fresh // lease.LeaseTTL lease never can, which is why StagingLeaseTTL exists. // lease.AcquireTTL itself is covered where lease's own Acquire tests are // (lease_test.go, S3-gated): this package adds no second S3 fixture. func TestGuardAgainstRealLeaseSettings(t *testing.T) { t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) fresh := func(ttl time.Duration) SafeUntilGuard { return fixedLease{t0.Add(ttl - lease.RenewMargin)} // what Held.SafeUntil answers right after Acquire } if err := (Guarded{Lease: fresh(StagingLeaseTTL), Now: func() time.Time { return t0 }}).Before(func(time.Time) error { return nil }); err != nil { t.Fatalf("a fresh staging lease is refused: %v", err) } if err := (Guarded{Lease: fresh(lease.LeaseTTL), Now: func() time.Time { return t0 }}).Before(func(time.Time) error { return nil }); !errors.Is(err, ErrLeaseUnsafe) { t.Fatalf("a fresh 15 minute lease passed the guard: %v", err) } } // The staging mirror signs at the check and never again: a request // carries the X-Amz-Date of the timestamp Guarded.Before captured, and a // second attempt is a second Before. The fixture is a recording handler // (the test asserts the signed header, not AWS's acceptance of it) in // front of internal/lease/leasetest's local S3, which stores a plain PUT // as a new version beside its create-only PUT. func TestStagingMirrorSignsAtTheCheck(t *testing.T) { bucket := leasetest.NewVersionedBucket() bucket.AddBucket("data-uk") handler, _ := leasetest.NewS3Server(bucket, "data-uk") var dates []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dates = append(dates, r.Header.Get("X-Amz-Date")) handler.ServeHTTP(w, r) })) defer srv.Close() creds := aws.CredentialsProviderFunc(func(context.Context) (aws.Credentials, error) { return aws.Credentials{AccessKeyID: "AKIATEST", SecretAccessKey: "secret"}, nil }) m := lease.NewStagingMirrorAt(srv.URL, "eu-west-2", creds) t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) now := t0 g := Guarded{Lease: fixedLease{t0.Add(30 * time.Minute)}, Now: func() time.Time { return now }} // First write: checked and signed at t0. if err := g.Before(func(at time.Time) error { _, err := m.PutVersionedAt(context.Background(), at, "data-uk", "moves/h/mv/timers/t.json", []byte("{}")) return err }); err != nil { t.Fatal(err) } // Second write: the check happens at t0+3m; then the goroutine pauses // past every bound before the request is signed and sent. The // signature must carry the CHECK's time, so no pause can mint a fresh // signature after the lease is gone (round-4 finding 3). now = t0.Add(3 * time.Minute) if err := g.Before(func(at time.Time) error { now = t0.Add(40 * time.Minute) // the pause: the wall clock is past the lease _, err := m.PutVersionedAt(context.Background(), at, "data-uk", "moves/h/mv/timers/t.json", []byte("{}")) return err }); err != nil { t.Fatal(err) } if len(dates) != 2 || dates[0] != "20261006T090000Z" || dates[1] != "20261006T090300Z" { t.Fatalf("X-Amz-Date per request must be the check's time: %v", dates) } // The lease is now unsafe: a third Before refuses and sends nothing. if err := g.Before(func(time.Time) error { t.Fatal("signed after the bound"); return nil }); !errors.Is(err, ErrLeaseUnsafe) || len(dates) != 2 { t.Fatalf("after the bound: %v, %d requests", err, len(dates)) } // create-only through the same signed path now = t0.Add(4 * time.Minute) put := func() error { return g.Before(func(at time.Time) error { _, err := m.PutIfAbsentVersionedAt(context.Background(), at, "data-uk", "moves/h/mv/move.json", []byte("{}")) return err }) } if err := put(); err != nil { t.Fatal(err) } if err := put(); !errors.Is(err, lease.ErrExists) { t.Fatalf("second create-only put: %v", err) } } // A negative skew would widen the window the guard is there to close (a // -15 minute skew admits a signature that outlives the lease), so it is a // configuration error and fn never runs. A skew so large that // SignatureLife+Skew overflows must refuse, not wrap into a pass. func TestGuardedSkewIsValidated(t *testing.T) { t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) plenty := fixedLease{t0.Add(24 * time.Hour)} ran := false fn := func(time.Time) error { ran = true; return nil } neg := Guarded{Lease: fixedLease{t0.Add(time.Second)}, Now: func() time.Time { return t0 }, Skew: -15 * time.Minute} if err := neg.Before(fn); !errors.Is(err, ErrInvalidSkew) || ran { t.Fatalf("negative skew: err %v ran %v", err, ran) } neg.Lease = plenty if err := neg.Before(fn); !errors.Is(err, ErrInvalidSkew) || ran { t.Fatalf("negative skew with room: err %v ran %v", err, ran) } for _, skew := range []time.Duration{time.Duration(math.MaxInt64), time.Duration(math.MaxInt64) - SignatureLife + 1} { huge := Guarded{Lease: plenty, Now: func() time.Time { return t0 }, Skew: skew} if err := huge.Before(fn); !errors.Is(err, ErrLeaseUnsafe) || ran { t.Fatalf("skew %v: err %v ran %v", skew, err, ran) } } }