Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/docs/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ Errors:
- 401 `invalid token` or `missing authorization` or `invalid authorization`
- 403 `forbidden`

Validation notes:

- `flag` must be at most 72 bytes (bcrypt input limit).

---

## List Registration Keys
Expand Down Expand Up @@ -613,6 +617,10 @@ Errors:
- 403 `forbidden`
- 404 `challenge not found`

Validation notes:

- When provided, `flag` must be at most 72 bytes (bcrypt input limit).

---

## Get Challenge Detail (Admin)
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ Errors:
- 400 `invalid input`
- 409 `user already exists`

Validation notes:

- `password` must be at most 72 bytes (bcrypt input limit).

`registration_key` must be an admin-created alphanumeric code.
Keys can be reused up to their configured `max_uses` and assign the user to the key's team.

Expand Down
1 change: 1 addition & 0 deletions docs/docs/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Errors:
- 400 `invalid input`
- 401 `invalid token` or `missing authorization` or `invalid authorization`
- 403 `user blocked`
- 409 `user already exists` (username already in use)

Notes:

Expand Down
5 changes: 4 additions & 1 deletion internal/bootstrap/testenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ func startPostgres(ctx context.Context) (testcontainers.Container, config.DBConf
"POSTGRES_PASSWORD": "smctf",
"POSTGRES_DB": "smctf_test",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
WaitingFor: wait.ForAll(
wait.ForListeningPort("5432/tcp").SkipExternalCheck(),
wait.ForLog("database system is ready to accept connections"),
),
Comment thread
yulmwu marked this conversation as resolved.
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
Expand Down
5 changes: 4 additions & 1 deletion internal/db/testenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ func startPostgres(ctx context.Context) (testcontainers.Container, config.DBConf
"POSTGRES_PASSWORD": "smctf",
"POSTGRES_DB": "smctf_test",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
WaitingFor: wait.ForAll(
wait.ForListeningPort("5432/tcp").SkipExternalCheck(),
wait.ForLog("database system is ready to accept connections"),
),
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
Expand Down
5 changes: 4 additions & 1 deletion internal/http/handlers/testenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ func startHandlerPostgres(ctx context.Context) (testcontainers.Container, config
"POSTGRES_PASSWORD": "smctf",
"POSTGRES_DB": "smctf_test",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
WaitingFor: wait.ForAll(
wait.ForListeningPort("5432/tcp").SkipExternalCheck(),
wait.ForLog("database system is ready to accept connections"),
),
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
Expand Down
24 changes: 24 additions & 0 deletions internal/http/integration/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -74,6 +75,20 @@ func TestAdminCreateChallenge(t *testing.T) {
decodeJSON(t, rec, &resp)

assertFieldErrors(t, resp.Details, map[string]string{"category": "invalid"})

rec = doRequest(t, env.router, http.MethodPost, "/api/admin/challenges", map[string]any{
"title": "Ch4",
"description": "desc",
"category": "Web",
"points": 100,
"flag": strings.Repeat("a", 73),
"is_active": true,
}, authHeader(adminAccess))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decodeJSON(t, rec, &resp)
assertFieldErrors(t, resp.Details, map[string]string{"flag": "max bytes is 72"})
}

func TestAdminUpdateChallenge(t *testing.T) {
Expand Down Expand Up @@ -163,6 +178,15 @@ func TestAdminUpdateChallenge(t *testing.T) {
t.Fatalf("expected flag hash to be updated")
}

rec = doRequest(t, env.router, http.MethodPut, "/api/admin/challenges/"+itoa(created.ID), map[string]any{
"flag": strings.Repeat("a", 73),
}, authHeader(adminAccess))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
decodeJSON(t, rec, &errResp)
assertFieldErrors(t, errResp.Details, map[string]string{"flag": "max bytes is 72"})

nullCases := []struct {
name string
body map[string]any
Expand Down
30 changes: 30 additions & 0 deletions internal/http/integration/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"net/http"
"smctf/internal/models"
"smctf/internal/service"
"strings"
"testing"
)

Expand Down Expand Up @@ -168,6 +169,29 @@ func TestRegister(t *testing.T) {
t.Fatalf("unexpected error: %s", resp.Error)
}
})

t.Run("password too long", func(t *testing.T) {
env := setupTest(t, testCfg)
admin := ensureAdminUser(t, env)
key := createRegistrationKey(t, env, admin.ID)
body := map[string]string{
"email": "user@example.com",
"username": "user1",
"password": strings.Repeat("a", 73),
"registration_key": key.Code,
}

rec := doRequest(t, env.router, http.MethodPost, "/api/auth/register", body, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}

var resp errorResp
decodeJSON(t, rec, &resp)
assertFieldErrors(t, resp.Details, map[string]string{
"password": "max bytes is 72",
})
})
}

func TestLogin(t *testing.T) {
Expand Down Expand Up @@ -315,6 +339,12 @@ func TestUpdateMe(t *testing.T) {
if resp.ID != userID || resp.Email != "user@example.com" || resp.Username != "newuser" || resp.Role != models.UserRole {
t.Fatalf("unexpected response: %+v", resp)
}

_, _, _ = registerAndLogin(t, env, "user2@example.com", "user2", "strong-password")
rec = doRequest(t, env.router, http.MethodPut, "/api/me", map[string]string{"username": "user2"}, authHeader(access))
if rec.Code != http.StatusConflict {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
}

func TestMeSolved(t *testing.T) {
Expand Down
5 changes: 4 additions & 1 deletion internal/http/integration/testenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,10 @@ func startPostgres(ctx context.Context) (testcontainers.Container, config.DBConf
"POSTGRES_PASSWORD": "smctf",
"POSTGRES_DB": "smctf_test",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
WaitingFor: wait.ForAll(
wait.ForListeningPort("5432/tcp").SkipExternalCheck(),
wait.ForLog("database system is ready to accept connections"),
),
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
Expand Down
5 changes: 4 additions & 1 deletion internal/repo/testenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ func startPostgres(ctx context.Context) (testcontainers.Container, config.DBConf
"POSTGRES_PASSWORD": "smctf",
"POSTGRES_DB": "smctf_test",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
WaitingFor: wait.ForAll(
wait.ForListeningPort("5432/tcp").SkipExternalCheck(),
wait.ForLog("database system is ready to accept connections"),
),
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
Expand Down
18 changes: 18 additions & 0 deletions internal/repo/user_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package repo

import (
"context"
"strings"

"smctf/internal/models"

Expand All @@ -12,6 +13,23 @@ type UserRepo struct {
db *bun.DB
}

func (r *UserRepo) ExistsByUsername(ctx context.Context, username string, excludeUserID *int64) (bool, error) {
query := r.db.NewSelect().
TableExpr("users AS u").
Where("u.username = ?", strings.TrimSpace(username))

if excludeUserID != nil {
query = query.Where("u.id != ?", *excludeUserID)
}

count, err := query.Count(ctx)
if err != nil {
return false, wrapError("userRepo.ExistsByUsername", err)
}

return count > 0, nil
}

func NewUserRepo(db *bun.DB) *UserRepo {
return &UserRepo{db: db}
}
Expand Down
32 changes: 32 additions & 0 deletions internal/repo/user_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,35 @@ func TestUserRepoNotFoundCases(t *testing.T) {
t.Fatalf("expected GetByEmailOrUsername not found, got %v", err)
}
}

func TestUserRepoExistsByUsername(t *testing.T) {
env := setupRepoTest(t)
user := createUserWithNewTeam(t, env, "exists@example.com", "exists-user", "pass", models.UserRole)

exists, err := env.userRepo.ExistsByUsername(context.Background(), "exists-user", nil)
if err != nil {
t.Fatalf("ExistsByUsername: %v", err)
}

if !exists {
t.Fatalf("expected exists=true")
}

exists, err = env.userRepo.ExistsByUsername(context.Background(), "exists-user", &user.ID)
if err != nil {
t.Fatalf("ExistsByUsername with exclude id: %v", err)
}

if exists {
t.Fatalf("expected exists=false when excluding same user")
}

exists, err = env.userRepo.ExistsByUsername(context.Background(), " missing-user ", nil)
if err != nil {
t.Fatalf("ExistsByUsername missing: %v", err)
}

if exists {
t.Fatalf("expected exists=false for missing username")
}
}
1 change: 1 addition & 0 deletions internal/service/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password, r
validator.Required("password", password)
validator.Required("registration_key", registrationKey)
validator.Email("email", email)
validator.MaxBytes("password", password, bcryptInputMaxBytes)

if registrationKey != "" && !isRegistrationCode(registrationKey) {
validator.fields = append(validator.fields, FieldError{Field: "registration_key", Reason: "invalid"})
Expand Down
16 changes: 16 additions & 0 deletions internal/service/auth_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ func TestAuthServiceRegisterValidation(t *testing.T) {
}
}

func TestAuthServiceRegisterPasswordTooLong(t *testing.T) {
env := setupServiceTest(t)
admin := createUserWithNewTeam(t, env, "admin@example.com", models.AdminRole, "pass", models.AdminRole)
key := createRegistrationKey(t, env, "ABCDEFGHJKLMNPQ7", admin.ID)

_, err := env.authSvc.Register(context.Background(), "user@example.com", "user1", strings.Repeat("a", 73), key.Code, "")
var ve *ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected validation error, got %v", err)
}

if len(ve.Fields) == 0 || ve.Fields[0].Field != "password" || ve.Fields[0].Reason != "max bytes is 72" {
t.Fatalf("unexpected validation details: %+v", ve.Fields)
}
}

func TestAuthServiceRegisterUserExists(t *testing.T) {
env := setupServiceTest(t)
admin := createUserWithNewTeam(t, env, "admin@example.com", models.AdminRole, "pass", models.AdminRole)
Expand Down
2 changes: 2 additions & 0 deletions internal/service/ctf_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ func (s *CTFService) CreateChallenge(ctx context.Context, title, description, ca
validator.Required("flag", flag)
validator.NonNegative("points", points)
validator.NonNegative("minimum_points", minimumPoints)
validator.MaxBytes("flag", flag, bcryptInputMaxBytes)
if previousChallengeID != nil {
validator.PositiveID("previous_challenge_id", *previousChallengeID)
}
Expand Down Expand Up @@ -241,6 +242,7 @@ func (s *CTFService) UpdateChallenge(ctx context.Context, id int64, title, descr
if value == "" {
validator.fields = append(validator.fields, FieldError{Field: "flag", Reason: "required"})
} else {
validator.MaxBytes("flag", value, bcryptInputMaxBytes)
normalizedFlag = &value
}
}
Expand Down
14 changes: 14 additions & 0 deletions internal/service/ctf_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,20 @@ func TestCTFServiceUpdateChallenge(t *testing.T) {
}
}

func TestCTFServiceChallengeFlagTooLong(t *testing.T) {
env := setupServiceTest(t)
longFlag := strings.Repeat("a", 73)

if _, err := env.ctfSvc.CreateChallenge(context.Background(), "Title", "Desc", "Misc", 100, 50, longFlag, true, false, nil, nil, nil); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected invalid input for create long flag, got %v", err)
}

challenge := createChallenge(t, env, "Old", 50, "FLAG{2}", true)
if _, err := env.ctfSvc.UpdateChallenge(context.Background(), challenge.ID, nil, nil, nil, nil, nil, &longFlag, nil, nil, nil, nil, nil, false); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected invalid input for update long flag, got %v", err)
}
}

func TestCTFServiceDeleteChallenge(t *testing.T) {
env := setupServiceTest(t)
challenge := createChallenge(t, env, "Delete", 50, "FLAG{3}", true)
Expand Down
5 changes: 4 additions & 1 deletion internal/service/testenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ func startPostgres(ctx context.Context) (testcontainers.Container, config.DBConf
"POSTGRES_PASSWORD": "smctf",
"POSTGRES_DB": "smctf_test",
},
WaitingFor: wait.ForListeningPort("5432/tcp"),
WaitingFor: wait.ForAll(
wait.ForListeningPort("5432/tcp").SkipExternalCheck(),
wait.ForLog("database system is ready to accept connections"),
),
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
Expand Down
19 changes: 18 additions & 1 deletion internal/service/user_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"time"

"smctf/internal/db"
"smctf/internal/models"
"smctf/internal/repo"
)
Expand Down Expand Up @@ -82,10 +83,26 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, username
}

if username != nil {
user.Username = *username
normalizedUsername := normalizeTrim(*username)
if normalizedUsername == "" {
return nil, NewValidationError(FieldError{Field: "username", Reason: "required"})
}

exists, err := s.userRepo.ExistsByUsername(ctx, normalizedUsername, &userID)
if err != nil {
return nil, fmt.Errorf("user.UpdateProfile username exists: %w", err)
}
if exists {
return nil, ErrUserExists
}

user.Username = normalizedUsername
}

if err := s.userRepo.Update(ctx, user); err != nil {
if db.IsUniqueViolation(err) {
return nil, ErrUserExists
}
return nil, fmt.Errorf("user.UpdateProfile: %w", err)
}

Expand Down
Loading
Loading