diff --git a/.env.example b/.env.example index 750fd91..00b9ac8 100644 --- a/.env.example +++ b/.env.example @@ -42,7 +42,8 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,https://smctf.example.com # Stack (Container Provisioner) STACKS_ENABLED=true -STACKS_MAX_PER_USER=3 +STACKS_MAX_SCOPE=team +STACKS_MAX_PER=3 STACKS_PROVISIONER_BASE_URL=http://localhost:8081 STACKS_PROVISIONER_API_KEY=change-me STACKS_PROVISIONER_TIMEOUT=5s diff --git a/README.md b/README.md index 5512400..9246d67 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ As a result, we decided to develop our own CTF platform as a long term project. See [SMCTF Docs](https://ctf.null4u.cloud/smctf/) for more details. This README only provides a brief overview. -### Available/Stable features: + ### Planned/Upcoming features: @@ -170,7 +170,8 @@ LEADERBOARD_CACHE_TTL=60s # Stack (Container Provisioner) STACKS_ENABLED=true -STACKS_MAX_PER_USER=3 +STACKS_MAX_SCOPE=team +STACKS_MAX_PER=3 STACKS_PROVISIONER_BASE_URL=http://localhost:8081 STACKS_PROVISIONER_API_KEY=change-me STACKS_PROVISIONER_TIMEOUT=5s diff --git a/docs/docs/auth.md b/docs/docs/auth.md index 3804a06..c329ae0 100644 --- a/docs/docs/auth.md +++ b/docs/docs/auth.md @@ -79,6 +79,10 @@ Errors: - 400 `invalid input` - 401 `invalid credentials` +Notes: + +- `stack_count` and `stack_limit` reflect the configured scope. If `STACKS_MAX_SCOPE=team`, these values are team-wide. + --- ## Refresh Token diff --git a/docs/docs/challenges.md b/docs/docs/challenges.md index b628c72..6474be0 100644 --- a/docs/docs/challenges.md +++ b/docs/docs/challenges.md @@ -39,7 +39,7 @@ Notes: - `points` is dynamically calculated based on solves. - `has_file` indicates whether a challenge file is available. -- `stack_enabled` indicates if a per-user stack instance is supported for this challenge. +- `stack_enabled` indicates if a stack instance is supported for this challenge. Scope is controlled by `STACKS_MAX_SCOPE` (user or team). - If a challenge is locked, the response includes only `id`, `title`, `category`, `points`, `initial_points`, `minimum_points`, `solve_count`, `previous_challenge_id`, `previous_challenge_title`, `previous_challenge_category`, `is_active`, and `is_locked`. - If `ctf_state` is `not_started`, the response only includes `ctf_state`. diff --git a/docs/docs/stacks.md b/docs/docs/stacks.md index aa008d6..aa60c00 100644 --- a/docs/docs/stacks.md +++ b/docs/docs/stacks.md @@ -22,6 +22,7 @@ Response 200 { "stack_id": "stack-716b6384dd477b0b", "challenge_id": 12, + "challenge_title": "SQLi 101", "status": "running", "node_public_ip": "12.34.56.78", "ports": [ @@ -34,6 +35,8 @@ Response 200 "ttl_expires_at": "2026-02-10T04:02:26Z", "created_at": "2026-02-10T02:02:26Z", "updated_at": "2026-02-10T02:07:29Z", + "created_by_user_id": 17, + "created_by_username": "alice", "ctf_state": "active" } ] @@ -48,6 +51,7 @@ Errors: Notes: - Blocked users can access this endpoint (read-only). +- If `STACKS_MAX_SCOPE=team`, this list includes stacks created by any member of the same team. --- @@ -67,6 +71,7 @@ Response 201 { "stack_id": "stack-716b6384dd477b0b", "challenge_id": 12, + "challenge_title": "SQLi 101", "status": "creating", "node_public_ip": "12.34.56.78", "ports": [ @@ -79,6 +84,8 @@ Response 201 "ttl_expires_at": "2026-02-10T04:02:26Z", "created_at": "2026-02-10T02:02:26Z", "updated_at": "2026-02-10T02:02:26Z", + "created_by_user_id": 17, + "created_by_username": "alice", "ctf_state": "active" } ``` @@ -96,7 +103,8 @@ Errors: Notes: -- Stack creation is rate-limited per user. Configure via `STACKS_CREATE_WINDOW` and `STACKS_CREATE_MAX`. +- Stack creation is rate-limited by scope. Configure via `STACKS_CREATE_WINDOW` and `STACKS_CREATE_MAX`. +- If `STACKS_MAX_SCOPE=team`, creating a stack counts against the team limit and team rate limit. --- @@ -116,6 +124,7 @@ Response 200 { "stack_id": "stack-716b6384dd477b0b", "challenge_id": 12, + "challenge_title": "SQLi 101", "status": "running", "node_public_ip": "12.34.56.78", "ports": [ @@ -128,6 +137,8 @@ Response 200 "ttl_expires_at": "2026-02-10T04:02:26Z", "created_at": "2026-02-10T02:02:26Z", "updated_at": "2026-02-10T02:07:29Z", + "created_by_user_id": 17, + "created_by_username": "alice", "ctf_state": "active" } ``` @@ -141,6 +152,7 @@ Errors: Notes: - Blocked users can access this endpoint (read-only). +- If `STACKS_MAX_SCOPE=team`, this returns the team stack for the challenge (if any). --- diff --git a/docs/docs/users.md b/docs/docs/users.md index 01ff93e..3633720 100644 --- a/docs/docs/users.md +++ b/docs/docs/users.md @@ -36,6 +36,10 @@ Errors: - 401 `invalid token` or `missing authorization` or `invalid authorization` +Notes: + +- `stack_count` and `stack_limit` reflect the configured scope. If `STACKS_MAX_SCOPE=team`, these values are team-wide. + --- ## Update Me @@ -81,6 +85,10 @@ Errors: - 401 `invalid token` or `missing authorization` or `invalid authorization` - 403 `user blocked` +Notes: + +- `stack_count` and `stack_limit` reflect the configured scope. If `STACKS_MAX_SCOPE=team`, these values are team-wide. + --- ## Solved Challenges diff --git a/internal/config/config.go b/internal/config/config.go index e69a3a6..efb2f7a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -91,7 +91,8 @@ type S3Config struct { type StackConfig struct { Enabled bool - MaxPerUser int + MaxScope string + MaxPer int ProvisionerBaseURL string ProvisionerAPIKey string ProvisionerTimeout time.Duration @@ -227,7 +228,9 @@ func Load() (Config, error) { errs = append(errs, err) } - stackMaxPerUser, err := getEnvInt("STACKS_MAX_PER_USER", 3) + stackMaxScope := strings.ToLower(strings.TrimSpace(getEnv("STACKS_MAX_SCOPE", "team"))) + + stackMaxPer, err := getEnvInt("STACKS_MAX_PER", 3) if err != nil { errs = append(errs, err) } @@ -315,7 +318,8 @@ func Load() (Config, error) { }, Stack: StackConfig{ Enabled: stackEnabled, - MaxPerUser: stackMaxPerUser, + MaxScope: stackMaxScope, + MaxPer: stackMaxPer, ProvisionerBaseURL: getEnv("STACKS_PROVISIONER_BASE_URL", "http://localhost:8081"), ProvisionerAPIKey: getEnv("STACKS_PROVISIONER_API_KEY", ""), ProvisionerTimeout: stackTimeout, @@ -476,8 +480,11 @@ func validateConfig(cfg Config) error { } if cfg.Stack.Enabled { - if cfg.Stack.MaxPerUser <= 0 { - errs = append(errs, errors.New("STACKS_MAX_PER_USER must be positive")) + if cfg.Stack.MaxPer <= 0 { + errs = append(errs, errors.New("STACKS_MAX_PER must be positive")) + } + if cfg.Stack.MaxScope != "user" && cfg.Stack.MaxScope != "team" { + errs = append(errs, errors.New("STACKS_MAX_SCOPE must be user or team")) } if cfg.Stack.ProvisionerBaseURL == "" { errs = append(errs, errors.New("STACKS_PROVISIONER_BASE_URL must not be empty")) @@ -608,7 +615,8 @@ func FormatForLog(cfg Config) map[string]any { }, "stack": map[string]any{ "enabled": cfg.Stack.Enabled, - "max_per_user": cfg.Stack.MaxPerUser, + "max_scope": cfg.Stack.MaxScope, + "max_per": cfg.Stack.MaxPer, "provisioner_base_url": cfg.Stack.ProvisionerBaseURL, "provisioner_api_key": cfg.Stack.ProvisionerAPIKey, "provisioner_timeout": seconds(cfg.Stack.ProvisionerTimeout), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0238206..c5674db 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -98,7 +98,8 @@ func TestLoadConfigCustomValues(t *testing.T) { os.Setenv("S3_FORCE_PATH_STYLE", "true") os.Setenv("S3_PRESIGN_TTL", "20m") os.Setenv("STACKS_ENABLED", "true") - os.Setenv("STACKS_MAX_PER_USER", "5") + os.Setenv("STACKS_MAX_SCOPE", "team") + os.Setenv("STACKS_MAX_PER", "5") os.Setenv("STACKS_PROVISIONER_BASE_URL", "http://localhost:18081") os.Setenv("STACKS_PROVISIONER_API_KEY", "custom-key") os.Setenv("STACKS_PROVISIONER_TIMEOUT", "9s") @@ -191,6 +192,12 @@ func TestLoadConfigCustomValues(t *testing.T) { if cfg.Stack.CreateMax != 2 { t.Errorf("expected Stack.CreateMax 2, got %d", cfg.Stack.CreateMax) } + if cfg.Stack.MaxScope != "team" { + t.Errorf("expected Stack.MaxScope team, got %s", cfg.Stack.MaxScope) + } + if cfg.Stack.MaxPer != 5 { + t.Errorf("expected Stack.MaxPer 5, got %d", cfg.Stack.MaxPer) + } } func TestLoadConfigInvalidValues(t *testing.T) { @@ -210,6 +217,7 @@ func TestLoadConfigInvalidValues(t *testing.T) { {"invalid s3 enabled", "S3_ENABLED", "not-a-bool"}, {"invalid s3 presign ttl", "S3_PRESIGN_TTL", "bad-duration"}, {"invalid s3 force path", "S3_FORCE_PATH_STYLE", "bad-bool"}, + {"invalid stack max scope", "STACKS_MAX_SCOPE", "org"}, {"invalid leaderboard cache ttl", "LEADERBOARD_CACHE_TTL", "bad-duration"}, {"invalid app config cache ttl", "APP_CONFIG_CACHE_TTL", "bad-duration"}, } @@ -623,7 +631,8 @@ func TestValidateConfigInvalidStackConfig(t *testing.T) { }, Stack: StackConfig{ Enabled: true, - MaxPerUser: 0, + MaxScope: "user", + MaxPer: 0, ProvisionerBaseURL: "", ProvisionerAPIKey: "", ProvisionerTimeout: 0, @@ -637,7 +646,7 @@ func TestValidateConfigInvalidStackConfig(t *testing.T) { t.Fatal("expected stack validation error") } - if !strings.Contains(err.Error(), "STACKS_MAX_PER_USER") { + if !strings.Contains(err.Error(), "STACKS_MAX_PER") { t.Fatalf("expected stack error, got %v", err) } } @@ -823,7 +832,8 @@ func TestFormatForLog(t *testing.T) { }, Stack: StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxScope: "user", + MaxPer: 3, ProvisionerBaseURL: "http://localhost:8081", ProvisionerAPIKey: "stack-key", ProvisionerTimeout: 5 * time.Second, diff --git a/internal/http/handlers/handler_test.go b/internal/http/handlers/handler_test.go index 7d3a442..20f7972 100644 --- a/internal/http/handlers/handler_test.go +++ b/internal/http/handlers/handler_test.go @@ -408,8 +408,8 @@ func TestHandlerRegisterLoginRefreshLogout(t *testing.T) { if loginResp.User.StackCount != 0 { t.Fatalf("expected stack_count 0, got %d", loginResp.User.StackCount) } - if loginResp.User.StackLimit != env.cfg.Stack.MaxPerUser { - t.Fatalf("expected stack_limit %d, got %d", env.cfg.Stack.MaxPerUser, loginResp.User.StackLimit) + if loginResp.User.StackLimit != env.cfg.Stack.MaxPer { + t.Fatalf("expected stack_limit %d, got %d", env.cfg.Stack.MaxPer, loginResp.User.StackLimit) } if loginResp.User.BlockedReason != nil || loginResp.User.BlockedAt != nil { t.Fatalf("expected blocked fields to be null") @@ -1213,7 +1213,22 @@ func setupHandlerStackService(t *testing.T, env handlerEnv, client stack.API) (* stackRepo := repo.NewStackRepo(env.db) stackCfg := config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, + CreateWindow: time.Minute, + CreateMax: 5, + } + + stackSvc := service.NewStackService(stackCfg, stackRepo, env.challengeRepo, env.submissionRepo, client, env.redis) + return stackSvc, stackRepo +} + +func setupHandlerStackServiceWithScope(t *testing.T, env handlerEnv, client stack.API, scope string) (*service.StackService, *repo.StackRepo) { + t.Helper() + stackRepo := repo.NewStackRepo(env.db) + stackCfg := config.StackConfig{ + Enabled: true, + MaxScope: scope, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5, } @@ -1263,6 +1278,10 @@ func TestStackHandlersCRUD(t *testing.T) { t.Fatalf("unexpected response: %+v", created) } + if created.CreatedByUsername == "" || created.ChallengeTitle == "" { + t.Fatalf("expected created_by and challenge_title, got %+v", created) + } + ctx, rec = newJSONContext(t, http.MethodGet, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/stack", nil) ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} ctx.Set("userID", user.ID) @@ -1332,6 +1351,107 @@ func TestStackHandlersList(t *testing.T) { } } +func TestStackHandlersListTeamScope(t *testing.T) { + env := setupHandlerTest(t) + team := createHandlerTeam(t, env, "TeamList") + user := createHandlerUserWithTeam(t, env, "t1@example.com", "t1", "pass", models.UserRole, team.ID) + user2 := createHandlerUserWithTeam(t, env, "t2@example.com", "t2", "pass", models.UserRole, team.ID) + challenge1 := createHandlerStackChallenge(t, env, "team-stack-1") + challenge2 := createHandlerStackChallenge(t, env, "team-stack-2") + + mock := &stack.MockClient{ + GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { + return &stack.StackStatus{StackID: stackID, Status: "running", Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}, nil + }, + } + + stackSvc, stackRepo := setupHandlerStackServiceWithScope(t, env, mock, "team") + env.handler.stacks = stackSvc + + now := time.Now().UTC() + stack1 := &models.Stack{UserID: user.ID, ChallengeID: challenge1.ID, StackID: "team-stack-1", Status: "running", Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: now, UpdatedAt: now} + stack2 := &models.Stack{UserID: user2.ID, ChallengeID: challenge2.ID, StackID: "team-stack-2", Status: "running", Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31002}}, CreatedAt: now, UpdatedAt: now} + if err := stackRepo.Create(context.Background(), stack1); err != nil { + t.Fatalf("create stack1: %v", err) + } + + if err := stackRepo.Create(context.Background(), stack2); err != nil { + t.Fatalf("create stack2: %v", err) + } + + ctx, rec := newJSONContext(t, http.MethodGet, "/api/stacks", nil) + ctx.Set("userID", user.ID) + env.handler.ListStacks(ctx) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var resp stacksListResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if len(resp.Stacks) != 2 { + t.Fatalf("expected 2 team stacks, got %d", len(resp.Stacks)) + } + + if resp.Stacks[0].CreatedByUserID == 0 || resp.Stacks[0].CreatedByUsername == "" { + t.Fatalf("expected created_by fields set, got %+v", resp.Stacks[0]) + } + + if resp.Stacks[0].ChallengeTitle == "" { + t.Fatalf("expected challenge_title set, got %+v", resp.Stacks[0]) + } +} + +func TestStackHandlersGetTeamScope(t *testing.T) { + env := setupHandlerTest(t) + team := createHandlerTeam(t, env, "TeamGet") + user := createHandlerUserWithTeam(t, env, "g1@example.com", "g1", "pass", models.UserRole, team.ID) + user2 := createHandlerUserWithTeam(t, env, "g2@example.com", "g2", "pass", models.UserRole, team.ID) + challenge := createHandlerStackChallenge(t, env, "team-get") + + mock := &stack.MockClient{ + GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { + return &stack.StackStatus{StackID: stackID, Status: "running", Ports: []stack.PortMapping{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}}, nil + }, + } + + stackSvc, stackRepo := setupHandlerStackServiceWithScope(t, env, mock, "team") + env.handler.stacks = stackSvc + + now := time.Now().UTC() + stackModel := &models.Stack{UserID: user2.ID, ChallengeID: challenge.ID, StackID: "team-get", Status: "running", Ports: stack.PortMappings{{ContainerPort: 80, Protocol: "TCP", NodePort: 31001}}, CreatedAt: now, UpdatedAt: now} + if err := stackRepo.Create(context.Background(), stackModel); err != nil { + t.Fatalf("create stack: %v", err) + } + + ctx, rec := newJSONContext(t, http.MethodGet, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/stack", nil) + ctx.Params = gin.Params{{Key: "id", Value: fmt.Sprint(challenge.ID)}} + ctx.Set("userID", user.ID) + env.handler.GetStack(ctx) + if rec.Code != http.StatusOK { + t.Fatalf("get stack status %d: %s", rec.Code, rec.Body.String()) + } + + var resp stackResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.StackID != "team-get" { + t.Fatalf("expected team stack, got %+v", resp) + } + + if resp.CreatedByUserID != user2.ID || resp.CreatedByUsername == "" { + t.Fatalf("expected created_by fields, got %+v", resp) + } + + if resp.ChallengeTitle != challenge.Title { + t.Fatalf("expected challenge_title %q, got %q", challenge.Title, resp.ChallengeTitle) + } +} + func TestAdminStackHandlersList(t *testing.T) { env := setupHandlerTest(t) team := createHandlerTeam(t, env, "Alpha") @@ -1551,7 +1671,7 @@ func TestAdminReport(t *testing.T) { } mock := &stack.MockClient{} - stackSvc := service.NewStackService(config.StackConfig{Enabled: true, MaxPerUser: 3, CreateWindow: time.Minute, CreateMax: 5}, stackRepo, env.challengeRepo, env.submissionRepo, mock, env.redis) + stackSvc := service.NewStackService(config.StackConfig{Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5}, stackRepo, env.challengeRepo, env.submissionRepo, mock, env.redis) env.handler.stacks = stackSvc createHandlerSubmission(t, env, user.ID, challenge.ID, true, time.Now().UTC()) @@ -1748,7 +1868,7 @@ func TestSubmitFlagDeletesStack(t *testing.T) { return nil }, } - stackSvc := service.NewStackService(config.StackConfig{Enabled: true, MaxPerUser: 3, CreateWindow: time.Minute, CreateMax: 5}, stackRepo, env.challengeRepo, env.submissionRepo, mock, env.redis) + stackSvc := service.NewStackService(config.StackConfig{Enabled: true, MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5}, stackRepo, env.challengeRepo, env.submissionRepo, mock, env.redis) env.handler.stacks = stackSvc ctx, rec := newJSONContext(t, http.MethodPost, "/api/challenges/"+fmt.Sprint(challenge.ID)+"/submit", submitRequest{Flag: "flag"}) @@ -2247,8 +2367,8 @@ func TestHandlerMeUpdateUsers(t *testing.T) { t.Fatalf("expected me stack_count 0, got %d", meResp.StackCount) } - if meResp.StackLimit != env.cfg.Stack.MaxPerUser { - t.Fatalf("expected me stack_limit %d, got %d", env.cfg.Stack.MaxPerUser, meResp.StackLimit) + if meResp.StackLimit != env.cfg.Stack.MaxPer { + t.Fatalf("expected me stack_limit %d, got %d", env.cfg.Stack.MaxPer, meResp.StackLimit) } ctx, rec = newJSONContext(t, http.MethodPut, "/api/me", map[string]string{"username": "user2"}) @@ -2278,8 +2398,8 @@ func TestHandlerMeUpdateUsers(t *testing.T) { t.Fatalf("expected update stack_count 0, got %d", updateResp.StackCount) } - if updateResp.StackLimit != env.cfg.Stack.MaxPerUser { - t.Fatalf("expected update stack_limit %d, got %d", env.cfg.Stack.MaxPerUser, updateResp.StackLimit) + if updateResp.StackLimit != env.cfg.Stack.MaxPer { + t.Fatalf("expected update stack_limit %d, got %d", env.cfg.Stack.MaxPer, updateResp.StackLimit) } waitForCacheClear(t, env, diff --git a/internal/http/handlers/testenv_test.go b/internal/http/handlers/testenv_test.go index a9e81ca..8532dae 100644 --- a/internal/http/handlers/testenv_test.go +++ b/internal/http/handlers/testenv_test.go @@ -119,7 +119,7 @@ func TestMain(m *testing.M) { }, Stack: config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, }, diff --git a/internal/http/handlers/types.go b/internal/http/handlers/types.go index e8d6517..9c3005e 100644 --- a/internal/http/handlers/types.go +++ b/internal/http/handlers/types.go @@ -348,15 +348,18 @@ type adminReportResponse struct { } type stackResponse struct { - StackID string `json:"stack_id"` - ChallengeID int64 `json:"challenge_id"` - Status string `json:"status"` - NodePublicIP *string `json:"node_public_ip,omitempty"` - Ports []stackpkg.PortMapping `json:"ports,omitempty"` - TTLExpiresAt *time.Time `json:"ttl_expires_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CTFState string `json:"-"` + StackID string `json:"stack_id"` + ChallengeID int64 `json:"challenge_id"` + Status string `json:"status"` + NodePublicIP *string `json:"node_public_ip,omitempty"` + Ports []stackpkg.PortMapping `json:"ports,omitempty"` + TTLExpiresAt *time.Time `json:"ttl_expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedByUserID int64 `json:"created_by_user_id"` + CreatedByUsername string `json:"created_by_username"` + ChallengeTitle string `json:"challenge_title"` + CTFState string `json:"-"` } type stacksListResponse struct { @@ -385,15 +388,18 @@ type adminStacksListResponse struct { func newStackResponse(stack *models.Stack, ctfState string) stackResponse { return stackResponse{ - StackID: stack.StackID, - ChallengeID: stack.ChallengeID, - Status: stack.Status, - NodePublicIP: stack.NodePublicIP, - Ports: []stackpkg.PortMapping(stack.Ports), - TTLExpiresAt: stack.TTLExpiresAt, - CreatedAt: stack.CreatedAt.UTC(), - UpdatedAt: stack.UpdatedAt.UTC(), - CTFState: ctfState, + StackID: stack.StackID, + ChallengeID: stack.ChallengeID, + Status: stack.Status, + NodePublicIP: stack.NodePublicIP, + Ports: []stackpkg.PortMapping(stack.Ports), + TTLExpiresAt: stack.TTLExpiresAt, + CreatedAt: stack.CreatedAt.UTC(), + UpdatedAt: stack.UpdatedAt.UTC(), + CreatedByUserID: stack.UserID, + CreatedByUsername: stack.Username, + ChallengeTitle: stack.ChallengeTitle, + CTFState: ctfState, } } diff --git a/internal/http/integration/admin_test.go b/internal/http/integration/admin_test.go index 41a38f7..bfdb5d8 100644 --- a/internal/http/integration/admin_test.go +++ b/internal/http/integration/admin_test.go @@ -552,7 +552,7 @@ func TestAdminStackManagement(t *testing.T) { cfg := testCfg cfg.Stack = config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } @@ -664,7 +664,7 @@ func TestAdminReportSuccess(t *testing.T) { cfg := testCfg cfg.Stack = config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } diff --git a/internal/http/integration/stacks_test.go b/internal/http/integration/stacks_test.go index 9302b21..853cde0 100644 --- a/internal/http/integration/stacks_test.go +++ b/internal/http/integration/stacks_test.go @@ -113,7 +113,7 @@ func TestStackLifecycle(t *testing.T) { cfg := testCfg cfg.Stack = config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } @@ -145,7 +145,7 @@ func TestStackCreateBlockedAfterSolve(t *testing.T) { cfg := testCfg cfg.Stack = config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } @@ -172,7 +172,7 @@ func TestStackCreateRateLimit(t *testing.T) { cfg := testCfg cfg.Stack = config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } @@ -200,7 +200,7 @@ func TestStackCreateLocked(t *testing.T) { cfg := testCfg cfg.Stack = config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } @@ -229,11 +229,109 @@ func TestStackCreateLocked(t *testing.T) { } } +func TestStackListTeamScope(t *testing.T) { + cfg := testCfg + cfg.Stack = config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 3, + CreateWindow: time.Minute, + CreateMax: 1, + } + + mock := stack.NewProvisionerMock() + env := setupStackTest(t, cfg, mock.Client()) + + admin := ensureAdminUser(t, env) + team := createTeam(t, env, "team-"+nextRegistrationCode()) + keyA := createRegistrationKeyWithTeam(t, env, admin.ID, team.ID) + keyB := createRegistrationKeyWithTeam(t, env, admin.ID, team.ID) + + userA := func() string { + regBody := map[string]string{ + "email": "team-a@example.com", + "username": "team-a", + "password": "strong-pass", + "registration_key": keyA.Code, + } + rec := doRequest(t, env.router, http.MethodPost, "/api/auth/register", regBody, nil) + if rec.Code != http.StatusCreated { + t.Fatalf("register team-a status %d: %s", rec.Code, rec.Body.String()) + } + + loginBody := map[string]string{"email": "team-a@example.com", "password": "strong-pass"} + rec = doRequest(t, env.router, http.MethodPost, "/api/auth/login", loginBody, nil) + if rec.Code != http.StatusOK { + t.Fatalf("login team-a status %d: %s", rec.Code, rec.Body.String()) + } + + var loginResp struct { + AccessToken string `json:"access_token"` + } + decodeJSON(t, rec, &loginResp) + + return loginResp.AccessToken + }() + + userB := func() string { + regBody := map[string]string{ + "email": "team-b@example.com", + "username": "team-b", + "password": "strong-pass", + "registration_key": keyB.Code, + } + rec := doRequest(t, env.router, http.MethodPost, "/api/auth/register", regBody, nil) + if rec.Code != http.StatusCreated { + t.Fatalf("register team-b status %d: %s", rec.Code, rec.Body.String()) + } + + loginBody := map[string]string{"email": "team-b@example.com", "password": "strong-pass"} + rec = doRequest(t, env.router, http.MethodPost, "/api/auth/login", loginBody, nil) + if rec.Code != http.StatusOK { + t.Fatalf("login team-b status %d: %s", rec.Code, rec.Body.String()) + } + + var loginResp struct { + AccessToken string `json:"access_token"` + } + decodeJSON(t, rec, &loginResp) + + return loginResp.AccessToken + }() + + challenge := createStackChallenge(t, env, "TeamScopeStack") + + rec := doRequest(t, env.router, http.MethodPost, "/api/challenges/"+itoa(challenge.ID)+"/stack", nil, authHeader(userA)) + if rec.Code != http.StatusCreated { + t.Fatalf("create stack status %d: %s", rec.Code, rec.Body.String()) + } + + rec = doRequest(t, env.router, http.MethodGet, "/api/stacks", nil, authHeader(userB)) + if rec.Code != http.StatusOK { + t.Fatalf("list stacks status %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + Stacks []struct { + CreatedByUsername string `json:"created_by_username"` + ChallengeTitle string `json:"challenge_title"` + } `json:"stacks"` + } + decodeJSON(t, rec, &resp) + if len(resp.Stacks) != 1 { + t.Fatalf("expected 1 team stack, got %d", len(resp.Stacks)) + } + + if resp.Stacks[0].CreatedByUsername == "" || resp.Stacks[0].ChallengeTitle == "" { + t.Fatalf("expected created_by and challenge_title, got %+v", resp.Stacks[0]) + } +} + func TestStacksBlockedBeforeStart(t *testing.T) { cfg := testCfg cfg.Stack = config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } @@ -297,7 +395,7 @@ func TestStacksCreateBlockedAfterEnd(t *testing.T) { cfg := testCfg cfg.Stack = config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, } diff --git a/internal/http/integration/testenv_test.go b/internal/http/integration/testenv_test.go index af26fce..83ab1f8 100644 --- a/internal/http/integration/testenv_test.go +++ b/internal/http/integration/testenv_test.go @@ -166,7 +166,7 @@ func TestMain(m *testing.M) { }, Stack: config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 1, }, @@ -466,8 +466,8 @@ func registerAndLogin(t *testing.T, env testEnv, email, username, password strin t.Fatalf("expected stack_count 0, got %d", loginResp.User.StackCount) } - if loginResp.User.StackLimit != env.cfg.Stack.MaxPerUser { - t.Fatalf("expected stack_limit %d, got %d", env.cfg.Stack.MaxPerUser, loginResp.User.StackLimit) + if loginResp.User.StackLimit != env.cfg.Stack.MaxPer { + t.Fatalf("expected stack_limit %d, got %d", env.cfg.Stack.MaxPer, loginResp.User.StackLimit) } return loginResp.AccessToken, loginResp.RefreshToken, loginResp.User.ID diff --git a/internal/models/stack.go b/internal/models/stack.go index e56016c..bfc86f2 100644 --- a/internal/models/stack.go +++ b/internal/models/stack.go @@ -9,17 +9,19 @@ import ( ) type Stack struct { - bun.BaseModel `bun:"table:stacks"` - ID int64 `bun:"id,pk,autoincrement"` - UserID int64 `bun:"user_id,notnull"` - ChallengeID int64 `bun:"challenge_id,notnull"` - StackID string `bun:"stack_id,notnull"` - Status string `bun:"status,notnull"` - NodePublicIP *string `bun:"node_public_ip,nullzero"` - Ports stack.PortMappings `bun:"ports,type:jsonb,nullzero"` - TTLExpiresAt *time.Time `bun:"ttl_expires_at,nullzero"` - CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` - UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` + bun.BaseModel `bun:"table:stacks"` + ID int64 `bun:"id,pk,autoincrement"` + UserID int64 `bun:"user_id,notnull"` + Username string `bun:"username,scanonly"` + ChallengeID int64 `bun:"challenge_id,notnull"` + ChallengeTitle string `bun:"challenge_title,scanonly"` + StackID string `bun:"stack_id,notnull"` + Status string `bun:"status,notnull"` + NodePublicIP *string `bun:"node_public_ip,nullzero"` + Ports stack.PortMappings `bun:"ports,type:jsonb,nullzero"` + TTLExpiresAt *time.Time `bun:"ttl_expires_at,nullzero"` + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` } type AdminStackSummary struct { diff --git a/internal/repo/stack_repo.go b/internal/repo/stack_repo.go index e7012c9..9876a78 100644 --- a/internal/repo/stack_repo.go +++ b/internal/repo/stack_repo.go @@ -20,8 +20,13 @@ func (r *StackRepo) ListByUser(ctx context.Context, userID int64) ([]models.Stac stacks := make([]models.Stack, 0) if err := r.db.NewSelect(). Model(&stacks). - Where("user_id = ?", userID). - Order("created_at DESC"). + ColumnExpr("stack.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("LEFT JOIN users AS u ON u.id = stack.user_id"). + Join("LEFT JOIN challenges AS c ON c.id = stack.challenge_id"). + Where("stack.user_id = ?", userID). + Order("stack.created_at DESC"). Scan(ctx); err != nil { return nil, wrapError("stackRepo.ListByUser", err) } @@ -29,6 +34,24 @@ func (r *StackRepo) ListByUser(ctx context.Context, userID int64) ([]models.Stac return stacks, nil } +func (r *StackRepo) ListByTeam(ctx context.Context, teamID int64) ([]models.Stack, error) { + stacks := make([]models.Stack, 0) + if err := r.db.NewSelect(). + Model(&stacks). + ColumnExpr("stack.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("JOIN users AS u ON u.id = stack.user_id"). + Join("JOIN challenges AS c ON c.id = stack.challenge_id"). + Where("u.team_id = ?", teamID). + Order("stack.created_at DESC"). + Scan(ctx); err != nil { + return nil, wrapError("stackRepo.ListByTeam", err) + } + + return stacks, nil +} + func (r *StackRepo) ListAll(ctx context.Context) ([]models.Stack, error) { stacks := make([]models.Stack, 0) if err := r.db.NewSelect(). @@ -96,12 +119,47 @@ func (r *StackRepo) CountByUserExcludingStatuses(ctx context.Context, userID int return count, nil } +func (r *StackRepo) CountByTeamExcludingStatuses(ctx context.Context, teamID int64, statuses []string) (int, error) { + query := r.db.NewSelect(). + Model((*models.Stack)(nil)). + Join("JOIN users AS u ON u.id = stack.user_id"). + Where("u.team_id = ?", teamID) + if len(statuses) > 0 { + query = query.Where("stack.status NOT IN (?)", bun.In(statuses)) + } + + count, err := query.Count(ctx) + if err != nil { + return 0, wrapError("stackRepo.CountByTeamExcludingStatuses", err) + } + + return count, nil +} + +func (r *StackRepo) TeamIDForUser(ctx context.Context, userID int64) (int64, error) { + var teamID int64 + if err := r.db.NewSelect(). + TableExpr("users"). + Column("team_id"). + Where("id = ?", userID). + Scan(ctx, &teamID); err != nil { + return 0, wrapNotFound("stackRepo.TeamIDForUser", err) + } + + return teamID, nil +} + func (r *StackRepo) GetByUserAndChallenge(ctx context.Context, userID, challengeID int64) (*models.Stack, error) { stack := new(models.Stack) if err := r.db.NewSelect(). Model(stack). - Where("user_id = ?", userID). - Where("challenge_id = ?", challengeID). + ColumnExpr("stack.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("LEFT JOIN users AS u ON u.id = stack.user_id"). + Join("LEFT JOIN challenges AS c ON c.id = stack.challenge_id"). + Where("stack.user_id = ?", userID). + Where("stack.challenge_id = ?", challengeID). Scan(ctx); err != nil { return nil, wrapNotFound("stackRepo.GetByUserAndChallenge", err) } @@ -109,11 +167,34 @@ func (r *StackRepo) GetByUserAndChallenge(ctx context.Context, userID, challenge return stack, nil } +func (r *StackRepo) GetByTeamAndChallenge(ctx context.Context, teamID, challengeID int64) (*models.Stack, error) { + stack := new(models.Stack) + if err := r.db.NewSelect(). + Model(stack). + ColumnExpr("stack.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("JOIN users AS u ON u.id = stack.user_id"). + Join("JOIN challenges AS c ON c.id = stack.challenge_id"). + Where("u.team_id = ?", teamID). + Where("stack.challenge_id = ?", challengeID). + Scan(ctx); err != nil { + return nil, wrapNotFound("stackRepo.GetByTeamAndChallenge", err) + } + + return stack, nil +} + func (r *StackRepo) GetByStackID(ctx context.Context, stackID string) (*models.Stack, error) { stack := new(models.Stack) if err := r.db.NewSelect(). Model(stack). - Where("stack_id = ?", stackID). + ColumnExpr("stack.*"). + ColumnExpr("u.username AS username"). + ColumnExpr("c.title AS challenge_title"). + Join("LEFT JOIN users AS u ON u.id = stack.user_id"). + Join("LEFT JOIN challenges AS c ON c.id = stack.challenge_id"). + Where("stack.stack_id = ?", stackID). Scan(ctx); err != nil { return nil, wrapNotFound("stackRepo.GetByStackID", err) } diff --git a/internal/repo/stack_repo_test.go b/internal/repo/stack_repo_test.go index 7ef38b1..56f9cf6 100644 --- a/internal/repo/stack_repo_test.go +++ b/internal/repo/stack_repo_test.go @@ -46,6 +46,14 @@ func TestStackRepoCRUD(t *testing.T) { t.Fatalf("expected stack %s, got %s", stack.StackID, got.StackID) } + if got.Username != user.Username { + t.Fatalf("expected username %s, got %s", user.Username, got.Username) + } + + if got.ChallengeTitle != challenge.Title { + t.Fatalf("expected challenge title %s, got %s", challenge.Title, got.ChallengeTitle) + } + got, err = env.stackRepo.GetByStackID(context.Background(), stack.StackID) if err != nil { t.Fatalf("GetByStackID: %v", err) @@ -55,6 +63,14 @@ func TestStackRepoCRUD(t *testing.T) { t.Fatalf("expected stack id %d, got %d", stack.ID, got.ID) } + if got.Username != user.Username { + t.Fatalf("expected username %s, got %s", user.Username, got.Username) + } + + if got.ChallengeTitle != challenge.Title { + t.Fatalf("expected challenge title %s, got %s", challenge.Title, got.ChallengeTitle) + } + count, err := env.stackRepo.CountByUser(context.Background(), user.ID) if err != nil { t.Fatalf("CountByUser: %v", err) @@ -76,6 +92,14 @@ func TestStackRepoCRUD(t *testing.T) { t.Fatalf("expected stack %s, got %s", stack.StackID, stacks[0].StackID) } + if stacks[0].Username != user.Username { + t.Fatalf("expected username %s, got %s", user.Username, stacks[0].Username) + } + + if stacks[0].ChallengeTitle != challenge.Title { + t.Fatalf("expected challenge title %s, got %s", challenge.Title, stacks[0].ChallengeTitle) + } + if err := env.stackRepo.DeleteByUserAndChallenge(context.Background(), user.ID, challenge.ID); err != nil { t.Fatalf("DeleteByUserAndChallenge: %v", err) } @@ -177,6 +201,113 @@ func TestStackRepoCountByUserExcludingStatuses(t *testing.T) { } } +func TestStackRepoListByTeam(t *testing.T) { + env := setupRepoTest(t) + + team := createTeam(t, env, "ListTeam") + userA := createUserWithTeam(t, env, "teamA@example.com", "teamA", "pass", models.UserRole, team.ID) + userB := createUserWithTeam(t, env, "teamB@example.com", "teamB", "pass", models.UserRole, team.ID) + otherTeam := createTeam(t, env, "OtherTeam") + otherUser := createUserWithTeam(t, env, "other@example.com", "other", "pass", models.UserRole, otherTeam.ID) + + challengeA := createChallenge(t, env, "TeamCh1", 100, "flag{ta}", true) + challengeB := createChallenge(t, env, "TeamCh2", 100, "flag{tb}", true) + otherChallenge := createChallenge(t, env, "OtherCh", 100, "flag{tc}", true) + + createStack(t, env, userA.ID, challengeA.ID, "stack-team-1", time.Now().UTC().Add(-time.Minute)) + createStack(t, env, userB.ID, challengeB.ID, "stack-team-2", time.Now().UTC()) + createStack(t, env, otherUser.ID, otherChallenge.ID, "stack-other", time.Now().UTC()) + + stacks, err := env.stackRepo.ListByTeam(context.Background(), team.ID) + if err != nil { + t.Fatalf("ListByTeam: %v", err) + } + + if len(stacks) != 2 { + t.Fatalf("expected 2 stacks, got %d", len(stacks)) + } + + if stacks[0].StackID != "stack-team-2" { + t.Fatalf("expected newest team stack first, got %s", stacks[0].StackID) + } + + if stacks[0].Username == "" || stacks[0].ChallengeTitle == "" { + t.Fatalf("expected username and challenge title set, got %+v", stacks[0]) + } +} + +func TestStackRepoGetByTeamAndChallenge(t *testing.T) { + env := setupRepoTest(t) + + team := createTeam(t, env, "TeamGet") + user := createUserWithTeam(t, env, "teamget@example.com", "teamget", "pass", models.UserRole, team.ID) + challenge := createChallenge(t, env, "TeamGetCh", 100, "flag{tg}", true) + + createStack(t, env, user.ID, challenge.ID, "stack-team-get", time.Now().UTC()) + + got, err := env.stackRepo.GetByTeamAndChallenge(context.Background(), team.ID, challenge.ID) + if err != nil { + t.Fatalf("GetByTeamAndChallenge: %v", err) + } + + if got.StackID != "stack-team-get" { + t.Fatalf("expected stack-team-get, got %s", got.StackID) + } + + if got.Username != user.Username || got.ChallengeTitle != challenge.Title { + t.Fatalf("expected username %s and title %s, got %+v", user.Username, challenge.Title, got) + } +} + +func TestStackRepoCountByTeamExcludingStatuses(t *testing.T) { + env := setupRepoTest(t) + + team := createTeam(t, env, "CountTeam") + user := createUserWithTeam(t, env, "countteam@example.com", "countteam", "pass", models.UserRole, team.ID) + challenge := createChallenge(t, env, "CountTeamCh", 100, "flag{ct}", true) + terminalChallenge := createChallenge(t, env, "CountTeamChTerm", 100, "flag{ct2}", true) + + now := time.Now().UTC() + createStack(t, env, user.ID, challenge.ID, "stack-team-running", now) + + terminal := &models.Stack{ + UserID: user.ID, + ChallengeID: terminalChallenge.ID, + StackID: "stack-team-stopped", + Status: "stopped", + CreatedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + } + if err := env.stackRepo.Create(context.Background(), terminal); err != nil { + t.Fatalf("create terminal stack: %v", err) + } + + count, err := env.stackRepo.CountByTeamExcludingStatuses(context.Background(), team.ID, []string{"stopped"}) + if err != nil { + t.Fatalf("CountByTeamExcludingStatuses: %v", err) + } + + if count != 1 { + t.Fatalf("expected count 1, got %d", count) + } +} + +func TestStackRepoTeamIDForUser(t *testing.T) { + env := setupRepoTest(t) + + team := createTeam(t, env, "TeamLookup") + user := createUserWithTeam(t, env, "lookup@example.com", "lookup", "pass", models.UserRole, team.ID) + + teamID, err := env.stackRepo.TeamIDForUser(context.Background(), user.ID) + if err != nil { + t.Fatalf("TeamIDForUser: %v", err) + } + + if teamID != team.ID { + t.Fatalf("expected team id %d, got %d", team.ID, teamID) + } +} + func TestStackRepoListAdmin(t *testing.T) { env := setupRepoTest(t) diff --git a/internal/service/stack_service.go b/internal/service/stack_service.go index 207f91d..b94951f 100644 --- a/internal/service/stack_service.go +++ b/internal/service/stack_service.go @@ -51,12 +51,24 @@ func (s *StackService) UserStackSummary(ctx context.Context, userID int64) (int, return 0, 0, nil } - limit := s.cfg.MaxPerUser + limit := s.cfg.MaxPer if userID <= 0 { return 0, limit, nil } - count, err := s.stackRepo.CountByUserExcludingStatuses(ctx, userID, terminalStackStatusList) + var count int + var err error + if s.maxScopeIsTeam() { + teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) + if lookupErr != nil { + return 0, limit, lookupErr + } + + count, err = s.stackRepo.CountByTeamExcludingStatuses(ctx, teamID, terminalStackStatusList) + } else { + count, err = s.stackRepo.CountByUserExcludingStatuses(ctx, userID, terminalStackStatusList) + } + if err != nil { return 0, limit, err } @@ -69,7 +81,18 @@ func (s *StackService) ListUserStacks(ctx context.Context, userID int64) ([]mode return nil, err } - stacks, err := s.stackRepo.ListByUser(ctx, userID) + var stacks []models.Stack + var err error + if s.maxScopeIsTeam() { + teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) + if lookupErr != nil { + return nil, lookupErr + } + stacks, err = s.stackRepo.ListByTeam(ctx, teamID) + } else { + stacks, err = s.stackRepo.ListByUser(ctx, userID) + } + if err != nil { return nil, err } @@ -185,6 +208,19 @@ func (s *StackService) GetOrCreateStack(ctx context.Context, userID, challengeID return nil, err } + if s.maxScopeIsTeam() { + teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) + if lookupErr == nil { + if reloaded, reloadErr := s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID); reloadErr == nil { + return reloaded, nil + } + } + } else { + if reloaded, reloadErr := s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID); reloadErr == nil { + return reloaded, nil + } + } + return stackModel, nil } @@ -193,7 +229,18 @@ func (s *StackService) GetStack(ctx context.Context, userID, challengeID int64) return nil, err } - existing, err := s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) + var existing *models.Stack + var err error + if s.maxScopeIsTeam() { + teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) + if lookupErr != nil { + return nil, lookupErr + } + existing, err = s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID) + } else { + existing, err = s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) + } + if err != nil { if errors.Is(err, repo.ErrNotFound) { return nil, ErrStackNotFound @@ -210,7 +257,17 @@ func (s *StackService) DeleteStack(ctx context.Context, userID, challengeID int6 return err } - existing, err := s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) + var existing *models.Stack + var err error + if s.maxScopeIsTeam() { + teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) + if lookupErr != nil { + return lookupErr + } + existing, err = s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID) + } else { + existing, err = s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) + } if err != nil { if errors.Is(err, repo.ErrNotFound) { return ErrStackNotFound @@ -235,7 +292,18 @@ func (s *StackService) DeleteStackByUserAndChallenge(ctx context.Context, userID return err } - existing, err := s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) + var existing *models.Stack + var err error + if s.maxScopeIsTeam() { + teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) + if lookupErr != nil { + return lookupErr + } + existing, err = s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID) + } else { + existing, err = s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) + } + if err != nil { if errors.Is(err, repo.ErrNotFound) { return ErrStackNotFound @@ -334,7 +402,18 @@ func (s *StackService) ensureUnlocked(ctx context.Context, userID int64, challen } func (s *StackService) findExistingStack(ctx context.Context, userID, challengeID int64) (*models.Stack, error) { - existing, err := s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) + var existing *models.Stack + var err error + if s.maxScopeIsTeam() { + teamID, lookupErr := s.stackRepo.TeamIDForUser(ctx, userID) + if lookupErr != nil { + return nil, lookupErr + } + existing, err = s.stackRepo.GetByTeamAndChallenge(ctx, teamID, challengeID) + } else { + existing, err = s.stackRepo.GetByUserAndChallenge(ctx, userID, challengeID) + } + if err == nil { refreshed, refreshErr := s.refreshStack(ctx, existing) if refreshErr == nil { @@ -361,16 +440,37 @@ func (s *StackService) applyRateLimit(ctx context.Context, userID int64) error { } key := stackRateLimitKey(userID) + if s.maxScopeIsTeam() { + teamID, err := s.stackRepo.TeamIDForUser(ctx, userID) + if err != nil { + return fmt.Errorf("stack.GetOrCreateStack team: %w", err) + } + key = stackTeamRateLimitKey(teamID) + } + return rateLimit(ctx, s.redis, key, s.cfg.CreateWindow, s.cfg.CreateMax) } func (s *StackService) ensureUserLimit(ctx context.Context, userID int64) error { + if s.maxScopeIsTeam() { + activeStacks, err := s.ListUserStacks(ctx, userID) + if err != nil { + return fmt.Errorf("stack.GetOrCreateStack list: %w", err) + } + + if len(activeStacks) >= s.cfg.MaxPer { + return ErrStackLimitReached + } + + return nil + } + activeStacks, err := s.ListUserStacks(ctx, userID) if err != nil { return fmt.Errorf("stack.GetOrCreateStack list: %w", err) } - if len(activeStacks) >= s.cfg.MaxPerUser { + if len(activeStacks) >= s.cfg.MaxPer { return ErrStackLimitReached } @@ -517,3 +617,11 @@ func timePtr(value time.Time) *time.Time { func stackRateLimitKey(userID int64) string { return "stack:create:" + strconv.FormatInt(userID, 10) } + +func stackTeamRateLimitKey(teamID int64) string { + return "stack:create:team:" + strconv.FormatInt(teamID, 10) +} + +func (s *StackService) maxScopeIsTeam() bool { + return strings.EqualFold(s.cfg.MaxScope, "team") +} diff --git a/internal/service/stack_service_test.go b/internal/service/stack_service_test.go index ef63709..e69ed15 100644 --- a/internal/service/stack_service_test.go +++ b/internal/service/stack_service_test.go @@ -58,7 +58,7 @@ func TestStackServiceGetOrCreateStack(t *testing.T) { cfg := config.StackConfig{ Enabled: true, - MaxPerUser: 2, + MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5, } @@ -91,7 +91,7 @@ func TestStackServiceUserStackSummary(t *testing.T) { cfg := config.StackConfig{ Enabled: true, - MaxPerUser: 3, + MaxPer: 3, CreateWindow: time.Minute, CreateMax: 5, } @@ -112,8 +112,8 @@ func TestStackServiceUserStackSummary(t *testing.T) { t.Fatalf("UserStackSummary empty: %v", err) } - if count != 0 || limit != cfg.MaxPerUser { - t.Fatalf("expected empty summary 0/%d, got %d/%d", cfg.MaxPerUser, count, limit) + if count != 0 || limit != cfg.MaxPer { + t.Fatalf("expected empty summary 0/%d, got %d/%d", cfg.MaxPer, count, limit) } now := time.Now().UTC() @@ -145,8 +145,139 @@ func TestStackServiceUserStackSummary(t *testing.T) { if err != nil { t.Fatalf("UserStackSummary: %v", err) } - if count != 1 || limit != cfg.MaxPerUser { - t.Fatalf("expected summary 1/%d, got %d/%d", cfg.MaxPerUser, count, limit) + if count != 1 || limit != cfg.MaxPer { + t.Fatalf("expected summary 1/%d, got %d/%d", cfg.MaxPer, count, limit) + } +} + +func TestStackServiceUserStackSummaryTeamScope(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "team-summary-1@example.com", "team-summary-1", "pass", models.UserRole) + user2 := createUserWithTeam(t, env, "team-summary-2@example.com", "team-summary-2", "pass", models.UserRole, user.TeamID) + challenge1 := createStackChallenge(t, env, "team-summary-1") + challenge2 := createStackChallenge(t, env, "team-summary-2") + + cfg := config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 5, + CreateWindow: time.Minute, + CreateMax: 5, + } + stackSvc, stackRepo := newStackService(env, stack.NewProvisionerMock().Client(), cfg) + + now := time.Now().UTC() + stackOne := &models.Stack{ + UserID: user.ID, + ChallengeID: challenge1.ID, + StackID: "team-summary-1", + Status: "running", + CreatedAt: now, + UpdatedAt: now, + } + if err := stackRepo.Create(context.Background(), stackOne); err != nil { + t.Fatalf("create stack one: %v", err) + } + + stack2 := &models.Stack{ + UserID: user2.ID, + ChallengeID: challenge2.ID, + StackID: "team-summary-2", + Status: "running", + CreatedAt: now, + UpdatedAt: now, + } + if err := stackRepo.Create(context.Background(), stack2); err != nil { + t.Fatalf("create stack 2: %v", err) + } + + count, limit, err := stackSvc.UserStackSummary(context.Background(), user.ID) + if err != nil { + t.Fatalf("UserStackSummary team: %v", err) + } + + if count != 2 || limit != cfg.MaxPer { + t.Fatalf("expected team summary 2/%d, got %d/%d", cfg.MaxPer, count, limit) + } +} + +func TestStackServiceListUserStacksTeamScope(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "team-list-1@example.com", "team-list-1", "pass", models.UserRole) + user2 := createUserWithTeam(t, env, "team-list-2@example.com", "team-list-2", "pass", models.UserRole, user.TeamID) + challenge1 := createStackChallenge(t, env, "team-list-1") + challenge2 := createStackChallenge(t, env, "team-list-2") + + client := &stack.MockClient{ + GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { + return &stack.StackStatus{StackID: stackID, Status: "running"}, nil + }, + } + + cfg := config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 5, + CreateWindow: time.Minute, + CreateMax: 5, + } + stackSvc, stackRepo := newStackService(env, client, cfg) + + now := time.Now().UTC() + stackOne := &models.Stack{UserID: user.ID, ChallengeID: challenge1.ID, StackID: "team-list-1", Status: "running", CreatedAt: now, UpdatedAt: now} + stack2 := &models.Stack{UserID: user2.ID, ChallengeID: challenge2.ID, StackID: "team-list-2", Status: "running", CreatedAt: now, UpdatedAt: now} + if err := stackRepo.Create(context.Background(), stackOne); err != nil { + t.Fatalf("create stack one: %v", err) + } + + if err := stackRepo.Create(context.Background(), stack2); err != nil { + t.Fatalf("create stack 2: %v", err) + } + + stacks, err := stackSvc.ListUserStacks(context.Background(), user.ID) + if err != nil { + t.Fatalf("ListUserStacks team: %v", err) + } + + if len(stacks) != 2 { + t.Fatalf("expected 2 stacks, got %d", len(stacks)) + } +} + +func TestStackServiceGetStackTeamScope(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "team-get-1@example.com", "team-get-1", "pass", models.UserRole) + user2 := createUserWithTeam(t, env, "team-get-2@example.com", "team-get-2", "pass", models.UserRole, user.TeamID) + challenge := createStackChallenge(t, env, "team-get") + + client := &stack.MockClient{ + GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { + return &stack.StackStatus{StackID: stackID, Status: "running"}, nil + }, + } + + cfg := config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 5, + CreateWindow: time.Minute, + CreateMax: 5, + } + stackSvc, stackRepo := newStackService(env, client, cfg) + + now := time.Now().UTC() + stackModel := &models.Stack{UserID: user2.ID, ChallengeID: challenge.ID, StackID: "team-get", Status: "running", CreatedAt: now, UpdatedAt: now} + if err := stackRepo.Create(context.Background(), stackModel); err != nil { + t.Fatalf("create stack: %v", err) + } + + got, err := stackSvc.GetStack(context.Background(), user.ID, challenge.ID) + if err != nil { + t.Fatalf("GetStack team: %v", err) + } + + if got.StackID != "team-get" { + t.Fatalf("expected team stack, got %+v", got) } } @@ -159,7 +290,7 @@ func TestStackServiceCreateStackInvalidPorts(t *testing.T) { } mock := stack.NewProvisionerMock() - cfg := config.StackConfig{Enabled: true, MaxPerUser: 2, CreateWindow: time.Minute, CreateMax: 5} + cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} stackSvc, _ := newStackService(env, mock.Client(), cfg) if _, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge.ID); !errors.Is(err, ErrStackInvalidSpec) { @@ -176,7 +307,7 @@ func TestStackServiceCreateStackProvisionerUnavailable(t *testing.T) { return nil, stack.ErrUnavailable }, } - cfg := config.StackConfig{Enabled: true, MaxPerUser: 2, CreateWindow: time.Minute, CreateMax: 5} + cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} stackSvc, _ := newStackService(env, client, cfg) if _, err := stackSvc.GetOrCreateStack(context.Background(), 1, challenge.ID); !errors.Is(err, ErrStackProvisionerDown) { @@ -184,6 +315,18 @@ func TestStackServiceCreateStackProvisionerUnavailable(t *testing.T) { } } +func TestStackServiceGetStackNotFound(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "get-missing@example.com", "get-missing", "pass", models.UserRole) + challenge := createStackChallenge(t, env, "get-missing") + + stackSvc, _ := newStackService(env, stack.NewProvisionerMock().Client(), config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) + + if _, err := stackSvc.GetStack(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrStackNotFound) { + t.Fatalf("expected ErrStackNotFound, got %v", err) + } +} + func TestStackServiceLockedChallenge(t *testing.T) { env := setupServiceTest(t) user := createUserWithNewTeam(t, env, "locked-stack@example.com", "locked-stack", "pass", models.UserRole) @@ -197,7 +340,7 @@ func TestStackServiceLockedChallenge(t *testing.T) { mock := stack.NewProvisionerMock() cfg := config.StackConfig{ Enabled: true, - MaxPerUser: 2, + MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5, } @@ -222,7 +365,7 @@ func TestStackServiceRateLimit(t *testing.T) { cfg := config.StackConfig{ Enabled: true, - MaxPerUser: 5, + MaxPer: 5, CreateWindow: time.Minute, CreateMax: 1, } @@ -237,6 +380,117 @@ func TestStackServiceRateLimit(t *testing.T) { } } +func TestStackServiceGetStackTeamScopeNotFound(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "team-get-missing@example.com", "team-get-missing", "pass", models.UserRole) + challenge := createStackChallenge(t, env, "team-get-missing") + + stackSvc, _ := newStackService(env, stack.NewProvisionerMock().Client(), config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 5, + CreateWindow: time.Minute, + CreateMax: 5, + }) + + if _, err := stackSvc.GetStack(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrStackNotFound) { + t.Fatalf("expected ErrStackNotFound, got %v", err) + } +} + +func TestStackServiceListUserStacksTeamScopeIgnoresOtherTeam(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "team-list-a@example.com", "team-list-a", "pass", models.UserRole) + otherUser := createUserWithNewTeam(t, env, "team-list-b@example.com", "team-list-b", "pass", models.UserRole) + challenge1 := createStackChallenge(t, env, "team-list-a") + challenge2 := createStackChallenge(t, env, "team-list-b") + + client := &stack.MockClient{ + GetStackStatusFn: func(ctx context.Context, stackID string) (*stack.StackStatus, error) { + return &stack.StackStatus{StackID: stackID, Status: "running"}, nil + }, + } + + stackSvc, stackRepo := newStackService(env, client, config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 5, + CreateWindow: time.Minute, + CreateMax: 5, + }) + + now := time.Now().UTC() + stackOne := &models.Stack{UserID: user.ID, ChallengeID: challenge1.ID, StackID: "team-list-a-1", Status: "running", CreatedAt: now, UpdatedAt: now} + stackTwo := &models.Stack{UserID: otherUser.ID, ChallengeID: challenge2.ID, StackID: "team-list-b-1", Status: "running", CreatedAt: now, UpdatedAt: now} + if err := stackRepo.Create(context.Background(), stackOne); err != nil { + t.Fatalf("create stack one: %v", err) + } + + if err := stackRepo.Create(context.Background(), stackTwo); err != nil { + t.Fatalf("create stack two: %v", err) + } + + stacks, err := stackSvc.ListUserStacks(context.Background(), user.ID) + if err != nil { + t.Fatalf("ListUserStacks team: %v", err) + } + + if len(stacks) != 1 || stacks[0].StackID != "team-list-a-1" { + t.Fatalf("expected only team stack, got %+v", stacks) + } +} + +func TestStackServiceRateLimitTeamScope(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "rate-team-1@example.com", "rate-team-1", "pass", models.UserRole) + user2 := createUserWithTeam(t, env, "rate-team-2@example.com", "rate-team-2", "pass", models.UserRole, user.TeamID) + challenge1 := createStackChallenge(t, env, "rate-team-1") + challenge2 := createStackChallenge(t, env, "rate-team-2") + + mock := stack.NewProvisionerMock() + + cfg := config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 5, + CreateWindow: time.Minute, + CreateMax: 1, + } + stackSvc, _ := newStackService(env, mock.Client(), cfg) + + if _, err := stackSvc.GetOrCreateStack(context.Background(), user.ID, challenge1.ID); err != nil { + t.Fatalf("first create: %v", err) + } + + if _, err := stackSvc.GetOrCreateStack(context.Background(), user2.ID, challenge2.ID); !errors.Is(err, ErrRateLimited) { + t.Fatalf("expected team rate limit error, got %v", err) + } +} + +func TestStackServiceDeleteStackNotFound(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "delete-missing@example.com", "delete-missing", "pass", models.UserRole) + challenge := createStackChallenge(t, env, "delete-missing") + + stackSvc, _ := newStackService(env, stack.NewProvisionerMock().Client(), config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) + + if err := stackSvc.DeleteStack(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrStackNotFound) { + t.Fatalf("expected ErrStackNotFound, got %v", err) + } +} + +func TestStackServiceDeleteStackByUserAndChallengeNotFound(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "delete-user-missing@example.com", "delete-user-missing", "pass", models.UserRole) + challenge := createStackChallenge(t, env, "delete-user-missing") + + stackSvc, _ := newStackService(env, stack.NewProvisionerMock().Client(), config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) + + if err := stackSvc.DeleteStackByUserAndChallenge(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrStackNotFound) { + t.Fatalf("expected ErrStackNotFound, got %v", err) + } +} + func TestStackServiceUserLimit(t *testing.T) { env := setupServiceTest(t) challenge1 := createStackChallenge(t, env, "stack-1") @@ -246,7 +500,7 @@ func TestStackServiceUserLimit(t *testing.T) { cfg := config.StackConfig{ Enabled: true, - MaxPerUser: 1, + MaxPer: 1, CreateWindow: time.Minute, CreateMax: 10, } @@ -261,6 +515,119 @@ func TestStackServiceUserLimit(t *testing.T) { } } +func TestStackServiceUserLimitTeamScope(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "limit-team-1@example.com", "limit-team-1", "pass", models.UserRole) + user2 := createUserWithTeam(t, env, "limit-team-2@example.com", "limit-team-2", "pass", models.UserRole, user.TeamID) + challenge1 := createStackChallenge(t, env, "limit-team-1") + challenge2 := createStackChallenge(t, env, "limit-team-2") + + mock := stack.NewProvisionerMock() + + cfg := config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 1, + CreateWindow: time.Minute, + CreateMax: 10, + } + stackSvc, _ := newStackService(env, mock.Client(), cfg) + + if _, err := stackSvc.GetOrCreateStack(context.Background(), user.ID, challenge1.ID); err != nil { + t.Fatalf("first create: %v", err) + } + + if _, err := stackSvc.GetOrCreateStack(context.Background(), user2.ID, challenge2.ID); !errors.Is(err, ErrStackLimitReached) { + t.Fatalf("expected team stack limit error, got %v", err) + } +} + +func TestStackServiceDeleteStackTeamScope(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "team-del-1@example.com", "team-del-1", "pass", models.UserRole) + userTwo := createUserWithTeam(t, env, "team-del-2@example.com", "team-del-2", "pass", models.UserRole, user.TeamID) + challenge := createStackChallenge(t, env, "team-del") + + deleted := false + client := &stack.MockClient{ + DeleteStackFn: func(ctx context.Context, stackID string) error { + if stackID == "team-del" { + deleted = true + } + return nil + }, + } + + stackSvc, stackRepo := newStackService(env, client, config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 5, + CreateWindow: time.Minute, + CreateMax: 5, + }) + + now := time.Now().UTC() + stackModel := &models.Stack{UserID: userTwo.ID, ChallengeID: challenge.ID, StackID: "team-del", Status: "running", CreatedAt: now, UpdatedAt: now} + if err := stackRepo.Create(context.Background(), stackModel); err != nil { + t.Fatalf("create stack: %v", err) + } + + if err := stackSvc.DeleteStack(context.Background(), user.ID, challenge.ID); err != nil { + t.Fatalf("DeleteStack: %v", err) + } + + if !deleted { + t.Fatalf("expected provisioner delete") + } + + if _, err := stackRepo.GetByStackID(context.Background(), "team-del"); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected stack deleted, got %v", err) + } +} + +func TestStackServiceDeleteStackByUserAndChallengeTeamScope(t *testing.T) { + env := setupServiceTest(t) + user := createUserWithNewTeam(t, env, "team-del-u-1@example.com", "team-del-u-1", "pass", models.UserRole) + userTwo := createUserWithTeam(t, env, "team-del-u-2@example.com", "team-del-u-2", "pass", models.UserRole, user.TeamID) + challenge := createStackChallenge(t, env, "team-del-u") + + deleted := false + client := &stack.MockClient{ + DeleteStackFn: func(ctx context.Context, stackID string) error { + if stackID == "team-del-u" { + deleted = true + } + return nil + }, + } + + stackSvc, stackRepo := newStackService(env, client, config.StackConfig{ + Enabled: true, + MaxScope: "team", + MaxPer: 5, + CreateWindow: time.Minute, + CreateMax: 5, + }) + + now := time.Now().UTC() + stackModel := &models.Stack{UserID: userTwo.ID, ChallengeID: challenge.ID, StackID: "team-del-u", Status: "running", CreatedAt: now, UpdatedAt: now} + if err := stackRepo.Create(context.Background(), stackModel); err != nil { + t.Fatalf("create stack: %v", err) + } + + if err := stackSvc.DeleteStackByUserAndChallenge(context.Background(), user.ID, challenge.ID); err != nil { + t.Fatalf("DeleteStackByUserAndChallenge: %v", err) + } + + if !deleted { + t.Fatalf("expected provisioner delete") + } + + if _, err := stackRepo.GetByStackID(context.Background(), "team-del-u"); !errors.Is(err, repo.ErrNotFound) { + t.Fatalf("expected stack deleted, got %v", err) + } +} + func TestStackServiceTerminalStatusDeletes(t *testing.T) { env := setupServiceTest(t) challenge := createStackChallenge(t, env, "stack") @@ -269,7 +636,7 @@ func TestStackServiceTerminalStatusDeletes(t *testing.T) { cfg := config.StackConfig{ Enabled: true, - MaxPerUser: 2, + MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5, } @@ -314,7 +681,7 @@ func TestStackServiceAlreadySolvedDeletesExisting(t *testing.T) { mock := stack.NewProvisionerMock() - cfg := config.StackConfig{Enabled: true, MaxPerUser: 2, CreateWindow: time.Minute, CreateMax: 5} + cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} stackSvc := NewStackService(cfg, stackRepo, env.challengeRepo, env.submissionRepo, mock.Client(), env.redis) if _, err := stackSvc.GetOrCreateStack(context.Background(), user.ID, challenge.ID); !errors.Is(err, ErrAlreadySolved) { @@ -337,7 +704,7 @@ func TestStackServiceDeleteStackByStackID(t *testing.T) { mock := stack.NewProvisionerMock() - cfg := config.StackConfig{Enabled: true, MaxPerUser: 2, CreateWindow: time.Minute, CreateMax: 5} + cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} stackSvc, stackRepo := newStackService(env, mock.Client(), cfg) stackModel := &models.Stack{ @@ -379,7 +746,7 @@ func TestStackServiceGetStackByStackID(t *testing.T) { mock := stack.NewProvisionerMock() - cfg := config.StackConfig{Enabled: true, MaxPerUser: 2, CreateWindow: time.Minute, CreateMax: 5} + cfg := config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5} stackSvc, stackRepo := newStackService(env, mock.Client(), cfg) stackModel := &models.Stack{ @@ -417,7 +784,7 @@ func TestStackServiceListAdminStacks(t *testing.T) { challenge := createStackChallenge(t, env, "admin-stack") mock := stack.NewProvisionerMock() - stackSvc, stackRepo := newStackService(env, mock.Client(), config.StackConfig{Enabled: true, MaxPerUser: 2, CreateWindow: time.Minute, CreateMax: 5}) + stackSvc, stackRepo := newStackService(env, mock.Client(), config.StackConfig{Enabled: true, MaxPer: 2, CreateWindow: time.Minute, CreateMax: 5}) stackModel := &models.Stack{ UserID: user.ID,