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
19 changes: 8 additions & 11 deletions apps/server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
github.com/labstack/echo-jwt/v5 v5.0.0
github.com/labstack/echo/v5 v5.0.3
github.com/labstack/echo-jwt/v5 v5.0.1
github.com/labstack/echo/v5 v5.1.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/echo-swagger v1.5.0
github.com/swaggo/echo-swagger v1.4.1
github.com/swaggo/swag v1.16.6
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.47.0
Expand All @@ -21,6 +21,7 @@ require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.9 // indirect
Expand All @@ -31,17 +32,14 @@ require (
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/labstack/echo-jwt/v4 v4.3.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/labstack/echo/v4 v4.9.0 // indirect
github.com/labstack/gommon v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.11 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sv-tools/openapi v0.2.1 // indirect
github.com/swaggo/files/v2 v2.0.0 // indirect
github.com/swaggo/swag/v2 v2.0.0-rc4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
Expand All @@ -56,5 +54,4 @@ require (
golang.org/x/tools v0.40.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
13 changes: 5 additions & 8 deletions apps/server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
Expand Down Expand Up @@ -74,8 +76,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
Expand All @@ -91,10 +91,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/sv-tools/openapi v0.2.1 h1:ES1tMQMJFGibWndMagvdoo34T1Vllxr1Nlm5wz6b1aA=
github.com/sv-tools/openapi v0.2.1/go.mod h1:k5VuZamTw1HuiS9p2Wl5YIDWzYnHG6/FgPOSFXLAhGg=
github.com/swaggo/echo-swagger v1.5.0 h1:nkHxOaBy0SkbJMtMeXZC64KHSa0mJdZFQhVqwEcMres=
github.com/swaggo/echo-swagger v1.5.0/go.mod h1:TzO363X1ZG/MSbjrG2IX6m65Yd3/zpqh5KM6lPctAhk=
github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk=
github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
Expand Down Expand Up @@ -145,7 +143,6 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
230 changes: 168 additions & 62 deletions apps/server/internal/modules/auth/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,30 @@ import (
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
"testing"

"github.com/coderz-space/coderz.space/internal/common/middleware/auth"
"github.com/coderz-space/coderz.space/internal/common/utils"
"github.com/coderz-space/coderz.space/internal/config"
db "github.com/coderz-space/coderz.space/internal/db/sqlc"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
)

// MockQuerier implements db.Querier for testing
type MockQuerier struct {
db.Querier
GetUserByIdFunc func(ctx context.Context, id pgtype.UUID) (db.User, error)
}

func (m *MockQuerier) GetUserById(ctx context.Context, id pgtype.UUID) (db.User, error) {
if m.GetUserByIdFunc != nil {
return m.GetUserByIdFunc(ctx, id)
}
return db.User{}, nil
}


// TestSignupPasswordComplexity verifies password validation requirements
//
// Requirements: 0.5
Expand Down Expand Up @@ -397,45 +419,89 @@ func TestLogoutResponseStructure(t *testing.T) {
//
// Requirements: 0.7, 18.1-18.5
func TestMeAuthentication(t *testing.T) {
e := echo.New()

validUUIDStr := "550e8400-e29b-41d4-a716-446655440000"

tests := []struct {
name string
scenario string
setupContext func(c echo.Context)
expectedError string
expectedStatus int
}{
{
name: "authenticated user can get profile",
scenario: "valid JWT token with claims",
expectedStatus: 200,
expectedError: "",
name: "missing claims fails",
scenario: "no claims in context",
setupContext: func(c echo.Context) {},
expectedStatus: http.StatusUnauthorized,
expectedError: "INVALID_TOKEN_CLAIMS",
},
{
name: "missing token fails",
scenario: "no Authorization header or cookie",
expectedStatus: 401,
expectedError: "UNAUTHORIZED",
name: "invalid type for claims fails",
scenario: "claims is not *utils.TokenPayload",
setupContext: func(c echo.Context) {
c.Set(auth.ClaimsKey, "invalid claims")
},
expectedStatus: http.StatusUnauthorized,
expectedError: "INVALID_TOKEN_CLAIMS",
},
{
name: "invalid token fails",
scenario: "malformed or expired JWT token",
expectedStatus: 401,
expectedError: "UNAUTHORIZED",
name: "invalid user ID in claims fails",
scenario: "claims has invalid UUID format",
setupContext: func(c echo.Context) {
payload := &utils.TokenPayload{UserID: "invalid-uuid"}
c.Set(auth.ClaimsKey, payload)
},
expectedStatus: http.StatusUnauthorized,
expectedError: "INVALID_USER_ID",
},
{
name: "invalid claims fails",
scenario: "token valid but claims missing",
expectedStatus: 401,
expectedError: "INVALID_TOKEN_CLAIMS",
name: "authenticated user can get profile",
scenario: "valid JWT token with claims",
setupContext: func(c echo.Context) {
payload := &utils.TokenPayload{UserID: validUUIDStr}
c.Set(auth.ClaimsKey, payload)
},
expectedStatus: http.StatusOK,
expectedError: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// This test documents that Me:
// - Requires valid JWT authentication
// - Extracts user claims from auth context
// - Returns 401 UNAUTHORIZED for missing/invalid auth
t.Logf("Scenario: %s expects status %d", tt.scenario, tt.expectedStatus)
req := httptest.NewRequest(http.MethodGet, "/v1/auth/me", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

tt.setupContext(*c)

mockQuerier := &MockQuerier{
GetUserByIdFunc: func(ctx context.Context, id pgtype.UUID) (db.User, error) {
return db.User{
ID: id,
Name: "Test User",
Email: pgtype.Text{String: "test@example.com", Valid: true},
EmailVerified: true,
}, nil
},
}
service := NewService(mockQuerier, &config.Config{})
handler := NewHandler(service)

err := handler.Me(c)
if err != nil {
// if Echo error handling returns an error
t.Fatalf("Unexpected error: %v", err)
}

assert.Equal(t, tt.expectedStatus, rec.Code)

if tt.expectedError != "" {
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
assert.Equal(t, tt.expectedError, resp["message"])
}
})
}
}
Expand All @@ -444,32 +510,68 @@ func TestMeAuthentication(t *testing.T) {
//
// Requirements: 0.7
func TestMeUserNotFound(t *testing.T) {
e := echo.New()
validUUIDStr := "550e8400-e29b-41d4-a716-446655440000"

tests := []struct {
name string
scenario string
setupQuerier func(m *MockQuerier)
expectedError string
expectedStatus int
}{
{
name: "existing user returns profile",
scenario: "user_id from token exists in database",
expectedStatus: 200,
setupQuerier: func(m *MockQuerier) {
m.GetUserByIdFunc = func(ctx context.Context, id pgtype.UUID) (db.User, error) {
return db.User{
ID: id,
Name: "Test User",
Email: pgtype.Text{String: "test@example.com", Valid: true},
}, nil
}
},
expectedStatus: http.StatusOK,
expectedError: "",
},
{
name: "deleted user returns 404",
scenario: "user_id from token does not exist",
expectedStatus: 404,
setupQuerier: func(m *MockQuerier) {
m.GetUserByIdFunc = func(ctx context.Context, id pgtype.UUID) (db.User, error) {
return db.User{}, errors.New("not found")
}
},
expectedStatus: http.StatusNotFound,
expectedError: "USER_NOT_FOUND",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// This test documents that Me:
// - Looks up user by ID from token claims
// - Returns 404 USER_NOT_FOUND if user deleted
t.Logf("Scenario: %s expects status %d", tt.scenario, tt.expectedStatus)
req := httptest.NewRequest(http.MethodGet, "/v1/auth/me", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

payload := &utils.TokenPayload{UserID: validUUIDStr}
c.Set(auth.ClaimsKey, payload)

mockQuerier := &MockQuerier{}
tt.setupQuerier(mockQuerier)

service := NewService(mockQuerier, &config.Config{})
handler := NewHandler(service)

err := handler.Me(c)
assert.NoError(t, err)
assert.Equal(t, tt.expectedStatus, rec.Code)

if tt.expectedError != "" {
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
assert.Equal(t, tt.expectedError, resp["message"])
}
})
}
}
Expand All @@ -479,44 +581,48 @@ func TestMeUserNotFound(t *testing.T) {
// Requirements: 0.7
func TestMeResponseStructure(t *testing.T) {
t.Run("response includes user profile", func(t *testing.T) {
// This test documents that Me returns:
// - success: true
// - data: user object with id, name, email, emailVerified
// - HTTP 200 status
t.Log("Response follows UserProfileResponse structure")
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/v1/auth/me", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)

validUUIDStr := "550e8400-e29b-41d4-a716-446655440000"
validUUID, _ := utils.StringToUUID(validUUIDStr)
payload := &utils.TokenPayload{UserID: validUUIDStr}
c.Set(auth.ClaimsKey, payload)

mockQuerier := &MockQuerier{
GetUserByIdFunc: func(ctx context.Context, id pgtype.UUID) (db.User, error) {
return db.User{
ID: id,
Name: "Test User",
Email: pgtype.Text{String: "test@example.com", Valid: true},
EmailVerified: true,
}, nil
},
}

service := NewService(mockQuerier, &config.Config{})
handler := NewHandler(service)

err := handler.Me(c)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)

var resp UserProfileResponse
err = json.Unmarshal(rec.Body.Bytes(), &resp)
assert.NoError(t, err)

assert.True(t, resp.Success)
assert.Equal(t, "Test User", resp.Data.Name)
assert.Equal(t, "test@example.com", resp.Data.Email)
assert.True(t, resp.Data.EmailVerified)

// Assert ID matches
assert.Equal(t, validUUID.Bytes, resp.Data.ID.Bytes)
})
}

// TestForgotPasswordEmailEnumeration verifies security behavior
//
// Requirements: 0.2
func TestForgotPasswordEmailEnumeration(t *testing.T) {
tests := []struct {
name string
scenario string
}{
{
name: "existing email returns success",
scenario: "email exists in database",
},
{
name: "non-existent email returns success",
scenario: "email does not exist in database",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// This test documents that ForgotPassword:
// - Always returns success (HTTP 200)
// - Does not reveal whether email exists
// - Prevents email enumeration attacks
// - Only sends reset token if email exists
t.Logf("Scenario: %s always returns success", tt.scenario)
})
}
}

// TestForgotPasswordTokenGeneration verifies token creation
//
// Requirements: 0.1
Expand Down
Loading
Loading