package services import ( "context" "testing" "clawreef/internal/models" ) // stubInstanceVisibilityRepo is a minimal InstanceRepository used by the // visibility tests below. It only implements the read paths exercised by // GetByUserID / GetAllInstances; every other method panics so that any // accidental call surfaces as a test failure instead of a silent stub. type stubInstanceVisibilityRepo struct { all []models.Instance } func (r *stubInstanceVisibilityRepo) byUser(userID int) []models.Instance { out := make([]models.Instance, 0, len(r.all)) for _, inst := range r.all { if inst.UserID == userID { out = append(out, inst) } } return out } func (r *stubInstanceVisibilityRepo) Create(*models.Instance) error { panic("not used") } func (r *stubInstanceVisibilityRepo) GetByID(int) (*models.Instance, error) { panic("not used") } func (r *stubInstanceVisibilityRepo) GetByAccessToken(string) (*models.Instance, error) { panic("not used") } func (r *stubInstanceVisibilityRepo) GetByAgentBootstrapToken(string) (*models.Instance, error) { panic("not used") } func (r *stubInstanceVisibilityRepo) GetAll(offset, limit int) ([]models.Instance, error) { return paginate(r.all, offset, limit), nil } func (r *stubInstanceVisibilityRepo) CountAll() (int, error) { return len(r.all), nil } func (r *stubInstanceVisibilityRepo) GetByUserID(userID, offset, limit int) ([]models.Instance, error) { return paginate(r.byUser(userID), offset, limit), nil } func (r *stubInstanceVisibilityRepo) CountByUserID(userID int) (int, error) { return len(r.byUser(userID)), nil } func (r *stubInstanceVisibilityRepo) CountActiveByMode(context.Context, string) (int, error) { return 0, nil } func (r *stubInstanceVisibilityRepo) ExistsByUserIDAndName(int, string) (bool, error) { return false, nil } func (r *stubInstanceVisibilityRepo) GetAllRunning() ([]models.Instance, error) { return nil, nil } func (r *stubInstanceVisibilityRepo) GetV2DesiredRunning(context.Context, int) ([]models.Instance, error) { panic("not used") } func (r *stubInstanceVisibilityRepo) GetV2Creating(context.Context, int) ([]models.Instance, error) { panic("not used") } func (r *stubInstanceVisibilityRepo) UpdateRuntimeState(context.Context, int, string, int, *string) error { panic("not used") } func (r *stubInstanceVisibilityRepo) SetWorkspacePath(context.Context, int, string) error { panic("not used") } func (r *stubInstanceVisibilityRepo) UpdateWorkspaceUsage(context.Context, int, int64) error { panic("not used") } func (r *stubInstanceVisibilityRepo) Update(*models.Instance) error { panic("not used") } func (r *stubInstanceVisibilityRepo) Delete(int) error { panic("not used") } func paginate(items []models.Instance, offset, limit int) []models.Instance { if offset >= len(items) { return []models.Instance{} } end := offset + limit if end > len(items) { end = len(items) } out := make([]models.Instance, end-offset) copy(out, items[offset:end]) return out } // TestGetByUserIDFiltersByCaller locks in the workspace-view contract: the // caller-scoped listing must return only the caller's own instances, // regardless of any role the caller may hold elsewhere. Regression guard // for the admin-cross-user-leakage bug in which role=admin widened this // endpoint to every user's instances. func TestGetByUserIDFiltersByCaller(t *testing.T) { t.Parallel() repo := &stubInstanceVisibilityRepo{ all: []models.Instance{ {ID: 1, UserID: 10, Name: "alice-1"}, {ID: 2, UserID: 10, Name: "alice-2"}, {ID: 3, UserID: 20, Name: "bob-1"}, }, } svc := &instanceService{instanceRepo: repo} instances, total, err := svc.GetByUserID(10, 0, 50) if err != nil { t.Fatalf("GetByUserID returned error: %v", err) } if total != 2 { t.Fatalf("expected total=2 for alice, got %d", total) } if len(instances) != 2 || instances[0].Name != "alice-1" || instances[1].Name != "alice-2" { t.Fatalf("expected alice's instances only, got %+v", instances) } instances, total, err = svc.GetByUserID(20, 0, 50) if err != nil { t.Fatalf("GetByUserID returned error: %v", err) } if total != 1 { t.Fatalf("expected total=1 for bob, got %d", total) } if len(instances) != 1 || instances[0].Name != "bob-1" { t.Fatalf("expected bob's instance only, got %+v", instances) } // A user with no instances sees an empty list, never someone else's. instances, total, err = svc.GetByUserID(99, 0, 50) if err != nil { t.Fatalf("GetByUserID returned error: %v", err) } if total != 0 || len(instances) != 0 { t.Fatalf("expected empty listing for user 99, got total=%d instances=%+v", total, instances) } } // TestGetAllInstancesReturnsEveryUser verifies the admin-console surface: // cross-user listing, with pagination, independent of caller identity (the // admin middleware gates reachability at the route layer, not here). func TestGetAllInstancesReturnsEveryUser(t *testing.T) { t.Parallel() repo := &stubInstanceVisibilityRepo{ all: []models.Instance{ {ID: 1, UserID: 10, Name: "alice-1"}, {ID: 2, UserID: 10, Name: "alice-2"}, {ID: 3, UserID: 20, Name: "bob-1"}, }, } svc := &instanceService{instanceRepo: repo} instances, total, err := svc.GetAllInstances(0, 50) if err != nil { t.Fatalf("GetAllInstances returned error: %v", err) } if total != 3 { t.Fatalf("expected total=3 across users, got %d", total) } if len(instances) != 3 { t.Fatalf("expected 3 instances, got %d", len(instances)) } // Pagination still respected. page1, _, err := svc.GetAllInstances(0, 2) if err != nil { t.Fatalf("GetAllInstances page1 error: %v", err) } if len(page1) != 2 { t.Fatalf("expected page1 size=2, got %d", len(page1)) } page2, _, err := svc.GetAllInstances(2, 2) if err != nil { t.Fatalf("GetAllInstances page2 error: %v", err) } if len(page2) != 1 || page2[0].Name != "bob-1" { t.Fatalf("expected page2=[bob-1], got %+v", page2) } }