diff --git a/internal/api/auth/login.go b/internal/api/auth/login.go new file mode 100644 index 0000000..8aab6fd --- /dev/null +++ b/internal/api/auth/login.go @@ -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, + }) +} diff --git a/internal/api/auth/login_test.go b/internal/api/auth/login_test.go new file mode 100644 index 0000000..0140778 --- /dev/null +++ b/internal/api/auth/login_test.go @@ -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"]) +} diff --git a/internal/api/auth/logout.go b/internal/api/auth/logout.go new file mode 100644 index 0000000..f684fd4 --- /dev/null +++ b/internal/api/auth/logout.go @@ -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() +} diff --git a/internal/api/auth/logout_test.go b/internal/api/auth/logout_test.go new file mode 100644 index 0000000..9f49b10 --- /dev/null +++ b/internal/api/auth/logout_test.go @@ -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) +} diff --git a/internal/api/auth/me.go b/internal/api/auth/me.go new file mode 100644 index 0000000..bfeb3a6 --- /dev/null +++ b/internal/api/auth/me.go @@ -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, + }) +} diff --git a/internal/api/auth/me_test.go b/internal/api/auth/me_test.go new file mode 100644 index 0000000..475e93c --- /dev/null +++ b/internal/api/auth/me_test.go @@ -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) +} diff --git a/internal/api/auth/router.go b/internal/api/auth/router.go new file mode 100644 index 0000000..9660705 --- /dev/null +++ b/internal/api/auth/router.go @@ -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)) +} diff --git a/internal/api/auth/setup_test.go b/internal/api/auth/setup_test.go new file mode 100644 index 0000000..4a6f81a --- /dev/null +++ b/internal/api/auth/setup_test.go @@ -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 +} diff --git a/internal/api/auth/signup.go b/internal/api/auth/signup.go new file mode 100644 index 0000000..bbb07af --- /dev/null +++ b/internal/api/auth/signup.go @@ -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(), + } +} diff --git a/internal/api/auth/signup_test.go b/internal/api/auth/signup_test.go new file mode 100644 index 0000000..ddafb33 --- /dev/null +++ b/internal/api/auth/signup_test.go @@ -0,0 +1,48 @@ +package auth_test + +import ( + "net/http" + "testing" + + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" +) + +func TestSignup_success(t *testing.T) { + getAdminState(t) + assert.Equal(t, adminEmail, adminSignupBody["email"]) + assert.NotEmpty(t, adminSignupBody["id"]) +} + +func TestSignup_missingEmail(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.PostStatus("/v1/auth/signup", map[string]any{ + "email": "", + "password": "password123", + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestSignup_shortPassword(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.PostStatus("/v1/auth/signup", map[string]any{ + "email": "short-pw-" + testutil.UniqueID() + "@test.com", + "password": "short", + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestSignup_secondUserForbidden(t *testing.T) { + getAdminState(t) + s := testutil.NewState(t) + + err := s.PostStatus("/v1/auth/signup", map[string]any{ + "email": "second-" + testutil.UniqueID() + "@test.com", + "password": "password123", + }) + testutil.RequireStatus(t, err, http.StatusForbidden) +} diff --git a/internal/api/gittokens/create.go b/internal/api/gittokens/create.go new file mode 100644 index 0000000..120a86f --- /dev/null +++ b/internal/api/gittokens/create.go @@ -0,0 +1,44 @@ +package gittokens + +import ( + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/gittokens" + "go.uber.org/zap" +) + +type CreateRequest struct { + Name string `json:"name"` + Provider string `json:"provider"` + Token string `json:"token"` +} + +func Create(c web.Context) error { + var req CreateRequest + if err := c.Bind(&req); err != nil { + return c.BadRequest("invalid request body") + } + + if req.Name == "" { + return c.BadRequest("name is required") + } + if req.Provider == "" { + return c.BadRequest("provider is required") + } + if req.Token == "" { + return c.BadRequest("token is required") + } + + ctx := c.Request().Context() + + token, err := gittokens.Create(ctx, gittokens.CreateParams{ + Name: req.Name, + Provider: req.Provider, + Token: req.Token, + }) + if err != nil { + c.L.Error("failed to create git token", zap.Error(err)) + return c.InternalError("failed to create git token") + } + + return c.Created(token) +} diff --git a/internal/api/gittokens/create_test.go b/internal/api/gittokens/create_test.go new file mode 100644 index 0000000..5aa88a0 --- /dev/null +++ b/internal/api/gittokens/create_test.go @@ -0,0 +1,90 @@ +package gittokens_test + +import ( + "net/http" + "testing" + + approvals "github.com/approvals/go-approval-tests" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreate_success(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + body, err := s.Post("/v1/gittokens", map[string]any{ + "name": "My Token " + uid, + "provider": "github", + "token": "ghp_test1234567890abcd", + }) + require.NoError(t, err) + assert.Equal(t, "github", body["provider"]) + assert.Equal(t, "My Token "+uid, body["name"]) + assert.Nil(t, body["token"], "raw token must not be exposed") +} + +func TestCreate_missingName(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.PostStatus("/v1/gittokens", map[string]any{ + "name": "", + "provider": "github", + "token": "ghp_test1234567890abcd", + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestCreate_missingProvider(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.PostStatus("/v1/gittokens", map[string]any{ + "name": "Token " + testutil.UniqueID(), + "provider": "", + "token": "ghp_test1234567890abcd", + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestCreate_missingToken(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.PostStatus("/v1/gittokens", map[string]any{ + "name": "Token " + testutil.UniqueID(), + "provider": "github", + "token": "", + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestCreate_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.PostStatus("/v1/gittokens", map[string]any{ + "name": "Token", + "provider": "github", + "token": "ghp_test1234567890abcd", + }) + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} + +// TestCreate_approvals must NOT run in parallel (shared testdata file writes). +func TestCreate_approvals(t *testing.T) { + s := testutil.NewAuthState(t) + + body, err := s.Post("/v1/gittokens", map[string]any{ + "name": "Approvals Token", + "provider": "github", + "token": "ghp_test1234567890abcd", + }) + require.NoError(t, err) + + testutil.ScrubFields(body, "id", "created", "hint") + approvals.VerifyJSONStruct(t, body) +} diff --git a/internal/api/gittokens/delete.go b/internal/api/gittokens/delete.go new file mode 100644 index 0000000..63ed2f7 --- /dev/null +++ b/internal/api/gittokens/delete.go @@ -0,0 +1,29 @@ +package gittokens + +import ( + "errors" + "strconv" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/gittokens" + "go.uber.org/zap" +) + +func Delete(c web.Context) error { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return c.BadRequest("invalid id") + } + + ctx := c.Request().Context() + + if err := gittokens.Delete(ctx, id); err != nil { + if errors.Is(err, gittokens.ErrNotFound) { + return c.NotFound("git token not found") + } + c.L.Error("failed to delete git token", zap.Error(err), zap.Int64("id", id)) + return c.InternalError("failed to delete git token") + } + + return c.NoContent() +} diff --git a/internal/api/gittokens/delete_test.go b/internal/api/gittokens/delete_test.go new file mode 100644 index 0000000..3e3b54c --- /dev/null +++ b/internal/api/gittokens/delete_test.go @@ -0,0 +1,43 @@ +package gittokens_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestDelete_success(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + created, err := s.Post("/v1/gittokens", map[string]any{ + "name": "Delete Token " + uid, + "provider": "github", + "token": "ghp_test1234567890abcd", + }) + require.NoError(t, err) + + id := fmt.Sprintf("%.0f", created["id"].(float64)) + err = s.DeleteStatus("/v1/gittokens/" + id) + testutil.RequireStatus(t, err, http.StatusNoContent) +} + +func TestDelete_notFound(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.DeleteStatus("/v1/gittokens/999999999") + testutil.RequireStatus(t, err, http.StatusNotFound) +} + +func TestDelete_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.DeleteStatus("/v1/gittokens/1") + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} diff --git a/internal/api/gittokens/list.go b/internal/api/gittokens/list.go new file mode 100644 index 0000000..593d11e --- /dev/null +++ b/internal/api/gittokens/list.go @@ -0,0 +1,23 @@ +package gittokens + +import ( + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/gittokens" + "go.uber.org/zap" +) + +type ListResponse struct { + Tokens []gittokens.GitToken `json:"tokens"` +} + +func List(c web.Context) error { + ctx := c.Request().Context() + + tokens, err := gittokens.List(ctx) + if err != nil { + c.L.Error("failed to list git tokens", zap.Error(err)) + return c.InternalError("failed to list git tokens") + } + + return c.OK(ListResponse{Tokens: tokens}) +} diff --git a/internal/api/gittokens/list_test.go b/internal/api/gittokens/list_test.go new file mode 100644 index 0000000..2e33fc2 --- /dev/null +++ b/internal/api/gittokens/list_test.go @@ -0,0 +1,65 @@ +package gittokens_test + +import ( + "net/http" + "testing" + + approvals "github.com/approvals/go-approval-tests" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestList_empty(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + body, err := s.Get("/v1/gittokens") + require.NoError(t, err) + assert.NotNil(t, body["tokens"]) +} + +func TestList_withItems(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + _, err := s.Post("/v1/gittokens", map[string]any{ + "name": "Listed Token " + uid, + "provider": "github", + "token": "ghp_test1234567890abcd", + }) + require.NoError(t, err) + + body, err := s.Get("/v1/gittokens") + require.NoError(t, err) + tokens := body["tokens"].([]any) + assert.NotEmpty(t, tokens) +} + +func TestList_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.GetStatus("/v1/gittokens") + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} + +// TestList_approvals must NOT run in parallel (shared testdata file writes). +func TestList_approvals(t *testing.T) { + s := testutil.NewAuthState(t) + + _, err := s.Post("/v1/gittokens", map[string]any{ + "name": "Approvals List Token", + "provider": "gitlab", + "token": "glpat_test1234567890abcd", + }) + require.NoError(t, err) + + body, err := s.Get("/v1/gittokens") + require.NoError(t, err) + + // Scrub the tokens array since IDs/timestamps/hints are non-deterministic. + body["tokens"] = "[SCRUBBED]" + approvals.VerifyJSONStruct(t, body) +} diff --git a/internal/api/gittokens/router.go b/internal/api/gittokens/router.go new file mode 100644 index 0000000..5726b6e --- /dev/null +++ b/internal/api/gittokens/router.go @@ -0,0 +1,13 @@ +package gittokens + +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.GET("/v1/gittokens", web.WrapAuth(List, l)) + e.POST("/v1/gittokens", web.WrapAuth(Create, l)) + e.DELETE("/v1/gittokens/:id", web.WrapAuth(Delete, l)) +} diff --git a/internal/api/gittokens/setup_test.go b/internal/api/gittokens/setup_test.go new file mode 100644 index 0000000..a6a3d11 --- /dev/null +++ b/internal/api/gittokens/setup_test.go @@ -0,0 +1,17 @@ +package gittokens_test + +import ( + "os" + "testing" + + "github.com/gomantics/semantix/internal/testutil" +) + +func TestMain(m *testing.M) { + main := testutil.Main(m, + testutil.WithPostgres(), + testutil.WithAdminUser(), + testutil.WithApprovals(), + ) + os.Exit(main.Run()) +} diff --git a/internal/api/gittokens/testdata/create_test.TestCreate_approvals.approved.json b/internal/api/gittokens/testdata/create_test.TestCreate_approvals.approved.json new file mode 100644 index 0000000..b04ad0a --- /dev/null +++ b/internal/api/gittokens/testdata/create_test.TestCreate_approvals.approved.json @@ -0,0 +1,7 @@ +{ + "created": "[SCRUBBED]", + "hint": "[SCRUBBED]", + "id": "[SCRUBBED]", + "name": "Approvals Token", + "provider": "github" +} \ No newline at end of file diff --git a/internal/api/gittokens/testdata/list_test.TestList_approvals.approved.json b/internal/api/gittokens/testdata/list_test.TestList_approvals.approved.json new file mode 100644 index 0000000..b383ca6 --- /dev/null +++ b/internal/api/gittokens/testdata/list_test.TestList_approvals.approved.json @@ -0,0 +1,3 @@ +{ + "tokens": "[SCRUBBED]" +} \ No newline at end of file diff --git a/internal/api/repositories/create.go b/internal/api/repositories/create.go new file mode 100644 index 0000000..ada3e5d --- /dev/null +++ b/internal/api/repositories/create.go @@ -0,0 +1,44 @@ +package repositories + +import ( + "strconv" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/repos" + "go.uber.org/zap" +) + +type CreateRequest struct { + URL string `json:"url"` + Branch string `json:"branch,omitempty"` +} + +func Create(c web.Context) error { + wid, err := strconv.ParseInt(c.Param("wid"), 10, 64) + if err != nil { + return c.BadRequest("invalid workspace id") + } + + var req CreateRequest + if err := c.Bind(&req); err != nil { + return c.BadRequest("invalid request body") + } + + if req.URL == "" { + return c.BadRequest("url is required") + } + + ctx := c.Request().Context() + + repo, err := repos.Create(ctx, repos.CreateParams{ + WorkspaceID: wid, + URL: req.URL, + Branch: req.Branch, + }) + if err != nil { + c.L.Error("failed to create repo", zap.Error(err), zap.Int64("wid", wid)) + return c.InternalError("failed to create repo") + } + + return c.Created(repo) +} diff --git a/internal/api/repositories/create_test.go b/internal/api/repositories/create_test.go new file mode 100644 index 0000000..1def12d --- /dev/null +++ b/internal/api/repositories/create_test.go @@ -0,0 +1,71 @@ +package repositories_test + +import ( + "net/http" + "testing" + + approvals "github.com/approvals/go-approval-tests" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreate_success(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + body, err := s.Post("/v1/workspaces/"+wid+"/repos", map[string]any{ + "url": "https://github.com/example/repo-" + testutil.UniqueID(), + "branch": "main", + }) + require.NoError(t, err) + assert.Equal(t, "pending", body["status"]) + assert.Equal(t, "main", body["branch"]) +} + +func TestCreate_missingURL(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + err := s.PostStatus("/v1/workspaces/"+wid+"/repos", map[string]any{ + "url": "", + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestCreate_invalidWorkspaceID(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.PostStatus("/v1/workspaces/not-a-number/repos", map[string]any{ + "url": "https://github.com/example/repo", + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestCreate_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.PostStatus("/v1/workspaces/1/repos", map[string]any{ + "url": "https://github.com/example/repo", + }) + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} + +// TestCreate_approvals must NOT run in parallel (shared testdata file writes). +func TestCreate_approvals(t *testing.T) { + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + body, err := s.Post("/v1/workspaces/"+wid+"/repos", map[string]any{ + "url": "https://github.com/example/approvals-repo", + "branch": "main", + }) + require.NoError(t, err) + + testutil.ScrubFields(body, "id", "workspace_id", "created", "updated") + approvals.VerifyJSONStruct(t, body) +} diff --git a/internal/api/repositories/delete.go b/internal/api/repositories/delete.go new file mode 100644 index 0000000..ddfc855 --- /dev/null +++ b/internal/api/repositories/delete.go @@ -0,0 +1,34 @@ +package repositories + +import ( + "errors" + "strconv" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/repos" + "go.uber.org/zap" +) + +func Delete(c web.Context) error { + _, err := strconv.ParseInt(c.Param("wid"), 10, 64) + if err != nil { + return c.BadRequest("invalid workspace id") + } + + rid, err := strconv.ParseInt(c.Param("rid"), 10, 64) + if err != nil { + return c.BadRequest("invalid repo id") + } + + ctx := c.Request().Context() + + if err := repos.Delete(ctx, rid); err != nil { + if errors.Is(err, repos.ErrNotFound) { + return c.NotFound("repo not found") + } + c.L.Error("failed to delete repo", zap.Error(err), zap.Int64("rid", rid)) + return c.InternalError("failed to delete repo") + } + + return c.NoContent() +} diff --git a/internal/api/repositories/delete_test.go b/internal/api/repositories/delete_test.go new file mode 100644 index 0000000..f1c7944 --- /dev/null +++ b/internal/api/repositories/delete_test.go @@ -0,0 +1,43 @@ +package repositories_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestDelete_success(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + created, err := s.Post("/v1/workspaces/"+wid+"/repos", map[string]any{ + "url": "https://github.com/example/del-repo-" + testutil.UniqueID(), + "branch": "main", + }) + require.NoError(t, err) + + rid := fmt.Sprintf("%.0f", created["id"].(float64)) + err = s.DeleteStatus("/v1/workspaces/" + wid + "/repos/" + rid) + testutil.RequireStatus(t, err, http.StatusNoContent) +} + +func TestDelete_notFound(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + err := s.DeleteStatus("/v1/workspaces/" + wid + "/repos/999999999") + testutil.RequireStatus(t, err, http.StatusNotFound) +} + +func TestDelete_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.DeleteStatus("/v1/workspaces/1/repos/1") + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} diff --git a/internal/api/repositories/get.go b/internal/api/repositories/get.go new file mode 100644 index 0000000..713864e --- /dev/null +++ b/internal/api/repositories/get.go @@ -0,0 +1,35 @@ +package repositories + +import ( + "errors" + "strconv" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/repos" + "go.uber.org/zap" +) + +func Get(c web.Context) error { + _, err := strconv.ParseInt(c.Param("wid"), 10, 64) + if err != nil { + return c.BadRequest("invalid workspace id") + } + + rid, err := strconv.ParseInt(c.Param("rid"), 10, 64) + if err != nil { + return c.BadRequest("invalid repo id") + } + + ctx := c.Request().Context() + + repo, err := repos.GetByID(ctx, rid) + if err != nil { + if errors.Is(err, repos.ErrNotFound) { + return c.NotFound("repo not found") + } + c.L.Error("failed to get repo", zap.Error(err), zap.Int64("rid", rid)) + return c.InternalError("failed to get repo") + } + + return c.OK(repo) +} diff --git a/internal/api/repositories/get_test.go b/internal/api/repositories/get_test.go new file mode 100644 index 0000000..fcb3803 --- /dev/null +++ b/internal/api/repositories/get_test.go @@ -0,0 +1,45 @@ +package repositories_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGet_success(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + created, err := s.Post("/v1/workspaces/"+wid+"/repos", map[string]any{ + "url": "https://github.com/example/get-repo-" + testutil.UniqueID(), + "branch": "main", + }) + require.NoError(t, err) + + rid := fmt.Sprintf("%.0f", created["id"].(float64)) + body, err := s.Get("/v1/workspaces/" + wid + "/repos/" + rid) + require.NoError(t, err) + assert.Equal(t, created["id"], body["id"]) +} + +func TestGet_notFound(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + err := s.GetStatus("/v1/workspaces/" + wid + "/repos/999999999") + testutil.RequireStatus(t, err, http.StatusNotFound) +} + +func TestGet_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.GetStatus("/v1/workspaces/1/repos/1") + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} diff --git a/internal/api/repositories/list.go b/internal/api/repositories/list.go new file mode 100644 index 0000000..def07ac --- /dev/null +++ b/internal/api/repositories/list.go @@ -0,0 +1,41 @@ +package repositories + +import ( + "strconv" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/repos" + "go.uber.org/zap" +) + +type ListResponse struct { + Repos []repos.Repo `json:"repos"` + Total int64 `json:"total"` +} + +func List(c web.Context) error { + wid, err := strconv.ParseInt(c.Param("wid"), 10, 64) + if err != nil { + return c.BadRequest("invalid workspace id") + } + + limit, _ := strconv.Atoi(c.QueryParam("limit")) + offset, _ := strconv.Atoi(c.QueryParam("offset")) + + ctx := c.Request().Context() + + result, err := repos.List(ctx, repos.ListParams{ + WorkspaceID: wid, + Limit: limit, + Offset: offset, + }) + if err != nil { + c.L.Error("failed to list repos", zap.Error(err), zap.Int64("wid", wid)) + return c.InternalError("failed to list repos") + } + + return c.OK(ListResponse{ + Repos: result.Repos, + Total: result.Total, + }) +} diff --git a/internal/api/repositories/list_test.go b/internal/api/repositories/list_test.go new file mode 100644 index 0000000..ff5d2a9 --- /dev/null +++ b/internal/api/repositories/list_test.go @@ -0,0 +1,65 @@ +package repositories_test + +import ( + "net/http" + "testing" + + approvals "github.com/approvals/go-approval-tests" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestList_empty(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + body, err := s.Get("/v1/workspaces/" + wid + "/repos") + require.NoError(t, err) + assert.NotNil(t, body["repos"]) + assert.Equal(t, float64(0), body["total"]) +} + +func TestList_withItems(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + _, err := s.Post("/v1/workspaces/"+wid+"/repos", map[string]any{ + "url": "https://github.com/example/listed-repo-" + testutil.UniqueID(), + "branch": "main", + }) + require.NoError(t, err) + + body, err := s.Get("/v1/workspaces/" + wid + "/repos") + require.NoError(t, err) + assert.Equal(t, float64(1), body["total"]) +} + +func TestList_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.GetStatus("/v1/workspaces/1/repos") + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} + +// TestList_approvals must NOT run in parallel (shared testdata file writes). +func TestList_approvals(t *testing.T) { + s := testutil.NewAuthState(t) + wid := createWorkspace(t, s) + + _, err := s.Post("/v1/workspaces/"+wid+"/repos", map[string]any{ + "url": "https://github.com/example/approvals-list-repo", + "branch": "main", + }) + require.NoError(t, err) + + body, err := s.Get("/v1/workspaces/" + wid + "/repos") + require.NoError(t, err) + + body["repos"] = "[SCRUBBED]" + body["total"] = "[SCRUBBED]" + approvals.VerifyJSONStruct(t, body) +} diff --git a/internal/api/repositories/reindex.go b/internal/api/repositories/reindex.go new file mode 100644 index 0000000..b99cfcb --- /dev/null +++ b/internal/api/repositories/reindex.go @@ -0,0 +1,35 @@ +package repositories + +import ( + "errors" + "strconv" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/repos" + "go.uber.org/zap" +) + +func Reindex(c web.Context) error { + _, err := strconv.ParseInt(c.Param("wid"), 10, 64) + if err != nil { + return c.BadRequest("invalid workspace id") + } + + rid, err := strconv.ParseInt(c.Param("rid"), 10, 64) + if err != nil { + return c.BadRequest("invalid repo id") + } + + ctx := c.Request().Context() + + repo, err := repos.UpdateStatus(ctx, rid, repos.StatusPending, nil) + if err != nil { + if errors.Is(err, repos.ErrNotFound) { + return c.NotFound("repo not found") + } + c.L.Error("failed to trigger reindex", zap.Error(err), zap.Int64("rid", rid)) + return c.InternalError("failed to trigger reindex") + } + + return c.OK(repo) +} diff --git a/internal/api/repositories/router.go b/internal/api/repositories/router.go new file mode 100644 index 0000000..35b99e0 --- /dev/null +++ b/internal/api/repositories/router.go @@ -0,0 +1,16 @@ +package repositories + +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) { + g := e.Group("/v1/workspaces/:wid/repos") + g.GET("", web.WrapAuth(List, l)) + g.POST("", web.WrapAuth(Create, l)) + g.GET("/:rid", web.WrapAuth(Get, l)) + g.DELETE("/:rid", web.WrapAuth(Delete, l)) + g.POST("/:rid/reindex", web.WrapAuth(Reindex, l)) +} diff --git a/internal/api/repositories/setup_test.go b/internal/api/repositories/setup_test.go new file mode 100644 index 0000000..39e3eca --- /dev/null +++ b/internal/api/repositories/setup_test.go @@ -0,0 +1,31 @@ +package repositories_test + +import ( + "fmt" + "os" + "testing" + + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + main := testutil.Main(m, + testutil.WithPostgres(), + testutil.WithAdminUser(), + testutil.WithApprovals(), + ) + os.Exit(main.Run()) +} + +// createWorkspace is a helper that creates a workspace and returns its ID string. +func createWorkspace(t *testing.T, s *testutil.State) string { + t.Helper() + uid := testutil.UniqueID() + body, err := s.Post("/v1/workspaces", map[string]any{ + "name": "Repo Test Workspace " + uid, + "slug": "repo-test-ws-" + uid, + }) + require.NoError(t, err) + return fmt.Sprintf("%.0f", body["id"].(float64)) +} diff --git a/internal/api/repositories/testdata/create_test.TestCreate_approvals.approved.json b/internal/api/repositories/testdata/create_test.TestCreate_approvals.approved.json new file mode 100644 index 0000000..89082ee --- /dev/null +++ b/internal/api/repositories/testdata/create_test.TestCreate_approvals.approved.json @@ -0,0 +1,9 @@ +{ + "branch": "main", + "created": "[SCRUBBED]", + "id": "[SCRUBBED]", + "status": "pending", + "updated": "[SCRUBBED]", + "url": "https://github.com/example/approvals-repo", + "workspace_id": "[SCRUBBED]" +} \ No newline at end of file diff --git a/internal/api/repositories/testdata/list_test.TestList_approvals.approved.json b/internal/api/repositories/testdata/list_test.TestList_approvals.approved.json new file mode 100644 index 0000000..d1c8116 --- /dev/null +++ b/internal/api/repositories/testdata/list_test.TestList_approvals.approved.json @@ -0,0 +1,4 @@ +{ + "repos": "[SCRUBBED]", + "total": "[SCRUBBED]" +} \ No newline at end of file diff --git a/internal/api/run.go b/internal/api/run.go index b1a39cd..ca903ba 100644 --- a/internal/api/run.go +++ b/internal/api/run.go @@ -8,7 +8,12 @@ import ( "time" "github.com/gomantics/semantix/config" + "github.com/gomantics/semantix/internal/api/auth" + "github.com/gomantics/semantix/internal/api/gittokens" "github.com/gomantics/semantix/internal/api/health" + "github.com/gomantics/semantix/internal/api/repositories" + "github.com/gomantics/semantix/internal/api/search" + "github.com/gomantics/semantix/internal/api/workspaces" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "go.uber.org/fx" @@ -117,10 +122,9 @@ func configureMiddleware(e *echo.Echo, l *zap.Logger) { func configureRoutes(e *echo.Echo, l *zap.Logger) { health.Configure(e, l) - - // TODO: Phase 1-3 - Add routes as they are implemented - // workspaces.Configure(e, l) - // gittokens.Configure(e, l) - // repositories.Configure(e, l) - // search.Configure(e, l) + auth.Configure(e, l) + gittokens.Configure(e, l) + workspaces.Configure(e, l) + repositories.Configure(e, l) + search.Configure(e, l) } diff --git a/internal/api/search/router.go b/internal/api/search/router.go new file mode 100644 index 0000000..691887d --- /dev/null +++ b/internal/api/search/router.go @@ -0,0 +1,11 @@ +package search + +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/workspaces/:wid/search", web.WrapAuth(Search, l)) +} diff --git a/internal/api/search/search.go b/internal/api/search/search.go new file mode 100644 index 0000000..008319e --- /dev/null +++ b/internal/api/search/search.go @@ -0,0 +1,34 @@ +package search + +import ( + "net/http" + + "github.com/gomantics/semantix/internal/api/web" +) + +type SearchRequest struct { + Query string `json:"query"` + Limit int `json:"limit,omitempty"` + RepoID *int64 `json:"repo_id,omitempty"` +} + +type SearchResult struct { + RepoID int64 `json:"repo_id"` + FilePath string `json:"file_path"` + Content string `json:"content"` + Language string `json:"language,omitempty"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Score float32 `json:"score"` +} + +type SearchResponse struct { + Results []SearchResult `json:"results"` +} + +// TODO: implement semantic search via Qdrant +func Search(c web.Context) error { + return c.JSON(http.StatusNotImplemented, map[string]string{ + "error": "search not yet implemented", + }) +} diff --git a/internal/api/search/search_test.go b/internal/api/search/search_test.go new file mode 100644 index 0000000..651b85d --- /dev/null +++ b/internal/api/search/search_test.go @@ -0,0 +1,40 @@ +package search_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestSearch_notImplemented(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + // Create a workspace to use as the route parameter. + uid := testutil.UniqueID() + ws, err := s.Post("/v1/workspaces", map[string]any{ + "name": "Search Test Workspace " + uid, + "slug": "search-test-ws-" + uid, + }) + require.NoError(t, err) + wid := fmt.Sprintf("%.0f", ws["id"].(float64)) + + err = s.PostStatus("/v1/workspaces/"+wid+"/search", map[string]any{ + "query": "find something", + "limit": 10, + }) + testutil.RequireStatus(t, err, http.StatusNotImplemented) +} + +func TestSearch_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.PostStatus("/v1/workspaces/1/search", map[string]any{ + "query": "find something", + }) + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} diff --git a/internal/api/search/setup_test.go b/internal/api/search/setup_test.go new file mode 100644 index 0000000..286fa8b --- /dev/null +++ b/internal/api/search/setup_test.go @@ -0,0 +1,16 @@ +package search_test + +import ( + "os" + "testing" + + "github.com/gomantics/semantix/internal/testutil" +) + +func TestMain(m *testing.M) { + main := testutil.Main(m, + testutil.WithPostgres(), + testutil.WithAdminUser(), + ) + os.Exit(main.Run()) +} diff --git a/internal/api/web/context.go b/internal/api/web/context.go index e968b2c..e4e61ee 100644 --- a/internal/api/web/context.go +++ b/internal/api/web/context.go @@ -3,13 +3,16 @@ package web import ( "net/http" + "github.com/gomantics/semantix/internal/domains/users" "github.com/labstack/echo/v4" "go.uber.org/zap" ) type Context struct { echo.Context - L *zap.Logger + L *zap.Logger + UserID int64 + UserEmail string } type HandlerFunc func(ctx Context) error @@ -27,6 +30,31 @@ func Wrap(h HandlerFunc, l *zap.Logger) echo.HandlerFunc { } } +func WrapAuth(h HandlerFunc, l *zap.Logger) echo.HandlerFunc { + return func(c echo.Context) error { + rid := c.Response().Header().Get(echo.HeaderXRequestID) + + cookie, err := c.Cookie("session_token") + if err != nil || cookie.Value == "" { + return echo.NewHTTPError(http.StatusUnauthorized, "unauthenticated") + } + + user, err := users.GetUserByToken(c.Request().Context(), cookie.Value) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "unauthenticated") + } + + ctx := Context{ + Context: c, + L: l.With(zap.String("request_id", rid)), + UserID: user.ID, + UserEmail: user.Email, + } + + return h(ctx) + } +} + func (c Context) Error(status int, message string) error { return c.JSON(status, map[string]string{ "error": message, @@ -41,6 +69,10 @@ func (c Context) NotFound(message string) error { return c.Error(http.StatusNotFound, message) } +func (c Context) Unauthorized(message string) error { + return c.Error(http.StatusUnauthorized, message) +} + func (c Context) InternalError(message string) error { return c.Error(http.StatusInternalServerError, message) } diff --git a/internal/api/workspaces/create.go b/internal/api/workspaces/create.go new file mode 100644 index 0000000..44844a8 --- /dev/null +++ b/internal/api/workspaces/create.go @@ -0,0 +1,48 @@ +package workspaces + +import ( + "errors" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/workspaces" + "go.uber.org/zap" +) + +type CreateRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description *string `json:"description,omitempty"` + Settings map[string]any `json:"settings,omitempty"` +} + +func Create(c web.Context) error { + var req CreateRequest + if err := c.Bind(&req); err != nil { + return c.BadRequest("invalid request body") + } + + if req.Name == "" { + return c.BadRequest("name is required") + } + if req.Slug == "" { + return c.BadRequest("slug is required") + } + + ctx := c.Request().Context() + + ws, err := workspaces.Create(ctx, workspaces.CreateParams{ + Name: req.Name, + Slug: req.Slug, + Description: req.Description, + Settings: req.Settings, + }) + if err != nil { + if errors.Is(err, workspaces.ErrAlreadyExists) { + return c.BadRequest("workspace with this slug already exists") + } + c.L.Error("failed to create workspace", zap.Error(err)) + return c.InternalError("failed to create workspace") + } + + return c.Created(ws) +} diff --git a/internal/api/workspaces/create_test.go b/internal/api/workspaces/create_test.go new file mode 100644 index 0000000..066580b --- /dev/null +++ b/internal/api/workspaces/create_test.go @@ -0,0 +1,96 @@ +package workspaces_test + +import ( + "net/http" + "testing" + + approvals "github.com/approvals/go-approval-tests" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreate_success(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + body, err := s.Post("/v1/workspaces", map[string]any{ + "name": "Test Workspace " + uid, + "slug": "test-ws-" + uid, + }) + require.NoError(t, err) + assert.Equal(t, "test-ws-"+uid, body["slug"]) + assert.Equal(t, "Test Workspace "+uid, body["name"]) +} + +func TestCreate_missingName(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.PostStatus("/v1/workspaces", map[string]any{ + "name": "", + "slug": "some-slug-" + testutil.UniqueID(), + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestCreate_missingSlug(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.PostStatus("/v1/workspaces", map[string]any{ + "name": "Test Workspace", + "slug": "", + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestCreate_duplicateSlug(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + slug := "dup-slug-" + uid + + _, err := s.Post("/v1/workspaces", map[string]any{ + "name": "First " + uid, + "slug": slug, + }) + require.NoError(t, err) + + err = s.PostStatus("/v1/workspaces", map[string]any{ + "name": "Second " + uid, + "slug": slug, + }) + testutil.RequireStatus(t, err, http.StatusBadRequest) +} + +func TestCreate_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.PostStatus("/v1/workspaces", map[string]any{ + "name": "Test", + "slug": "test-" + testutil.UniqueID(), + }) + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} + +// TestCreate_approvals must NOT run in parallel (shared testdata file writes). +func TestCreate_approvals(t *testing.T) { + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + body, err := s.Post("/v1/workspaces", map[string]any{ + "name": "Approvals Workspace " + uid, + "slug": "approvals-ws-" + uid, + }) + require.NoError(t, err) + + testutil.ScrubFields(body, "id", "created", "updated") + // Scrub slug/name since they contain the non-deterministic uid. + body["slug"] = "[SCRUBBED]" + body["name"] = "[SCRUBBED]" + approvals.VerifyJSONStruct(t, body) +} diff --git a/internal/api/workspaces/delete.go b/internal/api/workspaces/delete.go new file mode 100644 index 0000000..705f695 --- /dev/null +++ b/internal/api/workspaces/delete.go @@ -0,0 +1,29 @@ +package workspaces + +import ( + "errors" + "strconv" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/workspaces" + "go.uber.org/zap" +) + +func Delete(c web.Context) error { + wid, err := strconv.ParseInt(c.Param("wid"), 10, 64) + if err != nil { + return c.BadRequest("invalid workspace id") + } + + ctx := c.Request().Context() + + if err := workspaces.Delete(ctx, wid); err != nil { + if errors.Is(err, workspaces.ErrNotFound) { + return c.NotFound("workspace not found") + } + c.L.Error("failed to delete workspace", zap.Error(err), zap.Int64("wid", wid)) + return c.InternalError("failed to delete workspace") + } + + return c.NoContent() +} diff --git a/internal/api/workspaces/delete_test.go b/internal/api/workspaces/delete_test.go new file mode 100644 index 0000000..ac3208d --- /dev/null +++ b/internal/api/workspaces/delete_test.go @@ -0,0 +1,42 @@ +package workspaces_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/require" +) + +func TestDelete_success(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + created, err := s.Post("/v1/workspaces", map[string]any{ + "name": "Delete Workspace " + uid, + "slug": "del-ws-" + uid, + }) + require.NoError(t, err) + + wid := fmt.Sprintf("%.0f", created["id"].(float64)) + err = s.DeleteStatus("/v1/workspaces/" + wid) + testutil.RequireStatus(t, err, http.StatusNoContent) +} + +func TestDelete_notFound(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.DeleteStatus("/v1/workspaces/999999999") + testutil.RequireStatus(t, err, http.StatusNotFound) +} + +func TestDelete_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.DeleteStatus("/v1/workspaces/1") + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} diff --git a/internal/api/workspaces/get.go b/internal/api/workspaces/get.go new file mode 100644 index 0000000..074b5ef --- /dev/null +++ b/internal/api/workspaces/get.go @@ -0,0 +1,30 @@ +package workspaces + +import ( + "errors" + "strconv" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/workspaces" + "go.uber.org/zap" +) + +func Get(c web.Context) error { + wid, err := strconv.ParseInt(c.Param("wid"), 10, 64) + if err != nil { + return c.BadRequest("invalid workspace id") + } + + ctx := c.Request().Context() + + ws, err := workspaces.GetByID(ctx, wid) + if err != nil { + if errors.Is(err, workspaces.ErrNotFound) { + return c.NotFound("workspace not found") + } + c.L.Error("failed to get workspace", zap.Error(err), zap.Int64("wid", wid)) + return c.InternalError("failed to get workspace") + } + + return c.OK(ws) +} diff --git a/internal/api/workspaces/get_test.go b/internal/api/workspaces/get_test.go new file mode 100644 index 0000000..2da194b --- /dev/null +++ b/internal/api/workspaces/get_test.go @@ -0,0 +1,44 @@ +package workspaces_test + +import ( + "fmt" + "net/http" + "testing" + + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGet_success(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + created, err := s.Post("/v1/workspaces", map[string]any{ + "name": "Get Workspace " + uid, + "slug": "get-ws-" + uid, + }) + require.NoError(t, err) + + wid := fmt.Sprintf("%.0f", created["id"].(float64)) + body, err := s.Get("/v1/workspaces/" + wid) + require.NoError(t, err) + assert.Equal(t, created["id"], body["id"]) +} + +func TestGet_notFound(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.GetStatus("/v1/workspaces/999999999") + testutil.RequireStatus(t, err, http.StatusNotFound) +} + +func TestGet_invalidID(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + err := s.GetStatus("/v1/workspaces/not-a-number") + testutil.RequireStatus(t, err, http.StatusBadRequest) +} diff --git a/internal/api/workspaces/list.go b/internal/api/workspaces/list.go new file mode 100644 index 0000000..df7c0d5 --- /dev/null +++ b/internal/api/workspaces/list.go @@ -0,0 +1,35 @@ +package workspaces + +import ( + "strconv" + + "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/domains/workspaces" + "go.uber.org/zap" +) + +type ListResponse struct { + Workspaces []workspaces.Workspace `json:"workspaces"` + Total int64 `json:"total"` +} + +func List(c web.Context) error { + limit, _ := strconv.Atoi(c.QueryParam("limit")) + offset, _ := strconv.Atoi(c.QueryParam("offset")) + + ctx := c.Request().Context() + + result, err := workspaces.List(ctx, workspaces.ListParams{ + Limit: limit, + Offset: offset, + }) + if err != nil { + c.L.Error("failed to list workspaces", zap.Error(err)) + return c.InternalError("failed to list workspaces") + } + + return c.OK(ListResponse{ + Workspaces: result.Workspaces, + Total: result.Total, + }) +} diff --git a/internal/api/workspaces/list_test.go b/internal/api/workspaces/list_test.go new file mode 100644 index 0000000..8c82dbd --- /dev/null +++ b/internal/api/workspaces/list_test.go @@ -0,0 +1,64 @@ +package workspaces_test + +import ( + "net/http" + "testing" + + approvals "github.com/approvals/go-approval-tests" + "github.com/gomantics/semantix/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestList_empty(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + body, err := s.Get("/v1/workspaces") + require.NoError(t, err) + assert.NotNil(t, body["workspaces"]) +} + +func TestList_withItems(t *testing.T) { + t.Parallel() + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + _, err := s.Post("/v1/workspaces", map[string]any{ + "name": "Listed Workspace " + uid, + "slug": "listed-ws-" + uid, + }) + require.NoError(t, err) + + body, err := s.Get("/v1/workspaces") + require.NoError(t, err) + assert.NotNil(t, body["total"]) +} + +func TestList_requiresAuth(t *testing.T) { + t.Parallel() + s := testutil.NewState(t) + + err := s.GetStatus("/v1/workspaces") + testutil.RequireStatus(t, err, http.StatusUnauthorized) +} + +// TestList_approvals must NOT run in parallel (shared testdata file writes). +func TestList_approvals(t *testing.T) { + s := testutil.NewAuthState(t) + + uid := testutil.UniqueID() + _, err := s.Post("/v1/workspaces", map[string]any{ + "name": "Approvals List Workspace " + uid, + "slug": "approvals-list-ws-" + uid, + }) + require.NoError(t, err) + + body, err := s.Get("/v1/workspaces") + require.NoError(t, err) + + // Scrub the nested workspaces array to avoid non-deterministic ids/timestamps. + body["workspaces"] = "[SCRUBBED]" + body["total"] = "[SCRUBBED]" + approvals.VerifyJSONStruct(t, body) +} diff --git a/internal/api/workspaces/router.go b/internal/api/workspaces/router.go new file mode 100644 index 0000000..ed428f9 --- /dev/null +++ b/internal/api/workspaces/router.go @@ -0,0 +1,14 @@ +package workspaces + +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.GET("/v1/workspaces", web.WrapAuth(List, l)) + e.POST("/v1/workspaces", web.WrapAuth(Create, l)) + e.GET("/v1/workspaces/:wid", web.WrapAuth(Get, l)) + e.DELETE("/v1/workspaces/:wid", web.WrapAuth(Delete, l)) +} diff --git a/internal/api/workspaces/setup_test.go b/internal/api/workspaces/setup_test.go new file mode 100644 index 0000000..2c522fd --- /dev/null +++ b/internal/api/workspaces/setup_test.go @@ -0,0 +1,17 @@ +package workspaces_test + +import ( + "os" + "testing" + + "github.com/gomantics/semantix/internal/testutil" +) + +func TestMain(m *testing.M) { + main := testutil.Main(m, + testutil.WithPostgres(), + testutil.WithAdminUser(), + testutil.WithApprovals(), + ) + os.Exit(main.Run()) +} diff --git a/internal/api/workspaces/testdata/create_test.TestCreate_approvals.approved.json b/internal/api/workspaces/testdata/create_test.TestCreate_approvals.approved.json new file mode 100644 index 0000000..483502a --- /dev/null +++ b/internal/api/workspaces/testdata/create_test.TestCreate_approvals.approved.json @@ -0,0 +1,8 @@ +{ + "created": "[SCRUBBED]", + "id": "[SCRUBBED]", + "name": "[SCRUBBED]", + "settings": {}, + "slug": "[SCRUBBED]", + "updated": "[SCRUBBED]" +} \ No newline at end of file diff --git a/internal/api/workspaces/testdata/list_test.TestList_approvals.approved.json b/internal/api/workspaces/testdata/list_test.TestList_approvals.approved.json new file mode 100644 index 0000000..e72d3f3 --- /dev/null +++ b/internal/api/workspaces/testdata/list_test.TestList_approvals.approved.json @@ -0,0 +1,4 @@ +{ + "total": "[SCRUBBED]", + "workspaces": "[SCRUBBED]" +} \ No newline at end of file diff --git a/internal/db/auth.sql.go b/internal/db/auth.sql.go new file mode 100644 index 0000000..826b3b5 --- /dev/null +++ b/internal/db/auth.sql.go @@ -0,0 +1,152 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: auth.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countUsers = `-- name: CountUsers :one +SELECT COUNT(*) +FROM users +` + +func (q *Queries) CountUsers(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countUsers) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createSession = `-- name: CreateSession :one +INSERT INTO sessions (user_id, token, created, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, token, created, expires_at +` + +type CreateSessionParams struct { + UserID int64 `json:"user_id"` + Token string `json:"token"` + Created int64 `json:"created"` + ExpiresAt pgtype.Int8 `json:"expires_at"` +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { + row := q.db.QueryRow(ctx, createSession, + arg.UserID, + arg.Token, + arg.Created, + arg.ExpiresAt, + ) + var i Session + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.Created, + &i.ExpiresAt, + ) + return i, err +} + +const createUser = `-- name: CreateUser :one +INSERT INTO users (email, password_hash, created, updated) +VALUES ($1, $2, $3, $4) +RETURNING id, email, password_hash, created, updated +` + +type CreateUserParams struct { + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, + arg.Email, + arg.PasswordHash, + arg.Created, + arg.Updated, + ) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Created, + &i.Updated, + ) + return i, err +} + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token = $1 +` + +func (q *Queries) DeleteSession(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, deleteSession, token) + return err +} + +const getSessionByToken = `-- name: GetSessionByToken :one +SELECT s.id, s.user_id, s.token, s.created, s.expires_at, + u.email +FROM sessions s +JOIN users u ON u.id = s.user_id +WHERE s.token = $1 + AND (s.expires_at IS NULL OR s.expires_at > $2) +` + +type GetSessionByTokenParams struct { + Token string `json:"token"` + Now pgtype.Int8 `json:"now"` +} + +type GetSessionByTokenRow struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Token string `json:"token"` + Created int64 `json:"created"` + ExpiresAt pgtype.Int8 `json:"expires_at"` + Email string `json:"email"` +} + +func (q *Queries) GetSessionByToken(ctx context.Context, arg GetSessionByTokenParams) (GetSessionByTokenRow, error) { + row := q.db.QueryRow(ctx, getSessionByToken, arg.Token, arg.Now) + var i GetSessionByTokenRow + err := row.Scan( + &i.ID, + &i.UserID, + &i.Token, + &i.Created, + &i.ExpiresAt, + &i.Email, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, password_hash, created, updated +FROM users +WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Created, + &i.Updated, + ) + return i, err +} diff --git a/internal/db/migrations/00001_initial_schema.sql b/internal/db/migrations/00001_initial_schema.sql index 5e13891..3c45161 100644 --- a/internal/db/migrations/00001_initial_schema.sql +++ b/internal/db/migrations/00001_initial_schema.sql @@ -2,6 +2,25 @@ -- Safe to re-run: yes (all statements use IF NOT EXISTS) -- +goose Up +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created BIGINT NOT NULL, -- nanoseconds since epoch + updated BIGINT NOT NULL -- nanoseconds since epoch +); + +CREATE TABLE IF NOT EXISTS sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token TEXT NOT NULL UNIQUE, -- 32 random bytes, hex-encoded (64 chars) + created BIGINT NOT NULL, -- nanoseconds since epoch + expires_at BIGINT -- nanoseconds since epoch, NULL = never expires +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + CREATE TABLE IF NOT EXISTS workspaces ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, @@ -75,3 +94,5 @@ DROP TABLE IF EXISTS files; DROP TABLE IF EXISTS repos; DROP TABLE IF EXISTS git_tokens; DROP TABLE IF EXISTS workspaces; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; diff --git a/internal/db/models.go b/internal/db/models.go index c839c50..bfadf02 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -53,6 +53,22 @@ type Repo struct { Updated int64 `json:"updated"` } +type Session struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Token string `json:"token"` + Created int64 `json:"created"` + ExpiresAt pgtype.Int8 `json:"expires_at"` +} + +type User struct { + ID int64 `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` +} + type Workspace struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/internal/db/querier.go b/internal/db/querier.go index 50c5d10..e463a74 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -11,11 +11,14 @@ import ( type Querier interface { CountFilesByRepo(ctx context.Context, repoID int64) (int64, error) CountReposByWorkspace(ctx context.Context, workspaceID int64) (int64, error) + CountUsers(ctx context.Context) (int64, error) CountWorkspaces(ctx context.Context) (int64, error) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) CreateGitToken(ctx context.Context, arg CreateGitTokenParams) (CreateGitTokenRow, error) CreateIndexRun(ctx context.Context, arg CreateIndexRunParams) (IndexRun, error) CreateRepo(ctx context.Context, arg CreateRepoParams) (Repo, error) + CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) + CreateUser(ctx context.Context, arg CreateUserParams) (User, error) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) DeleteFile(ctx context.Context, id int64) error DeleteFilesByPaths(ctx context.Context, arg DeleteFilesByPathsParams) error @@ -24,12 +27,15 @@ type Querier interface { DeleteIndexRunsByRepo(ctx context.Context, repoID int64) error DeleteRepo(ctx context.Context, id int64) error DeleteReposByWorkspace(ctx context.Context, workspaceID int64) error + DeleteSession(ctx context.Context, token string) error DeleteWorkspace(ctx context.Context, id int64) error GetFileByID(ctx context.Context, id int64) (File, error) GetFileByRepoAndPath(ctx context.Context, arg GetFileByRepoAndPathParams) (File, error) GetGitTokenByID(ctx context.Context, id int64) (GetGitTokenByIDRow, error) GetIndexRunByID(ctx context.Context, id int64) (IndexRun, error) GetRepoByID(ctx context.Context, id int64) (Repo, error) + GetSessionByToken(ctx context.Context, arg GetSessionByTokenParams) (GetSessionByTokenRow, error) + GetUserByEmail(ctx context.Context, email string) (User, error) GetWorkspaceByID(ctx context.Context, id int64) (Workspace, error) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspace, error) ListFilesByRepo(ctx context.Context, repoID int64) ([]File, error) diff --git a/internal/db/queries/auth.sql b/internal/db/queries/auth.sql new file mode 100644 index 0000000..47dcb28 --- /dev/null +++ b/internal/db/queries/auth.sql @@ -0,0 +1,30 @@ +-- name: CreateUser :one +INSERT INTO users (email, password_hash, created, updated) +VALUES ($1, $2, $3, $4) +RETURNING id, email, password_hash, created, updated; + +-- name: GetUserByEmail :one +SELECT id, email, password_hash, created, updated +FROM users +WHERE email = $1; + +-- name: CountUsers :one +SELECT COUNT(*) +FROM users; + +-- name: CreateSession :one +INSERT INTO sessions (user_id, token, created, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING id, user_id, token, created, expires_at; + +-- name: GetSessionByToken :one +SELECT s.id, s.user_id, s.token, s.created, s.expires_at, + u.email +FROM sessions s +JOIN users u ON u.id = s.user_id +WHERE s.token = $1 + AND (s.expires_at IS NULL OR s.expires_at > sqlc.arg(now)); + +-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token = $1; diff --git a/internal/domains/gittokens/gittokens.go b/internal/domains/gittokens/gittokens.go index b9bf039..eba3ffb 100644 --- a/internal/domains/gittokens/gittokens.go +++ b/internal/domains/gittokens/gittokens.go @@ -61,6 +61,21 @@ func FindForProvider(ctx context.Context, provider gitrepo.Provider) (*GitToken, return toGitToken(r.ID, r.Name, r.Provider, string(r.TokenEncrypted), r.Created), nil } +func List(ctx context.Context) ([]GitToken, error) { + rows, err := db.Query1(ctx, func(q *db.Queries) ([]db.ListGitTokensRow, error) { + return q.ListGitTokens(ctx) + }) + if err != nil { + return nil, err + } + + tokens := make([]GitToken, len(rows)) + for i, r := range rows { + tokens[i] = *toGitToken(r.ID, r.Name, r.Provider, string(r.TokenEncrypted), r.Created) + } + return tokens, nil +} + func Delete(ctx context.Context, id int64) error { return db.Tx(ctx, func(q *db.Queries) error { _, err := q.GetGitTokenByID(ctx, id) diff --git a/internal/domains/gittokens/models.go b/internal/domains/gittokens/models.go index 548b0ba..f29e437 100644 --- a/internal/domains/gittokens/models.go +++ b/internal/domains/gittokens/models.go @@ -1,6 +1,5 @@ package gittokens -// GitToken represents a stored access token for a git provider. type GitToken struct { ID int64 `json:"id"` Name string `json:"name"` diff --git a/internal/domains/users/models.go b/internal/domains/users/models.go new file mode 100644 index 0000000..4d3566d --- /dev/null +++ b/internal/domains/users/models.go @@ -0,0 +1,13 @@ +package users + +type User struct { + ID int64 + Email string + Created int64 + Updated int64 +} + +type CreateParams struct { + Email string + Password string +} diff --git a/internal/domains/users/users.go b/internal/domains/users/users.go new file mode 100644 index 0000000..7b449b8 --- /dev/null +++ b/internal/domains/users/users.go @@ -0,0 +1,167 @@ +package users + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "time" + + "github.com/gomantics/semantix/internal/db" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrNotFound = errors.New("user not found") + ErrAlreadyExists = errors.New("user with this email already exists") + ErrInvalidCreds = errors.New("invalid email or password") + ErrSessionNotFound = errors.New("session not found") + ErrAdminExists = errors.New("admin user already exists") +) + +func Count(ctx context.Context) (int64, error) { + return db.Query1(ctx, func(q *db.Queries) (int64, error) { + return q.CountUsers(ctx) + }) +} + +func Create(ctx context.Context, params CreateParams) (*User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + now := time.Now().UnixNano() + dbUser, err := db.Tx1(ctx, func(q *db.Queries) (db.User, error) { + _, err := q.GetUserByEmail(ctx, params.Email) + if err == nil { + return db.User{}, ErrAlreadyExists + } + if !errors.Is(err, pgx.ErrNoRows) { + return db.User{}, err + } + + return q.CreateUser(ctx, db.CreateUserParams{ + Email: params.Email, + PasswordHash: string(hash), + Created: now, + Updated: now, + }) + }) + if err != nil { + return nil, err + } + + return toUser(dbUser), nil +} + +// CreateFirst atomically creates the first (admin) user. Returns ErrAdminExists +// if any user already exists. The count check and insert run in a single +// transaction to avoid TOCTOU races. +func CreateFirst(ctx context.Context, params CreateParams) (*User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + now := time.Now().UnixNano() + dbUser, err := db.Tx1(ctx, func(q *db.Queries) (db.User, error) { + count, err := q.CountUsers(ctx) + if err != nil { + return db.User{}, err + } + if count > 0 { + return db.User{}, ErrAdminExists + } + + return q.CreateUser(ctx, db.CreateUserParams{ + Email: params.Email, + PasswordHash: string(hash), + Created: now, + Updated: now, + }) + }) + if err != nil { + return nil, err + } + + return toUser(dbUser), nil +} + +func Login(ctx context.Context, email, password string) (*User, error) { + dbUser, err := db.Query1(ctx, func(q *db.Queries) (db.User, error) { + return q.GetUserByEmail(ctx, email) + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidCreds + } + return nil, err + } + + if err := bcrypt.CompareHashAndPassword([]byte(dbUser.PasswordHash), []byte(password)); err != nil { + return nil, ErrInvalidCreds + } + + return toUser(dbUser), nil +} + +func CreateSession(ctx context.Context, userID int64) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := hex.EncodeToString(raw) + + now := time.Now().UnixNano() + _, err := db.Tx1(ctx, func(q *db.Queries) (db.Session, error) { + return q.CreateSession(ctx, db.CreateSessionParams{ + UserID: userID, + Token: token, + Created: now, + ExpiresAt: pgtype.Int8{Valid: false}, // never expires + }) + }) + if err != nil { + return "", err + } + + return token, nil +} + +func GetUserByToken(ctx context.Context, token string) (*User, error) { + row, err := db.Query1(ctx, func(q *db.Queries) (db.GetSessionByTokenRow, error) { + return q.GetSessionByToken(ctx, db.GetSessionByTokenParams{ + Token: token, + Now: pgtype.Int8{Int64: time.Now().UnixNano(), Valid: true}, + }) + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrSessionNotFound + } + return nil, err + } + + return &User{ + ID: row.UserID, + Email: row.Email, + }, nil +} + +func DeleteSession(ctx context.Context, token string) error { + return db.Tx(ctx, func(q *db.Queries) error { + return q.DeleteSession(ctx, token) + }) +} + +func toUser(u db.User) *User { + return &User{ + ID: u.ID, + Email: u.Email, + Created: u.Created, + Updated: u.Updated, + } +} diff --git a/internal/libs/gitrepo/setup_test.go b/internal/libs/gitrepo/setup_test.go new file mode 100644 index 0000000..f12ab1a --- /dev/null +++ b/internal/libs/gitrepo/setup_test.go @@ -0,0 +1,12 @@ +package gitrepo + +import ( + "os" + "testing" + + _ "github.com/gomantics/semantix/internal/testflags" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/internal/testflags/testflags.go b/internal/testflags/testflags.go new file mode 100644 index 0000000..0dd7dc8 --- /dev/null +++ b/internal/testflags/testflags.go @@ -0,0 +1,11 @@ +// Package testflags registers shared test flags. +package testflags + +import "flag" + +// AcceptChanges is true when -accept-changes is passed to go test. +var AcceptChanges bool + +func init() { + flag.BoolVar(&AcceptChanges, "accept-changes", false, "automatically accept approval test snapshots") +} diff --git a/internal/testutil/admin.go b/internal/testutil/admin.go new file mode 100644 index 0000000..ec38583 --- /dev/null +++ b/internal/testutil/admin.go @@ -0,0 +1,57 @@ +package testutil + +import ( + "context" + "fmt" + "time" + + "github.com/gomantics/semantix/internal/db" + "golang.org/x/crypto/bcrypt" +) + +// AdminCreds holds the email and password of the single admin user created +// by WithAdminUser. Tests that need an authenticated session should use these +// credentials via NewAuthState, which logs in using them. +var AdminCreds struct { + Email string + Password string +} + +// WithAdminUser returns an Option that creates the single admin user directly +// in the database (bypassing the signup endpoint's one-user guard). It must +// be composed after WithPostgres() so the DB pool is ready. +func WithAdminUser() Option { + return func() Teardown { + return withAdminUser() + } +} + +func withAdminUser() Teardown { + ctx := context.Background() + + email := fmt.Sprintf("admin-%s@test.com", UniqueID()) + password := "testpassword123" + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + if err != nil { + panic(fmt.Sprintf("testutil: hash admin password: %v", err)) + } + + now := time.Now().UnixNano() + _, err = db.Tx1(ctx, func(q *db.Queries) (db.User, error) { + return q.CreateUser(ctx, db.CreateUserParams{ + Email: email, + PasswordHash: string(hash), + Created: now, + Updated: now, + }) + }) + if err != nil { + panic(fmt.Sprintf("testutil: create admin user: %v", err)) + } + + AdminCreds.Email = email + AdminCreds.Password = password + + return func() {} +} diff --git a/internal/testutil/approvals.go b/internal/testutil/approvals.go index c9cfa4c..05ff60c 100644 --- a/internal/testutil/approvals.go +++ b/internal/testutil/approvals.go @@ -6,6 +6,7 @@ import ( approvals "github.com/approvals/go-approval-tests" "github.com/approvals/go-approval-tests/reporters" + "github.com/gomantics/semantix/internal/testflags" ) // WithApprovals returns an Option that configures the go-approval-tests reporter @@ -21,8 +22,10 @@ func withApprovals() Teardown { approvals.UseFolder("testdata") var closer io.Closer - if AcceptChanges { + if testflags.AcceptChanges { closer = approvals.UseFrontLoadedReporter(reporters.NewReporterThatAutomaticallyApproves()) + } else { + closer = approvals.UseReporter(reporters.NewQuietReporter()) } return func() { diff --git a/internal/testutil/http.go b/internal/testutil/http.go index a6e3588..8cb16ec 100644 --- a/internal/testutil/http.go +++ b/internal/testutil/http.go @@ -9,8 +9,12 @@ import ( "net/http/httptest" "testing" + "github.com/gomantics/semantix/internal/api/auth" + "github.com/gomantics/semantix/internal/api/gittokens" "github.com/gomantics/semantix/internal/api/health" - "github.com/gomantics/semantix/internal/api/web" + "github.com/gomantics/semantix/internal/api/repositories" + "github.com/gomantics/semantix/internal/api/search" + "github.com/gomantics/semantix/internal/api/workspaces" "github.com/labstack/echo/v4" "go.uber.org/zap" ) @@ -29,6 +33,7 @@ func (e *StatusError) Error() string { type State struct { t *testing.T server *echo.Echo + cookie *http.Cookie } // NewState creates a State backed by a minimal Echo server with all routes registered. @@ -41,25 +46,72 @@ func NewState(t *testing.T) *State { e.HidePort = true health.Configure(e, l) + auth.Configure(e, l) + workspaces.Configure(e, l) + gittokens.Configure(e, l) + repositories.Configure(e, l) + search.Configure(e, l) return &State{t: t, server: e} } -// Get performs an authenticated GET request against the test server. +// NewAuthState creates a State with a session cookie pre-set by logging in as +// the admin user created by WithAdminUser(). Tests using this helper must +// include WithAdminUser() in their TestMain options. +func NewAuthState(t *testing.T) *State { + t.Helper() + + s := NewState(t) + + _, err := s.Post("/v1/auth/login", map[string]any{ + "email": AdminCreds.Email, + "password": AdminCreds.Password, + }) + if err != nil { + t.Fatalf("NewAuthState: login failed: %v", err) + } + + if s.cookie == nil { + t.Fatal("NewAuthState: no session cookie set after login") + } + + return s +} + +// Get performs a GET request against the test server. func (s *State) Get(path string) (map[string]any, error) { return s.do(http.MethodGet, path, nil) } +// GetStatus performs a GET request and returns only the error (useful for +// asserting non-2xx status codes without caring about the body). +func (s *State) GetStatus(path string) error { + _, err := s.do(http.MethodGet, path, nil) + return err +} + // Post performs a POST request with a JSON body. func (s *State) Post(path string, body any) (map[string]any, error) { return s.do(http.MethodPost, path, body) } +// PostStatus performs a POST request and returns only the error. +func (s *State) PostStatus(path string, body any) error { + _, err := s.do(http.MethodPost, path, body) + return err +} + // Put performs a PUT request with a JSON body. func (s *State) Put(path string, body any) (map[string]any, error) { return s.do(http.MethodPut, path, body) } +// DeleteStatus performs a DELETE request and returns only the error. +func (s *State) DeleteStatus(path string) error { + _, err := s.do(http.MethodDelete, path, nil) + return err +} + // Delete performs a DELETE request. func (s *State) Delete(path string) (map[string]any, error) { return s.do(http.MethodDelete, path, nil) @@ -78,17 +130,31 @@ func (s *State) do(method, path string, body any) (map[string]any, error) { } req := httptest.NewRequest(method, path, bodyReader) - if body != nil { - req.Header.Set(echo.MIMEApplicationJSON, "application/json") - } req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + if s.cookie != nil { + req.AddCookie(s.cookie) + } + rec := httptest.NewRecorder() s.server.ServeHTTP(rec, req) resp := rec.Result() defer resp.Body.Close() + // Capture session cookie from signup/login responses. + for _, c := range resp.Cookies() { + if c.Name == "session_token" { + if c.MaxAge >= 0 && c.Value != "" { + s.cookie = c + } else { + // MaxAge < 0 means cookie was cleared (logout). + s.cookie = nil + } + break + } + } + rawBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response body: %w", err) @@ -129,8 +195,3 @@ func RequireStatus(t *testing.T, err error, expectedCode int) { t.Fatalf("expected status %d, got %d: %s", expectedCode, se.Code, se.Body) } } - -// Wrap adapts a web.HandlerFunc into the echo handler format for direct testing. -func Wrap(h web.HandlerFunc, l *zap.Logger) echo.HandlerFunc { - return web.Wrap(h, l) -} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 5eedb86..5430fde 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -3,18 +3,10 @@ package testutil import ( - "flag" "testing" "time" ) -// AcceptChanges is a flag that, when set, auto-approves all snapshot diffs. -var AcceptChanges bool - -func init() { - flag.BoolVar(&AcceptChanges, "accept-changes", false, "automatically accept approval test snapshots") -} - // Teardown is a cleanup function returned by an Option. type Teardown func()