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
50 changes: 50 additions & 0 deletions internal/api/auth/login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package auth

import (
"errors"
"net/http"

"github.com/gomantics/semantix/internal/api/web"
"github.com/gomantics/semantix/internal/domains/users"
"go.uber.org/zap"
)

type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}

func Login(c web.Context) error {
var req LoginRequest
if err := c.Bind(&req); err != nil {
return c.BadRequest("invalid request body")
}

if req.Email == "" || req.Password == "" {
return c.BadRequest("email and password are required")
}

ctx := c.Request().Context()

user, err := users.Login(ctx, req.Email, req.Password)
if err != nil {
if errors.Is(err, users.ErrInvalidCreds) {
return c.Error(http.StatusUnauthorized, "invalid email or password")
}
c.L.Error("failed to login", zap.Error(err))
return c.InternalError("failed to login")
}

token, err := users.CreateSession(ctx, user.ID)
if err != nil {
c.L.Error("failed to create session", zap.Error(err))
return c.InternalError("failed to create session")
}

c.SetCookie(sessionCookie(token))

return c.OK(map[string]any{
"id": user.ID,
"email": user.Email,
})
}
45 changes: 45 additions & 0 deletions internal/api/auth/login_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package auth_test

import (
"net/http"
"testing"

"github.com/gomantics/semantix/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLogin_invalidCreds(t *testing.T) {
t.Parallel()
s := testutil.NewState(t)

err := s.PostStatus("/v1/auth/login", map[string]any{
"email": "nobody-" + testutil.UniqueID() + "@test.com",
"password": "wrongpass",
})
testutil.RequireStatus(t, err, http.StatusUnauthorized)
}

func TestLogin_missingFields(t *testing.T) {
t.Parallel()
s := testutil.NewState(t)

err := s.PostStatus("/v1/auth/login", map[string]any{
"email": "",
"password": "",
})
testutil.RequireStatus(t, err, http.StatusBadRequest)
}

// TestLogin_validCredentials must not be parallel: depends on admin user existing.
func TestLogin_validCredentials(t *testing.T) {
getAdminState(t)
s := testutil.NewState(t)

body, err := s.Post("/v1/auth/login", map[string]any{
"email": adminEmail,
"password": "password123",
})
require.NoError(t, err)
assert.Equal(t, adminEmail, body["email"])
}
33 changes: 33 additions & 0 deletions internal/api/auth/logout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package auth

import (
"net/http"
"time"

"github.com/gomantics/semantix/internal/api/web"
"github.com/gomantics/semantix/internal/domains/users"
"go.uber.org/zap"
)

func Logout(c web.Context) error {
cookie, err := c.Cookie("session_token")
if err != nil || cookie.Value == "" {
return c.NoContent()
}

ctx := c.Request().Context()
if err := users.DeleteSession(ctx, cookie.Value); err != nil {
c.L.Error("failed to delete session", zap.Error(err))
}

c.SetCookie(&http.Cookie{
Name: "session_token",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
Expires: time.Unix(0, 0),
})

return c.NoContent()
}
41 changes: 41 additions & 0 deletions internal/api/auth/logout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package auth_test

import (
"net/http"
"testing"

"github.com/gomantics/semantix/internal/testutil"
"github.com/stretchr/testify/require"
)

// TestLogout_clearsSession must not be parallel.
func TestLogout_clearsSession(t *testing.T) {
getAdminState(t)
s := testutil.NewState(t)
_, err := s.Post("/v1/auth/login", map[string]any{
"email": adminEmail,
"password": "password123",
})
require.NoError(t, err)

// Verify authenticated.
_, err = s.Get("/v1/auth/me")
require.NoError(t, err)

// Logout clears the cookie.
err = s.PostStatus("/v1/auth/logout", nil)
testutil.RequireStatus(t, err, http.StatusNoContent)

// Now /me should return 401.
err = s.GetStatus("/v1/auth/me")
testutil.RequireStatus(t, err, http.StatusUnauthorized)
}

func TestLogout_withoutSession(t *testing.T) {
t.Parallel()
s := testutil.NewState(t)

// Logout with no cookie should return 204 gracefully.
err := s.PostStatus("/v1/auth/logout", nil)
testutil.RequireStatus(t, err, http.StatusNoContent)
}
12 changes: 12 additions & 0 deletions internal/api/auth/me.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package auth

import (
"github.com/gomantics/semantix/internal/api/web"
)

func Me(c web.Context) error {
return c.OK(map[string]any{
"id": c.UserID,
"email": c.UserEmail,
})
}
34 changes: 34 additions & 0 deletions internal/api/auth/me_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package auth_test

import (
"net/http"
"testing"

"github.com/gomantics/semantix/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMe_authenticated(t *testing.T) {
getAdminState(t)
s := testutil.NewState(t)

_, err := s.Post("/v1/auth/login", map[string]any{
"email": adminEmail,
"password": "password123",
})
require.NoError(t, err)

body, err := s.Get("/v1/auth/me")
require.NoError(t, err)
assert.NotEmpty(t, body["id"])
assert.Equal(t, adminEmail, body["email"])
}

func TestMe_unauthenticated(t *testing.T) {
t.Parallel()
s := testutil.NewState(t)

err := s.GetStatus("/v1/auth/me")
testutil.RequireStatus(t, err, http.StatusUnauthorized)
}
14 changes: 14 additions & 0 deletions internal/api/auth/router.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package auth

import (
"github.com/gomantics/semantix/internal/api/web"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)

func Configure(e *echo.Echo, l *zap.Logger) {
e.POST("/v1/auth/signup", web.Wrap(Signup, l))
e.POST("/v1/auth/login", web.Wrap(Login, l))
e.POST("/v1/auth/logout", web.Wrap(Logout, l))
e.GET("/v1/auth/me", web.WrapAuth(Me, l))
}
47 changes: 47 additions & 0 deletions internal/api/auth/setup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package auth_test

import (
"os"
"sync"
"testing"

"github.com/gomantics/semantix/internal/testutil"
"github.com/stretchr/testify/require"
)

func TestMain(m *testing.M) {
main := testutil.Main(m,
testutil.WithPostgres(),
)
os.Exit(main.Run())
}

// adminState holds the single authenticated session available in this package.
// Because the signup endpoint only allows one user per DB, we create it once
// across all tests using sync.Once.
var (
adminOnce sync.Once
adminEmail string
adminState *testutil.State
adminSignupBody map[string]any
)

// getAdminState returns the shared authenticated State, creating the admin
// user via signup on the first call. Safe to call from any test regardless
// of file ordering.
func getAdminState(t *testing.T) *testutil.State {
t.Helper()
adminOnce.Do(func() {
s := testutil.NewState(t)
email := "admin-" + testutil.UniqueID() + "@test.com"
body, err := s.Post("/v1/auth/signup", map[string]any{
"email": email,
"password": "password123",
})
require.NoError(t, err)
adminEmail = email
adminState = s
adminSignupBody = body
})
return adminState
}
68 changes: 68 additions & 0 deletions internal/api/auth/signup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package auth

import (
"errors"
"net/http"

"github.com/gomantics/semantix/config"
"github.com/gomantics/semantix/internal/api/web"
"github.com/gomantics/semantix/internal/domains/users"
"go.uber.org/zap"
)

type SignupRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}

func Signup(c web.Context) error {
var req SignupRequest
if err := c.Bind(&req); err != nil {
return c.BadRequest("invalid request body")
}

if req.Email == "" {
return c.BadRequest("email is required")
}
if len(req.Password) < 8 {
return c.BadRequest("password must be at least 8 characters")
}

ctx := c.Request().Context()

user, err := users.CreateFirst(ctx, users.CreateParams{
Email: req.Email,
Password: req.Password,
})
if err != nil {
if errors.Is(err, users.ErrAdminExists) {
return c.Error(http.StatusForbidden, "admin user already exists")
}
c.L.Error("failed to create user", zap.Error(err))
return c.InternalError("failed to create user")
}

token, err := users.CreateSession(ctx, user.ID)
if err != nil {
c.L.Error("failed to create session", zap.Error(err))
return c.InternalError("failed to create session")
}

c.SetCookie(sessionCookie(token))

return c.Created(map[string]any{
"id": user.ID,
"email": user.Email,
})
}

func sessionCookie(token string) *http.Cookie {
return &http.Cookie{
Name: "session_token",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: config.IsProd(),
}
}
Loading