Skip to content

Commit 735aec5

Browse files
committed
test(api,db): cover snapshot team scoping
Add tests for the ENG-3544 team scoping at every layer: - db: GetLastSnapshot returns the snapshot for the owning team, ErrNoRows for another team, and the snapshot when no team is given. - cache: SnapshotCache isolates teams by cache key and Invalidate clears the team-scoped key via prefix deletion. - handlers: pauseHandleNotRunningSandbox returns 404 for a foreign team and 409 for the owner; get/connect/resume return 404 end to end when the snapshot belongs to another team (real Postgres+Redis containers, mocked orchestrator). Also add the InsertTestSnapshot/EnvBuild test query helpers.
1 parent b7ca3bd commit 735aec5

6 files changed

Lines changed: 505 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package snapshotcache
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/uuid"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/e2b-dev/infra/packages/db/pkg/testutils"
11+
redis_utils "github.com/e2b-dev/infra/packages/shared/pkg/redis"
12+
)
13+
14+
// TestSnapshotCache_TeamScopedIsolationAcrossTeams verifies that a snapshot fetched
15+
// through the cache is scoped to the owning team: the owner reads it, while another
16+
// team querying the identical sandbox ID gets a not-found result (no cross-team leak
17+
// and no cache-key collision between teams).
18+
func TestSnapshotCache_TeamScopedIsolationAcrossTeams(t *testing.T) {
19+
t.Parallel()
20+
db := testutils.SetupDatabase(t)
21+
redisClient := redis_utils.SetupInstance(t)
22+
ctx := t.Context()
23+
24+
sc := NewSnapshotCache(db.SqlcClient, redisClient)
25+
defer sc.Close(ctx)
26+
27+
ownerTeamID := testutils.CreateTestTeam(t, db)
28+
baseTemplateID := testutils.CreateTestTemplate(t, db, ownerTeamID)
29+
30+
sandboxID := "sandbox-" + uuid.New().String()
31+
snapshotTemplateID := "snapshot-template-" + uuid.New().String()
32+
result := testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, ownerTeamID, baseTemplateID)
33+
34+
// Owner team reads its own snapshot.
35+
info, err := sc.Get(ctx, sandboxID, ownerTeamID)
36+
require.NoError(t, err)
37+
assert.Equal(t, result.BuildID, info.EnvBuild.ID,
38+
"owning team should read the snapshot build through the cache")
39+
40+
// A different team must not see it, even though the sandbox ID is identical.
41+
otherTeamID := testutils.CreateTestTeam(t, db)
42+
_, err = sc.Get(ctx, sandboxID, otherTeamID)
43+
require.ErrorIs(t, err, ErrSnapshotNotFound,
44+
"a non-owning team must not read another team's snapshot")
45+
}
46+
47+
// TestSnapshotCache_InvalidateClearsTeamScopedKey verifies that Invalidate clears the
48+
// team-scoped cache key (sandboxID:teamID) and not only the plain sandboxID key.
49+
// It first caches a negative (not-found) result under the team-scoped key, then proves
50+
// the negative result is served from cache, and finally that Invalidate forces a
51+
// re-fetch that now finds the snapshot.
52+
func TestSnapshotCache_InvalidateClearsTeamScopedKey(t *testing.T) {
53+
t.Parallel()
54+
db := testutils.SetupDatabase(t)
55+
redisClient := redis_utils.SetupInstance(t)
56+
ctx := t.Context()
57+
58+
sc := NewSnapshotCache(db.SqlcClient, redisClient)
59+
defer sc.Close(ctx)
60+
61+
teamID := testutils.CreateTestTeam(t, db)
62+
baseTemplateID := testutils.CreateTestTemplate(t, db, teamID)
63+
64+
sandboxID := "sandbox-" + uuid.New().String()
65+
snapshotTemplateID := "snapshot-template-" + uuid.New().String()
66+
67+
// No snapshot yet: not-found, cached under the team-scoped key.
68+
_, err := sc.Get(ctx, sandboxID, teamID)
69+
require.ErrorIs(t, err, ErrSnapshotNotFound)
70+
71+
// The snapshot now appears in the DB.
72+
result := testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, teamID, baseTemplateID)
73+
74+
// The cached negative result is still served, proving the team-scoped key is cached.
75+
_, err = sc.Get(ctx, sandboxID, teamID)
76+
require.ErrorIs(t, err, ErrSnapshotNotFound,
77+
"the team-scoped negative result should be served from cache before invalidation")
78+
79+
// Invalidate must delete the team-scoped key (sandboxID:teamID) via prefix deletion.
80+
sc.Invalidate(ctx, sandboxID)
81+
82+
info, err := sc.Get(ctx, sandboxID, teamID)
83+
require.NoError(t, err, "after Invalidate the team-scoped key must be re-fetched from the DB")
84+
assert.Equal(t, result.BuildID, info.EnvBuild.ID)
85+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package handlers
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/google/uuid"
8+
"github.com/stretchr/testify/assert"
9+
10+
snapshotcache "github.com/e2b-dev/infra/packages/api/internal/cache/snapshots"
11+
"github.com/e2b-dev/infra/packages/db/pkg/testutils"
12+
redis_utils "github.com/e2b-dev/infra/packages/shared/pkg/redis"
13+
)
14+
15+
// newTestSnapshotCache builds a SnapshotCache backed by a real Postgres and Redis
16+
// test container, mirroring the wiring in NewSnapshotCache used by the API store.
17+
func newTestSnapshotCache(t *testing.T, db *testutils.Database) *snapshotcache.SnapshotCache {
18+
t.Helper()
19+
20+
redisClient := redis_utils.SetupInstance(t)
21+
sc := snapshotcache.NewSnapshotCache(db.SqlcClient, redisClient)
22+
t.Cleanup(func() {
23+
_ = sc.Close(t.Context())
24+
})
25+
26+
return sc
27+
}
28+
29+
// TestPauseHandleNotRunningSandbox_WrongTeamReturnsNotFound verifies the ENG-3544
30+
// behavior in the pause path: pausing a not-running sandbox whose snapshot belongs to
31+
// another team must return 404 (indistinguishable from a sandbox that does not exist),
32+
// rather than leaking that a paused snapshot exists.
33+
func TestPauseHandleNotRunningSandbox_WrongTeamReturnsNotFound(t *testing.T) {
34+
t.Parallel()
35+
db := testutils.SetupDatabase(t)
36+
ctx := t.Context()
37+
38+
sc := newTestSnapshotCache(t, db)
39+
40+
ownerTeamID := testutils.CreateTestTeam(t, db)
41+
baseTemplateID := testutils.CreateTestTemplate(t, db, ownerTeamID)
42+
43+
sandboxID := "sandbox-" + uuid.New().String()
44+
snapshotTemplateID := "snapshot-template-" + uuid.New().String()
45+
testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, ownerTeamID, baseTemplateID)
46+
47+
otherTeamID := testutils.CreateTestTeam(t, db)
48+
49+
apiErr := pauseHandleNotRunningSandbox(ctx, sc, sandboxID, otherTeamID)
50+
assert.Equal(t, http.StatusNotFound, apiErr.Code,
51+
"another team's paused sandbox must be reported as not found")
52+
}
53+
54+
// TestPauseHandleNotRunningSandbox_OwnerReturnsAlreadyPaused verifies that when the
55+
// owning team pauses a sandbox whose snapshot already exists, the handler reports the
56+
// already-paused conflict rather than a not-found.
57+
func TestPauseHandleNotRunningSandbox_OwnerReturnsAlreadyPaused(t *testing.T) {
58+
t.Parallel()
59+
db := testutils.SetupDatabase(t)
60+
ctx := t.Context()
61+
62+
sc := newTestSnapshotCache(t, db)
63+
64+
teamID := testutils.CreateTestTeam(t, db)
65+
baseTemplateID := testutils.CreateTestTemplate(t, db, teamID)
66+
67+
sandboxID := "sandbox-" + uuid.New().String()
68+
snapshotTemplateID := "snapshot-template-" + uuid.New().String()
69+
testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, teamID, baseTemplateID)
70+
71+
apiErr := pauseHandleNotRunningSandbox(ctx, sc, sandboxID, teamID)
72+
assert.Equal(t, http.StatusConflict, apiErr.Code,
73+
"the owning team's already-paused sandbox should return a conflict")
74+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package handlers
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/gin-gonic/gin"
12+
"github.com/google/uuid"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/mock"
15+
"github.com/stretchr/testify/require"
16+
17+
"github.com/e2b-dev/infra/packages/api/internal/api"
18+
handlersmocks "github.com/e2b-dev/infra/packages/api/internal/handlers/mocks"
19+
"github.com/e2b-dev/infra/packages/api/internal/sandbox"
20+
"github.com/e2b-dev/infra/packages/auth/pkg/auth"
21+
authtypes "github.com/e2b-dev/infra/packages/auth/pkg/types"
22+
authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries"
23+
"github.com/e2b-dev/infra/packages/db/pkg/testutils"
24+
)
25+
26+
// newCrossTeamSnapshotFixture creates a snapshot owned by one team and returns an
27+
// APIStore whose orchestrator is mocked (so the sandbox is reported not-running and
28+
// the handler falls through to the snapshot lookup) together with a *different*
29+
// requesting team. The returned sandboxID is a bare, dash-free lowercase ID so it
30+
// survives utils.ShortID parsing unchanged and matches the stored snapshot row.
31+
func newCrossTeamSnapshotFixture(t *testing.T) (store *APIStore, orch *handlersmocks.MockSandboxOrchestrator, requesterTeamID uuid.UUID, sandboxID string) {
32+
t.Helper()
33+
34+
db := testutils.SetupDatabase(t)
35+
ctx := t.Context()
36+
37+
sc := newTestSnapshotCache(t, db)
38+
orch = handlersmocks.NewMockSandboxOrchestrator(t)
39+
40+
ownerTeamID := testutils.CreateTestTeam(t, db)
41+
baseTemplateID := testutils.CreateTestTemplate(t, db, ownerTeamID)
42+
43+
sandboxID = strings.ReplaceAll(uuid.NewString(), "-", "")
44+
snapshotTemplateID := "snapshot-template-" + uuid.New().String()
45+
testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, ownerTeamID, baseTemplateID)
46+
47+
requesterTeamID = testutils.CreateTestTeam(t, db)
48+
49+
store = &APIStore{
50+
orchestrator: orch,
51+
snapshotCache: sc,
52+
}
53+
54+
return store, orch, requesterTeamID, sandboxID
55+
}
56+
57+
func newTeamGinContext(t *testing.T, teamID uuid.UUID, method, target string, body any) (*httptest.ResponseRecorder, *gin.Context) {
58+
t.Helper()
59+
60+
rec := httptest.NewRecorder()
61+
c, _ := gin.CreateTestContext(rec)
62+
63+
var raw []byte
64+
if body != nil {
65+
var err error
66+
raw, err = json.Marshal(body)
67+
require.NoError(t, err)
68+
}
69+
70+
c.Request = httptest.NewRequestWithContext(t.Context(), method, target, bytes.NewReader(raw))
71+
c.Request.Header.Set("Content-Type", "application/json")
72+
73+
auth.SetTeamInfoForTest(t, c, &authtypes.Team{
74+
Team: &authqueries.Team{ID: teamID, Slug: "team-" + teamID.String()[:8]},
75+
Limits: &authtypes.TeamLimits{MaxLengthHours: 24},
76+
})
77+
78+
return rec, c
79+
}
80+
81+
// TestGetSandboxesSandboxID_CrossTeamSnapshotReturnsNotFound verifies ENG-3544 end to
82+
// end for GET: when the running sandbox is absent and the snapshot belongs to another
83+
// team, the requester receives 404 rather than the other team's snapshot.
84+
func TestGetSandboxesSandboxID_CrossTeamSnapshotReturnsNotFound(t *testing.T) {
85+
t.Parallel()
86+
87+
store, orch, requesterTeamID, sandboxID := newCrossTeamSnapshotFixture(t)
88+
89+
orch.EXPECT().
90+
GetSandbox(mock.Anything, requesterTeamID, sandboxID).
91+
Return(sandbox.Sandbox{}, sandbox.ErrNotFound)
92+
93+
rec, c := newTeamGinContext(t, requesterTeamID, http.MethodGet, "/sandboxes/"+sandboxID, nil)
94+
store.GetSandboxesSandboxID(c, sandboxID)
95+
96+
assert.Equal(t, http.StatusNotFound, rec.Code)
97+
}
98+
99+
// TestPostSandboxesSandboxIDConnect_CrossTeamSnapshotReturnsNotFound verifies the same
100+
// for the connect path: KeepAliveFor reports the sandbox is not in the store, so the
101+
// handler falls through to the team-scoped snapshot lookup, which must not find another
102+
// team's snapshot.
103+
func TestPostSandboxesSandboxIDConnect_CrossTeamSnapshotReturnsNotFound(t *testing.T) {
104+
t.Parallel()
105+
106+
store, orch, requesterTeamID, sandboxID := newCrossTeamSnapshotFixture(t)
107+
108+
orch.EXPECT().
109+
KeepAliveFor(mock.Anything, requesterTeamID, sandboxID, mock.Anything, false).
110+
Return(nil, &api.APIError{Code: http.StatusNotFound, ClientMsg: "not found", Err: sandbox.ErrNotFound})
111+
112+
rec, c := newTeamGinContext(t, requesterTeamID, http.MethodPost, "/sandboxes/"+sandboxID+"/connect", api.ConnectSandbox{Timeout: 30})
113+
store.PostSandboxesSandboxIDConnect(c, sandboxID)
114+
115+
assert.Equal(t, http.StatusNotFound, rec.Code)
116+
}
117+
118+
// TestPostSandboxesSandboxIDResume_CrossTeamSnapshotReturnsNotFound verifies the same
119+
// for the resume path: GetSandbox reports not-running, and the team-scoped snapshot
120+
// lookup must not resume another team's snapshot.
121+
func TestPostSandboxesSandboxIDResume_CrossTeamSnapshotReturnsNotFound(t *testing.T) {
122+
t.Parallel()
123+
124+
store, orch, requesterTeamID, sandboxID := newCrossTeamSnapshotFixture(t)
125+
126+
orch.EXPECT().
127+
GetSandbox(mock.Anything, requesterTeamID, sandboxID).
128+
Return(sandbox.Sandbox{}, sandbox.ErrNotFound)
129+
130+
rec, c := newTeamGinContext(t, requesterTeamID, http.MethodPost, "/sandboxes/"+sandboxID+"/resume", api.ResumedSandbox{})
131+
store.PostSandboxesSandboxIDResume(c, sandboxID)
132+
133+
assert.Equal(t, http.StatusNotFound, rec.Code)
134+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package snapshots
2+
3+
import (
4+
"testing"
5+
6+
"github.com/google/uuid"
7+
"github.com/jackc/pgx/v5"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/e2b-dev/infra/packages/db/pkg/testutils"
12+
"github.com/e2b-dev/infra/packages/db/queries"
13+
)
14+
15+
// TestGetLastSnapshot_MatchingTeamReturnsSnapshot verifies that scoping the query
16+
// to the owning team returns the snapshot's latest build.
17+
func TestGetLastSnapshot_MatchingTeamReturnsSnapshot(t *testing.T) {
18+
t.Parallel()
19+
db := testutils.SetupDatabase(t)
20+
ctx := t.Context()
21+
22+
teamID := testutils.CreateTestTeam(t, db)
23+
baseTemplateID := testutils.CreateTestTemplate(t, db, teamID)
24+
25+
sandboxID := "sandbox-" + uuid.New().String()
26+
snapshotTemplateID := "snapshot-template-" + uuid.New().String()
27+
result := testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, teamID, baseTemplateID)
28+
29+
snapshot, err := db.SqlcClient.GetLastSnapshot(ctx, queries.GetLastSnapshotParams{
30+
SandboxID: sandboxID,
31+
TeamID: &teamID,
32+
})
33+
require.NoError(t, err)
34+
35+
assert.Equal(t, result.BuildID, snapshot.EnvBuild.ID,
36+
"GetLastSnapshot scoped to the owning team should return the snapshot build")
37+
}
38+
39+
// TestGetLastSnapshot_WrongTeamReturnsNotFound is the core security assertion for
40+
// ENG-3544: a team must not be able to read another team's snapshot. Scoping the
41+
// query to a non-owning team must yield no rows.
42+
func TestGetLastSnapshot_WrongTeamReturnsNotFound(t *testing.T) {
43+
t.Parallel()
44+
db := testutils.SetupDatabase(t)
45+
ctx := t.Context()
46+
47+
ownerTeamID := testutils.CreateTestTeam(t, db)
48+
baseTemplateID := testutils.CreateTestTemplate(t, db, ownerTeamID)
49+
50+
sandboxID := "sandbox-" + uuid.New().String()
51+
snapshotTemplateID := "snapshot-template-" + uuid.New().String()
52+
testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, ownerTeamID, baseTemplateID)
53+
54+
otherTeamID := testutils.CreateTestTeam(t, db)
55+
56+
_, err := db.SqlcClient.GetLastSnapshot(ctx, queries.GetLastSnapshotParams{
57+
SandboxID: sandboxID,
58+
TeamID: &otherTeamID,
59+
})
60+
require.ErrorIs(t, err, pgx.ErrNoRows,
61+
"GetLastSnapshot scoped to a non-owning team must not return the snapshot")
62+
}
63+
64+
// TestGetLastSnapshot_NilTeamReturnsSnapshot verifies the backwards-compatible path:
65+
// when no team is provided the query is not team-scoped and still returns the snapshot.
66+
func TestGetLastSnapshot_NilTeamReturnsSnapshot(t *testing.T) {
67+
t.Parallel()
68+
db := testutils.SetupDatabase(t)
69+
ctx := t.Context()
70+
71+
teamID := testutils.CreateTestTeam(t, db)
72+
baseTemplateID := testutils.CreateTestTemplate(t, db, teamID)
73+
74+
sandboxID := "sandbox-" + uuid.New().String()
75+
snapshotTemplateID := "snapshot-template-" + uuid.New().String()
76+
result := testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, teamID, baseTemplateID)
77+
78+
snapshot, err := db.SqlcClient.GetLastSnapshot(ctx, queries.GetLastSnapshotParams{
79+
SandboxID: sandboxID,
80+
TeamID: nil,
81+
})
82+
require.NoError(t, err)
83+
84+
assert.Equal(t, result.BuildID, snapshot.EnvBuild.ID,
85+
"GetLastSnapshot without team scoping should still return the snapshot build")
86+
}

0 commit comments

Comments
 (0)