mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
* feat(base): per-(user,org) multitenant store + gateway-upstream middleware
claims: AssertGatewayUpstream (boot refusal unless HANZO_GATEWAY_UPSTREAM=1),
Inject / RequireGateway / RequireRole middleware wired onto the canonical
3-header contract (X-User-Id, X-Org-Id, X-Roles). RequireGateway returns
503 (deployment topology bug, not an auth failure). RequireRole returns
404 (existence-leak defense). 18 subtests pass.
store: MultiTenantStore holds one SQLite handle per (org, user) in a luxfi/
cache LRU, hydrates on miss from hanzoai/base/tools/filesystem (S3/GCS/
local), checkpoints dirty handles to object storage on idle-TTL, LRU-cap,
explicit Evict, and SIGTERM. Org-wide DB via ForOrg. 13 tests pass (miss,
hit, isolation, checkpoint-uploads, eviction-rehydrates, close-flushes,
concurrent-readers, gateway-bypass). Works around luxfi/container v0.0.2
onEvict-silently-dropped bug via explicit shadow set + flushAndCloseLocked.
* fix(claims): reject CTL bytes + cap slug length on identity headers (P7-H3/H4/M1)
FromHeaders now sanitizes X-User-Id, X-Org-Id, and each X-Roles element
through hasControlByte + MaxIdentityValueLen=256. Any header containing
a byte below 0x20 or equal to 0x7f, or exceeding the length cap, is
discarded — the field becomes "" and RequireGateway 503s instead of
forwarding attacker-controlled bytes into log / path / audit code.
parseRoles gains the same defense at the per-role level: CRLF-smuggled
roles are dropped without breaking the rest of the list.
Red P7-H3 / P7-H4 / P7-M2 verified: CRLF response-splitting primitive
and NUL / TAB / zero-width / DEL injection primitives all closed.
Tests:
TestFromHeaders_RejectsControlBytesInSlug (6 subcases)
TestFromHeaders_RejectsOverlongValue
TestParseRoles_DropsCRLFRoles
go test -race -count=3 ./tools/claims/... green.
* feat(claims): Chain(h) canonical middleware order enforcement (P7-H3)
Chain is the sole approved way to mount a tenant-scoped handler:
mux.Handle("/v1/", claims.Chain(tenant))
It wires Strip → Inject → RequireGateway in the required order. Services
MUST NOT compose the three middlewares by hand — the PHILOSOPHY.md
"one and only one way" principle applies. A misordered hand-wired chain
silently defeats the forged-header defense (verified by Red probe
P7-H3: Inject before Strip allowed attacker headers through).
Tests:
TestChain_EnforcesCanonicalOrder
TestChain_DirectAccess_IsRejected
go test -race -count=3 ./tools/claims/... green.
* fix(store): P7 hardening — SQLite magic, upload errors, chunked reaper, slug cap, CAS=false
Bundles the six Red P7 findings on store/multitenant.go that touch the
same file into one atomic review unit. Each sub-fix has its own test and
its own commentary in the code so the diff reads as six independent
changes with no cross-dependencies.
P7-C1 (option B — sticky-session v1)
Package doc spells out: single-writer per (org,user) is provided by
gateway sticky-session affinity on X-Org-Id, NOT by object-storage CAS.
Exports `CAS = false` so any consumer that requires generation-checked
writes can feature-gate and refuse to boot until CAS flips true. The
ARCHITECTURE.md §5.5 reconciliation lands in the next commit.
Test: TestCAS_ConstantExposed.
P7-C2 (object-store poisoning)
Hydrate now validates the SQLite 3 magic (`SQLite format 3\x00`) on
the downloaded blob BEFORE handing bytes to modernc.org/sqlite. A
non-SQLite blob (or short/truncated) is rejected with ErrCorruptDB
and the local file is removed — the poisoned bytes never reach the
driver, so hostile sqlite_master rows cannot fire on the next query.
Empty blobs (fresh-tenant slot) are allowed.
Tests: TestHydrate_RejectsPoisonedBlob, TestValidateSQLiteMagic_AcceptsEmpty.
P7-H1 (silent upload failures)
ErrUploadFailed is the canonical sentinel. flushAndCloseLocked now
returns error; Evict propagates it; Close aggregates via errors.Join
and reports "flushed N/M, K upload failures". On upload failure the
handle is re-opened and retained in the LRU + shadow so the next
reap/Checkpoint can retry — no silent data loss. The LRU-cap-eviction
path inside hydrate surfaces the same error instead of falling back
to a different victim (which would have silently dropped the retry).
OnReapFailure is a new observer hook on Options for ops to wire a
structured logger or Prometheus counter to reap-cycle failures.
Tests: TestEvict_SurfacesUploadError, TestClose_AggregatesUploadFailures,
TestEvictionReturnsError_RecoversOnRetry.
P7-H2 (reaper O(N) under s.mu)
reapOnce now snapshots idle keys under s.mu, RELEASES the lock, and
then calls s.Evict per-key (each Evict reacquires briefly). Close
follows the same pattern. A cold Get for a new tenant made during a
reap blocks for a single handle's flush latency, not the entire
reap cycle. Documents the lock discipline on both methods.
Test: TestReaper_LockHoldTimeChunked (fills 100 handles, probes
latency; must stay < 500ms).
P7-M1 (slug length)
MaxSlugLen = 128. validateSlug rejects overlong inputs before any FS
call; the attacker-controlled slug no longer reaches the error
message with ENAMETOOLONG.
Test: TestKey_Valid_RejectsLongSlug.
Test-infra
newTestStore default IdleTTL bumped to 5s so standard-flow tests
aren't racing the reaper while operating on a DB handle. Tests that
exercise reap/idle behavior pass a short IdleTTL via the variadic.
go test -race -count=3 ./store/... green: 21 tests pass.
655 lines
18 KiB
Go
655 lines
18 KiB
Go
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/store"
|
|
"github.com/hanzoai/base/tools/claims"
|
|
"github.com/hanzoai/base/tools/filesystem"
|
|
)
|
|
|
|
// newTestStore constructs a store backed by a fileblob bucket in a temp
|
|
// directory. Returns the store and the path to the bucket so tests can peek
|
|
// at object keys directly.
|
|
func newTestStore(t *testing.T, opts ...func(*store.Options)) (*store.MultiTenantStore, string) {
|
|
t.Helper()
|
|
bucketDir := filepath.Join(t.TempDir(), "bucket")
|
|
cacheDir := filepath.Join(t.TempDir(), "cache")
|
|
|
|
fs, err := filesystem.NewLocal(bucketDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { fs.Close() })
|
|
|
|
o := store.Options{
|
|
ObjectStore: fs,
|
|
CacheRoot: cacheDir,
|
|
LRUSize: 4, // tiny on purpose to exercise eviction
|
|
// IdleTTL is long by default so standard tests don't race the
|
|
// reaper while operating on a DB handle. Tests that need to
|
|
// exercise reap behavior pass a small IdleTTL via the opts
|
|
// variadic.
|
|
IdleTTL: 5 * time.Second,
|
|
CheckpointWrites: 2,
|
|
CheckpointInterval: 10 * time.Millisecond,
|
|
}
|
|
for _, fn := range opts {
|
|
fn(&o)
|
|
}
|
|
s, err := store.New(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = s.Close(context.Background())
|
|
})
|
|
return s, bucketDir
|
|
}
|
|
|
|
// ctxWithClaims builds a request context like claims.Inject would.
|
|
func ctxWithClaims(orgID, userID string, roles ...string) context.Context {
|
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
r.Header.Set(claims.HeaderOrgID, orgID)
|
|
r.Header.Set(claims.HeaderUserID, userID)
|
|
if len(roles) > 0 {
|
|
r.Header.Set(claims.HeaderRoles, joinRoles(roles))
|
|
}
|
|
|
|
var ctx context.Context
|
|
claims.Inject(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx = r.Context()
|
|
})).ServeHTTP(httptest.NewRecorder(), r)
|
|
return ctx
|
|
}
|
|
|
|
func joinRoles(roles []string) string {
|
|
out := ""
|
|
for i, r := range roles {
|
|
if i > 0 {
|
|
out += ","
|
|
}
|
|
out += r
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestKey_ObjectKey(t *testing.T) {
|
|
cases := []struct {
|
|
k store.Key
|
|
want string
|
|
}{
|
|
{store.Key{OrgID: "acme", UserID: "alice", Scope: store.ScopeUser}, "acme/users/alice.db"},
|
|
{store.Key{OrgID: "acme", Scope: store.ScopeOrg}, "acme/org.db"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := tc.k.ObjectKey(); got != tc.want {
|
|
t.Errorf("ObjectKey(%v) = %q, want %q", tc.k, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKey_Valid_RejectsTraversal(t *testing.T) {
|
|
bad := []store.Key{
|
|
{OrgID: "../etc", UserID: "x", Scope: store.ScopeUser},
|
|
{OrgID: "acme", UserID: "../passwd", Scope: store.ScopeUser},
|
|
{OrgID: "UPPER", UserID: "x", Scope: store.ScopeUser},
|
|
{OrgID: "", UserID: "x", Scope: store.ScopeUser},
|
|
{OrgID: "acme", UserID: "", Scope: store.ScopeUser},
|
|
{OrgID: "acme has space", UserID: "x", Scope: store.ScopeUser},
|
|
}
|
|
for _, k := range bad {
|
|
if err := k.Valid(); err == nil {
|
|
t.Errorf("expected Valid()=err for %+v", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestKey_Valid_AcceptsSlugs(t *testing.T) {
|
|
good := []store.Key{
|
|
{OrgID: "acme", UserID: "alice", Scope: store.ScopeUser},
|
|
{OrgID: "org-123", UserID: "user_42", Scope: store.ScopeUser},
|
|
{OrgID: "a", UserID: "b", Scope: store.ScopeUser},
|
|
{OrgID: "acme", Scope: store.ScopeOrg},
|
|
}
|
|
for _, k := range good {
|
|
if err := k.Valid(); err != nil {
|
|
t.Errorf("Valid(%+v) = %v, want nil", k, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHydrate_MissCreatesLocalAndOpens verifies that a first Get for a
|
|
// brand-new tenant creates the local file and opens a working SQLite.
|
|
func TestHydrate_MissCreatesLocalAndOpens(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
ctx := ctxWithClaims("acme", "alice")
|
|
|
|
db, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("INSERT INTO t(v) VALUES ('hello')").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var v string
|
|
if err := db.NewQuery("SELECT v FROM t LIMIT 1").Row(&v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v != "hello" {
|
|
t.Fatalf("got %q", v)
|
|
}
|
|
}
|
|
|
|
// TestCacheHit verifies that the second Get returns the same *dbx.DB pointer
|
|
// as the first (cache hit, no hydration).
|
|
func TestCacheHit(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
ctx := ctxWithClaims("acme", "alice")
|
|
|
|
db1, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db2, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if db1 != db2 {
|
|
t.Fatal("expected cache hit to return same DB pointer")
|
|
}
|
|
}
|
|
|
|
// TestIsolationBetweenTenants verifies that two (org, user) pairs see
|
|
// independent SQLite databases.
|
|
func TestIsolationBetweenTenants(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
|
|
alice := ctxWithClaims("acme", "alice")
|
|
bob := ctxWithClaims("acme", "bob")
|
|
carol := ctxWithClaims("globex", "carol")
|
|
|
|
dbAlice, err := s.ForCtx(alice)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dbBob, err := s.ForCtx(bob)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dbCarol, err := s.ForCtx(carol)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := dbAlice.NewQuery(`CREATE TABLE t(id INTEGER, v TEXT)`).Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := dbBob.NewQuery(`CREATE TABLE t(id INTEGER, v TEXT)`).Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := dbCarol.NewQuery(`CREATE TABLE t(id INTEGER, v TEXT)`).Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := dbAlice.NewQuery(`INSERT INTO t VALUES (1, 'from-alice')`).Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := dbBob.NewQuery(`INSERT INTO t VALUES (1, 'from-bob')`).Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var count int
|
|
if err := dbCarol.NewQuery(`SELECT count(*) FROM t`).Row(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("carol leaked data: count=%d", count)
|
|
}
|
|
|
|
var v string
|
|
if err := dbAlice.NewQuery(`SELECT v FROM t WHERE id=1`).Row(&v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v != "from-alice" {
|
|
t.Fatalf("alice saw %q, expected from-alice", v)
|
|
}
|
|
}
|
|
|
|
|
|
// TestForCtx_GatewayBypass returns claims.ErrGatewayBypass when the context
|
|
// has no claims attached (identity stripped or never injected).
|
|
func TestForCtx_GatewayBypass(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
// Bare context, no Inject.
|
|
_, err := s.ForCtx(context.Background())
|
|
if !errors.Is(err, claims.ErrGatewayBypass) {
|
|
t.Fatalf("expected ErrGatewayBypass, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckpoint_WritesToObjectStorage ensures that explicit Checkpoint
|
|
// uploads the DB into the backing bucket.
|
|
func TestCheckpoint_WritesToObjectStorage(t *testing.T) {
|
|
s, bucketDir := newTestStore(t)
|
|
ctx := ctxWithClaims("acme", "alice")
|
|
|
|
db, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("CREATE TABLE t(v TEXT)").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("INSERT INTO t VALUES ('persist-me')").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
k := store.Key{OrgID: "acme", UserID: "alice", Scope: store.ScopeUser}
|
|
if err := s.Checkpoint(ctx, k); err != nil {
|
|
t.Fatalf("Checkpoint: %v", err)
|
|
}
|
|
|
|
// Object should exist in the bucket on disk.
|
|
objPath := filepath.Join(bucketDir, "acme", "users", "alice.db")
|
|
if !fileExists(t, objPath) {
|
|
t.Fatalf("expected object at %s", objPath)
|
|
}
|
|
}
|
|
|
|
// TestEvictionPersistsAndRehydrates: fill the LRU past its cap, confirm the
|
|
// coldest is evicted (and its state is recoverable via re-hydrate from the
|
|
// bucket).
|
|
func TestEvictionPersistsAndRehydrates(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
|
|
// LRUSize = 4. Open 5 distinct tenants; the first should be evicted.
|
|
tenants := []struct{ org, user string }{
|
|
{"o1", "u1"}, {"o2", "u2"}, {"o3", "u3"}, {"o4", "u4"}, {"o5", "u5"},
|
|
}
|
|
for _, tn := range tenants {
|
|
ctx := ctxWithClaims(tn.org, tn.user)
|
|
db, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("CREATE TABLE t(v TEXT)").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("INSERT INTO t VALUES ('" + tn.user + "')").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Force explicit checkpoint so the bucket has the data before LRU
|
|
// eviction triggers best-effort flush.
|
|
k := store.Key{OrgID: tn.org, UserID: tn.user, Scope: store.ScopeUser}
|
|
if err := s.Checkpoint(ctx, k); err != nil {
|
|
t.Fatalf("checkpoint %v: %v", k, err)
|
|
}
|
|
}
|
|
|
|
// Re-hydrate the first (evicted) tenant from the bucket.
|
|
ctx := ctxWithClaims("o1", "u1")
|
|
db, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatalf("rehydrate o1/u1: %v", err)
|
|
}
|
|
var v string
|
|
if err := db.NewQuery("SELECT v FROM t").Row(&v); err != nil {
|
|
t.Fatalf("read after rehydrate: %v", err)
|
|
}
|
|
if v != "u1" {
|
|
t.Fatalf("expected u1, got %q", v)
|
|
}
|
|
}
|
|
|
|
// TestClose_FlushesDirty: confirm that Close snapshots every resident handle
|
|
// to the bucket before returning.
|
|
func TestClose_FlushesDirty(t *testing.T) {
|
|
s, bucketDir := newTestStore(t)
|
|
|
|
for _, u := range []string{"a", "b", "c"} {
|
|
ctx := ctxWithClaims("acme", u)
|
|
db, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("CREATE TABLE t(v TEXT)").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("INSERT INTO t VALUES ('" + u + "')").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if err := s.Close(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, u := range []string{"a", "b", "c"} {
|
|
obj := filepath.Join(bucketDir, "acme", "users", u+".db")
|
|
if !fileExists(t, obj) {
|
|
t.Errorf("expected flushed object for %s at %s", u, obj)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConcurrentReaders_OnSameTenant: multiple goroutines hitting ForCtx on
|
|
// the same key hydrate exactly once.
|
|
func TestConcurrentReaders_OnSameTenant(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
ctx := ctxWithClaims("acme", "alice")
|
|
|
|
var wg sync.WaitGroup
|
|
seen := make([]interface{}, 16)
|
|
for i := range seen {
|
|
i := i
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
db, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
seen[i] = db
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
for i := 1; i < len(seen); i++ {
|
|
if seen[i] != seen[0] {
|
|
t.Fatalf("goroutine %d got a different *dbx.DB", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOrgScope: ForOrg resolves the org-wide DB (distinct from any user DB).
|
|
func TestOrgScope(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
ctx := ctxWithClaims("acme", "alice")
|
|
|
|
orgDB, err := s.ForOrg(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
userDB, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if orgDB == userDB {
|
|
t.Fatal("ForOrg must not alias ForCtx — they are different DBs")
|
|
}
|
|
}
|
|
|
|
// TestDoubleCloseIdempotent: calling Close twice is safe.
|
|
func TestDoubleCloseIdempotent(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
if err := s.Close(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Close(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// P7 — Red re-review coverage. Each test maps to one Red finding.
|
|
// ============================================================
|
|
|
|
// TestHydrate_RejectsPoisonedBlob (P7-C2) — a non-SQLite blob planted in
|
|
// the bucket is rejected on hydrate and the modernc driver never touches
|
|
// the bytes.
|
|
func TestHydrate_RejectsPoisonedBlob(t *testing.T) {
|
|
s, bucketDir := newTestStore(t)
|
|
|
|
// Plant a malicious blob at the expected object path.
|
|
if err := os.MkdirAll(filepath.Join(bucketDir, "acme", "users"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
poison := []byte("--evil DROP TABLE users; SELECT * FROM sqlite_master;--")
|
|
if err := os.WriteFile(filepath.Join(bucketDir, "acme", "users", "alice.db"), poison, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx := ctxWithClaims("acme", "alice")
|
|
db, err := s.ForCtx(ctx)
|
|
if !errors.Is(err, store.ErrCorruptDB) {
|
|
t.Fatalf("ForCtx: got err=%v, want ErrCorruptDB (db=%v)", err, db)
|
|
}
|
|
if db != nil {
|
|
t.Fatalf("ForCtx returned a non-nil db for a poisoned blob: %v", db)
|
|
}
|
|
}
|
|
|
|
// TestValidateSQLiteMagic checks boundary cases on the magic-header check.
|
|
func TestValidateSQLiteMagic_AcceptsEmpty(t *testing.T) {
|
|
// This exercises store.verifySQLiteFile indirectly through a fresh
|
|
// tenant — empty local file must NOT be rejected (a new tenant's
|
|
// localPath is 0 bytes on first hydrate).
|
|
s, _ := newTestStore(t)
|
|
ctx := ctxWithClaims("acme", "alice")
|
|
if _, err := s.ForCtx(ctx); err != nil {
|
|
t.Fatalf("fresh tenant (empty file) must not be rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEvict_SurfacesUploadError (P7-H1) — when object-store Upload fails,
|
|
// Evict returns an error wrapping ErrUploadFailed and the local data is
|
|
// retained (caller can retry, data not silently lost).
|
|
func TestEvict_SurfacesUploadError(t *testing.T) {
|
|
s, bucketDir := newTestStore(t)
|
|
ctx := ctxWithClaims("acme", "alice")
|
|
|
|
db, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("CREATE TABLE t(v TEXT)").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("INSERT INTO t VALUES ('keep-me')").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Lock the bucket so Upload fails. fileblob writes a tempfile inside
|
|
// the destination directory and renames it — chmod 0500 on the dir
|
|
// that holds the target prevents both file creation and rename.
|
|
targetDir := filepath.Join(bucketDir, "acme", "users")
|
|
// The directory may not exist yet (no prior upload). Create it.
|
|
if err := os.MkdirAll(targetDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(targetDir, 0o500); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(targetDir, 0o700) })
|
|
|
|
k := store.Key{OrgID: "acme", UserID: "alice", Scope: store.ScopeUser}
|
|
err = s.Evict(context.Background(), k)
|
|
if !errors.Is(err, store.ErrUploadFailed) {
|
|
t.Fatalf("Evict: err=%v, want ErrUploadFailed", err)
|
|
}
|
|
}
|
|
|
|
// TestClose_AggregatesUploadFailures (P7-H1) — Close loudly surfaces how
|
|
// many tenants failed to flush so ops can intervene.
|
|
func TestClose_AggregatesUploadFailures(t *testing.T) {
|
|
s, bucketDir := newTestStore(t)
|
|
|
|
for _, u := range []string{"alice", "bob", "carol"} {
|
|
ctx := ctxWithClaims("acme", u)
|
|
db, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("CREATE TABLE t(v TEXT)").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("INSERT INTO t VALUES ('data')").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Poison every upload target.
|
|
targetDir := filepath.Join(bucketDir, "acme", "users")
|
|
if err := os.MkdirAll(targetDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(targetDir, 0o500); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(targetDir, 0o700) })
|
|
|
|
err := s.Close(context.Background())
|
|
if err == nil {
|
|
t.Fatalf("Close: expected aggregated upload-failure error, got nil")
|
|
}
|
|
if !errors.Is(err, store.ErrUploadFailed) {
|
|
t.Fatalf("Close: err=%v, want Is(ErrUploadFailed)", err)
|
|
}
|
|
}
|
|
|
|
// TestReaper_LockHoldTimeChunked (P7-H2) — with 100 resident handles, a
|
|
// Get call for a fresh tenant made while the reaper is flushing must
|
|
// return fast (under 500ms), proving the reaper releases s.mu per-handle.
|
|
func TestReaper_LockHoldTimeChunked(t *testing.T) {
|
|
// Large LRU so filling 100 handles doesn't trigger cap eviction; tiny
|
|
// idle TTL so every handle is eligible for reap.
|
|
s, _ := newTestStore(t, func(o *store.Options) {
|
|
o.LRUSize = 200
|
|
o.IdleTTL = 1 * time.Millisecond
|
|
o.CheckpointInterval = 10 * time.Minute // disable time-based flush
|
|
})
|
|
|
|
// Fill 100 handles.
|
|
for i := 0; i < 100; i++ {
|
|
ctx := ctxWithClaims("acme", "u"+itoa(i))
|
|
if _, err := s.ForCtx(ctx); err != nil {
|
|
t.Fatalf("warmup %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Force every handle's lastAccess beyond the TTL cutoff.
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
// Kick the reaper by sleeping past one tick interval. The default
|
|
// reaperLoop tick is IdleTTL/2 = 500µs; definitely fired by 5ms.
|
|
done := make(chan struct{})
|
|
start := time.Now()
|
|
go func() {
|
|
// While the reaper is mid-flush, issue a cold Get for a NEW tenant.
|
|
// Under the old (O(N)) reaper this Get blocks for the entire reap
|
|
// cycle; under the chunked reaper it blocks at most one handle.
|
|
ctx := ctxWithClaims("acme", "probe")
|
|
_, _ = s.ForCtx(ctx)
|
|
close(done)
|
|
}()
|
|
<-done
|
|
elapsed := time.Since(start)
|
|
if elapsed > 500*time.Millisecond {
|
|
t.Fatalf("cold Get during reap blocked for %v (>500ms)", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestEvictionReturnsError_RecoversOnRetry (P7-H1) — after an upload-fail
|
|
// Evict, clearing the failure lets the next Evict succeed and drops the
|
|
// handle cleanly. No data is lost in the local cache until durable write
|
|
// is confirmed.
|
|
func TestEvictionReturnsError_RecoversOnRetry(t *testing.T) {
|
|
s, bucketDir := newTestStore(t)
|
|
ctx := ctxWithClaims("acme", "alice")
|
|
|
|
db, err := s.ForCtx(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("CREATE TABLE t(v TEXT)").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.NewQuery("INSERT INTO t VALUES ('x')").Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
targetDir := filepath.Join(bucketDir, "acme", "users")
|
|
if err := os.MkdirAll(targetDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(targetDir, 0o500); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
k := store.Key{OrgID: "acme", UserID: "alice", Scope: store.ScopeUser}
|
|
if err := s.Evict(context.Background(), k); !errors.Is(err, store.ErrUploadFailed) {
|
|
t.Fatalf("first Evict should fail: %v", err)
|
|
}
|
|
|
|
// Clear the poisoning.
|
|
if err := os.Chmod(targetDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A retry succeeds; data is now durable.
|
|
if err := s.Evict(context.Background(), k); err != nil {
|
|
t.Fatalf("second Evict should succeed: %v", err)
|
|
}
|
|
if !fileExists(t, filepath.Join(targetDir, "alice.db")) {
|
|
t.Fatalf("bucket should have the flushed DB now")
|
|
}
|
|
}
|
|
|
|
// TestCAS_ConstantExposed (P7-C1 option B) — consumers that want
|
|
// generation-checked writes must feature-gate on store.CAS. In v1 it's
|
|
// false; a CAS-requiring service can refuse to boot.
|
|
func TestCAS_ConstantExposed(t *testing.T) {
|
|
if store.CAS {
|
|
t.Fatal("store.CAS must be false in v1 until ETag upload path lands")
|
|
}
|
|
}
|
|
|
|
// TestKey_Valid_RejectsLongSlug (P7-M1/H additional) — slugs over the cap
|
|
// are rejected by validateSlug before any FS call. No log-injection
|
|
// surface through ENAMETOOLONG.
|
|
func TestKey_Valid_RejectsLongSlug(t *testing.T) {
|
|
long := make([]byte, store.MaxSlugLen+1)
|
|
for i := range long {
|
|
long[i] = 'a'
|
|
}
|
|
k := store.Key{OrgID: string(long), UserID: "alice", Scope: store.ScopeUser}
|
|
if err := k.Valid(); err == nil {
|
|
t.Fatalf("expected Valid()=err for %d-byte slug", len(long))
|
|
}
|
|
}
|
|
|
|
// helpers
|
|
|
|
func fileExists(t *testing.T, path string) bool {
|
|
t.Helper()
|
|
info, err := os.Stat(path)
|
|
return err == nil && info != nil
|
|
}
|
|
|
|
// itoa is a tiny int→string for test IDs; avoids strconv just to keep
|
|
// the test file focused on the testing DSL.
|
|
func itoa(i int) string {
|
|
if i == 0 {
|
|
return "0"
|
|
}
|
|
var buf [20]byte
|
|
pos := len(buf)
|
|
for i > 0 {
|
|
pos--
|
|
buf[pos] = byte('0' + i%10)
|
|
i /= 10
|
|
}
|
|
return string(buf[pos:])
|
|
}
|