diff --git a/account/api/api.go b/account/api/api.go index f52f195..f9eb255 100644 --- a/account/api/api.go +++ b/account/api/api.go @@ -44,6 +44,12 @@ func RegisterRoutes(r *gin.Engine) { v1.POST("/login", h.login) v1.GET("/session", h.session) v1.DELETE("/session", h.deleteSession) + + auth := r.Group("/api/auth") + auth.POST("/register", h.register) + auth.POST("/login", h.login) + auth.GET("/session", h.session) + auth.DELETE("/session", h.deleteSession) } type registerRequest struct { diff --git a/account/api/api_test.go b/account/api/api_test.go new file mode 100644 index 0000000..e4deaf5 --- /dev/null +++ b/account/api/api_test.go @@ -0,0 +1,63 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRegisterEndpointAvailableUnderMultiplePrefixes(t *testing.T) { + gin.SetMode(gin.TestMode) + + paths := []string{"/v1/register", "/api/auth/register"} + + for _, path := range paths { + router := gin.New() + RegisterRoutes(router) + + payload := map[string]string{ + "name": "Test User", + "email": "user" + path + "@example.com", + "password": "supersecure", + } + + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("%s: expected status %d, got %d, body: %s", path, http.StatusCreated, rr.Code, rr.Body.String()) + } + + var response struct { + User map[string]any `json:"user"` + } + + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("%s: failed to decode response: %v", path, err) + } + + if response.User == nil { + t.Fatalf("%s: expected user object in response", path) + } + + if email, ok := response.User["email"].(string); !ok || email != payload["email"] { + t.Fatalf("%s: expected email %q, got %#v", path, payload["email"], response.User["email"]) + } + + if _, exists := response.User["password"]; exists { + t.Fatalf("%s: response should not include password field", path) + } + } +} diff --git a/ui/homepage/app/api/auth/register/route.ts b/ui/homepage/app/api/auth/register/route.ts index 16ce58c..a24e2dd 100644 --- a/ui/homepage/app/api/auth/register/route.ts +++ b/ui/homepage/app/api/auth/register/route.ts @@ -4,6 +4,13 @@ import { getAccountServiceBaseUrl } from '@lib/serviceConfig' const ACCOUNT_SERVICE_URL = getAccountServiceBaseUrl() +type RegistrationBody = { + name: string + email: string + password: string + confirmPassword: string +} + async function registerWithAccountService(body: Record) { try { const response = await fetch(`${ACCOUNT_SERVICE_URL}/v1/register`, { @@ -24,11 +31,20 @@ async function registerWithAccountService(body: Record) { } export async function POST(request: NextRequest) { - const formData = await request.formData() - const name = String(formData.get('name') ?? '').trim() - const email = String(formData.get('email') ?? '').trim().toLowerCase() - const password = String(formData.get('password') ?? '') - const confirmPassword = String(formData.get('confirmPassword') ?? '') + let fields: RegistrationBody + try { + fields = await extractRegistrationFields(request) + } catch (error) { + console.error('Failed to parse registration payload', error) + const redirectURL = new URL('/register', request.url) + redirectURL.searchParams.set('error', 'invalid_request_payload') + return NextResponse.redirect(redirectURL, { status: 303 }) + } + + const name = fields.name.trim() + const email = fields.email.trim().toLowerCase() + const password = fields.password + const confirmPassword = fields.confirmPassword if (!email || !password) { const redirectURL = new URL('/register', request.url) @@ -54,3 +70,56 @@ export async function POST(request: NextRequest) { redirectURL.searchParams.set('registered', '1') return NextResponse.redirect(redirectURL, { status: 303 }) } + +function ensureString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +async function extractRegistrationFields(request: NextRequest): Promise { + const contentType = request.headers.get('content-type')?.toLowerCase() ?? '' + + if (contentType.includes('application/json')) { + const body = await request.json().catch((error) => { + console.error('Failed to decode JSON body', error) + throw error + }) + return { + name: ensureString(body?.name ?? ''), + email: ensureString(body?.email ?? ''), + password: ensureString(body?.password ?? ''), + confirmPassword: ensureString(body?.confirmPassword ?? body?.password ?? ''), + } + } + + if (contentType.includes('application/x-www-form-urlencoded')) { + const text = await request.text() + const params = new URLSearchParams(text) + return { + name: ensureString(params.get('name')), + email: ensureString(params.get('email')), + password: ensureString(params.get('password')), + confirmPassword: ensureString(params.get('confirmPassword')), + } + } + + const formData = await request.formData() + const read = (key: string) => ensureString(formData.get(key)) + return { + name: read('name'), + email: read('email'), + password: read('password'), + confirmPassword: read('confirmPassword'), + } +} + +export function GET() { + return NextResponse.json( + { error: 'method_not_allowed' }, + { + status: 405, + headers: { + Allow: 'POST', + }, + }, + ) +}