Skip to content

Commit fc2e3a3

Browse files
authored
Add REST API endpoints with session-based auth (#3)
1 parent 21cbfaf commit fc2e3a3

66 files changed

Lines changed: 2349 additions & 27 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/api/auth/login.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package auth
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
7+
"github.com/gomantics/semantix/internal/api/web"
8+
"github.com/gomantics/semantix/internal/domains/users"
9+
"go.uber.org/zap"
10+
)
11+
12+
type LoginRequest struct {
13+
Email string `json:"email"`
14+
Password string `json:"password"`
15+
}
16+
17+
func Login(c web.Context) error {
18+
var req LoginRequest
19+
if err := c.Bind(&req); err != nil {
20+
return c.BadRequest("invalid request body")
21+
}
22+
23+
if req.Email == "" || req.Password == "" {
24+
return c.BadRequest("email and password are required")
25+
}
26+
27+
ctx := c.Request().Context()
28+
29+
user, err := users.Login(ctx, req.Email, req.Password)
30+
if err != nil {
31+
if errors.Is(err, users.ErrInvalidCreds) {
32+
return c.Error(http.StatusUnauthorized, "invalid email or password")
33+
}
34+
c.L.Error("failed to login", zap.Error(err))
35+
return c.InternalError("failed to login")
36+
}
37+
38+
token, err := users.CreateSession(ctx, user.ID)
39+
if err != nil {
40+
c.L.Error("failed to create session", zap.Error(err))
41+
return c.InternalError("failed to create session")
42+
}
43+
44+
c.SetCookie(sessionCookie(token))
45+
46+
return c.OK(map[string]any{
47+
"id": user.ID,
48+
"email": user.Email,
49+
})
50+
}

internal/api/auth/login_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package auth_test
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/gomantics/semantix/internal/testutil"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestLogin_invalidCreds(t *testing.T) {
13+
t.Parallel()
14+
s := testutil.NewState(t)
15+
16+
err := s.PostStatus("/v1/auth/login", map[string]any{
17+
"email": "nobody-" + testutil.UniqueID() + "@test.com",
18+
"password": "wrongpass",
19+
})
20+
testutil.RequireStatus(t, err, http.StatusUnauthorized)
21+
}
22+
23+
func TestLogin_missingFields(t *testing.T) {
24+
t.Parallel()
25+
s := testutil.NewState(t)
26+
27+
err := s.PostStatus("/v1/auth/login", map[string]any{
28+
"email": "",
29+
"password": "",
30+
})
31+
testutil.RequireStatus(t, err, http.StatusBadRequest)
32+
}
33+
34+
// TestLogin_validCredentials must not be parallel: depends on admin user existing.
35+
func TestLogin_validCredentials(t *testing.T) {
36+
getAdminState(t)
37+
s := testutil.NewState(t)
38+
39+
body, err := s.Post("/v1/auth/login", map[string]any{
40+
"email": adminEmail,
41+
"password": "password123",
42+
})
43+
require.NoError(t, err)
44+
assert.Equal(t, adminEmail, body["email"])
45+
}

internal/api/auth/logout.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package auth
2+
3+
import (
4+
"net/http"
5+
"time"
6+
7+
"github.com/gomantics/semantix/internal/api/web"
8+
"github.com/gomantics/semantix/internal/domains/users"
9+
"go.uber.org/zap"
10+
)
11+
12+
func Logout(c web.Context) error {
13+
cookie, err := c.Cookie("session_token")
14+
if err != nil || cookie.Value == "" {
15+
return c.NoContent()
16+
}
17+
18+
ctx := c.Request().Context()
19+
if err := users.DeleteSession(ctx, cookie.Value); err != nil {
20+
c.L.Error("failed to delete session", zap.Error(err))
21+
}
22+
23+
c.SetCookie(&http.Cookie{
24+
Name: "session_token",
25+
Value: "",
26+
Path: "/",
27+
HttpOnly: true,
28+
MaxAge: -1,
29+
Expires: time.Unix(0, 0),
30+
})
31+
32+
return c.NoContent()
33+
}

internal/api/auth/logout_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package auth_test
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/gomantics/semantix/internal/testutil"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// TestLogout_clearsSession must not be parallel.
12+
func TestLogout_clearsSession(t *testing.T) {
13+
getAdminState(t)
14+
s := testutil.NewState(t)
15+
_, err := s.Post("/v1/auth/login", map[string]any{
16+
"email": adminEmail,
17+
"password": "password123",
18+
})
19+
require.NoError(t, err)
20+
21+
// Verify authenticated.
22+
_, err = s.Get("/v1/auth/me")
23+
require.NoError(t, err)
24+
25+
// Logout clears the cookie.
26+
err = s.PostStatus("/v1/auth/logout", nil)
27+
testutil.RequireStatus(t, err, http.StatusNoContent)
28+
29+
// Now /me should return 401.
30+
err = s.GetStatus("/v1/auth/me")
31+
testutil.RequireStatus(t, err, http.StatusUnauthorized)
32+
}
33+
34+
func TestLogout_withoutSession(t *testing.T) {
35+
t.Parallel()
36+
s := testutil.NewState(t)
37+
38+
// Logout with no cookie should return 204 gracefully.
39+
err := s.PostStatus("/v1/auth/logout", nil)
40+
testutil.RequireStatus(t, err, http.StatusNoContent)
41+
}

internal/api/auth/me.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package auth
2+
3+
import (
4+
"github.com/gomantics/semantix/internal/api/web"
5+
)
6+
7+
func Me(c web.Context) error {
8+
return c.OK(map[string]any{
9+
"id": c.UserID,
10+
"email": c.UserEmail,
11+
})
12+
}

internal/api/auth/me_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package auth_test
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/gomantics/semantix/internal/testutil"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestMe_authenticated(t *testing.T) {
13+
getAdminState(t)
14+
s := testutil.NewState(t)
15+
16+
_, err := s.Post("/v1/auth/login", map[string]any{
17+
"email": adminEmail,
18+
"password": "password123",
19+
})
20+
require.NoError(t, err)
21+
22+
body, err := s.Get("/v1/auth/me")
23+
require.NoError(t, err)
24+
assert.NotEmpty(t, body["id"])
25+
assert.Equal(t, adminEmail, body["email"])
26+
}
27+
28+
func TestMe_unauthenticated(t *testing.T) {
29+
t.Parallel()
30+
s := testutil.NewState(t)
31+
32+
err := s.GetStatus("/v1/auth/me")
33+
testutil.RequireStatus(t, err, http.StatusUnauthorized)
34+
}

internal/api/auth/router.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package auth
2+
3+
import (
4+
"github.com/gomantics/semantix/internal/api/web"
5+
"github.com/labstack/echo/v4"
6+
"go.uber.org/zap"
7+
)
8+
9+
func Configure(e *echo.Echo, l *zap.Logger) {
10+
e.POST("/v1/auth/signup", web.Wrap(Signup, l))
11+
e.POST("/v1/auth/login", web.Wrap(Login, l))
12+
e.POST("/v1/auth/logout", web.Wrap(Logout, l))
13+
e.GET("/v1/auth/me", web.WrapAuth(Me, l))
14+
}

internal/api/auth/setup_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package auth_test
2+
3+
import (
4+
"os"
5+
"sync"
6+
"testing"
7+
8+
"github.com/gomantics/semantix/internal/testutil"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestMain(m *testing.M) {
13+
main := testutil.Main(m,
14+
testutil.WithPostgres(),
15+
)
16+
os.Exit(main.Run())
17+
}
18+
19+
// adminState holds the single authenticated session available in this package.
20+
// Because the signup endpoint only allows one user per DB, we create it once
21+
// across all tests using sync.Once.
22+
var (
23+
adminOnce sync.Once
24+
adminEmail string
25+
adminState *testutil.State
26+
adminSignupBody map[string]any
27+
)
28+
29+
// getAdminState returns the shared authenticated State, creating the admin
30+
// user via signup on the first call. Safe to call from any test regardless
31+
// of file ordering.
32+
func getAdminState(t *testing.T) *testutil.State {
33+
t.Helper()
34+
adminOnce.Do(func() {
35+
s := testutil.NewState(t)
36+
email := "admin-" + testutil.UniqueID() + "@test.com"
37+
body, err := s.Post("/v1/auth/signup", map[string]any{
38+
"email": email,
39+
"password": "password123",
40+
})
41+
require.NoError(t, err)
42+
adminEmail = email
43+
adminState = s
44+
adminSignupBody = body
45+
})
46+
return adminState
47+
}

internal/api/auth/signup.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package auth
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
7+
"github.com/gomantics/semantix/config"
8+
"github.com/gomantics/semantix/internal/api/web"
9+
"github.com/gomantics/semantix/internal/domains/users"
10+
"go.uber.org/zap"
11+
)
12+
13+
type SignupRequest struct {
14+
Email string `json:"email"`
15+
Password string `json:"password"`
16+
}
17+
18+
func Signup(c web.Context) error {
19+
var req SignupRequest
20+
if err := c.Bind(&req); err != nil {
21+
return c.BadRequest("invalid request body")
22+
}
23+
24+
if req.Email == "" {
25+
return c.BadRequest("email is required")
26+
}
27+
if len(req.Password) < 8 {
28+
return c.BadRequest("password must be at least 8 characters")
29+
}
30+
31+
ctx := c.Request().Context()
32+
33+
user, err := users.CreateFirst(ctx, users.CreateParams{
34+
Email: req.Email,
35+
Password: req.Password,
36+
})
37+
if err != nil {
38+
if errors.Is(err, users.ErrAdminExists) {
39+
return c.Error(http.StatusForbidden, "admin user already exists")
40+
}
41+
c.L.Error("failed to create user", zap.Error(err))
42+
return c.InternalError("failed to create user")
43+
}
44+
45+
token, err := users.CreateSession(ctx, user.ID)
46+
if err != nil {
47+
c.L.Error("failed to create session", zap.Error(err))
48+
return c.InternalError("failed to create session")
49+
}
50+
51+
c.SetCookie(sessionCookie(token))
52+
53+
return c.Created(map[string]any{
54+
"id": user.ID,
55+
"email": user.Email,
56+
})
57+
}
58+
59+
func sessionCookie(token string) *http.Cookie {
60+
return &http.Cookie{
61+
Name: "session_token",
62+
Value: token,
63+
Path: "/",
64+
HttpOnly: true,
65+
SameSite: http.SameSiteStrictMode,
66+
Secure: config.IsProd(),
67+
}
68+
}

0 commit comments

Comments
 (0)