Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Every request requires `Authorization: Bearer <ADMIN_TOKEN>`. Responses are enve
| -------- | ------------------------------ | -------------------- | ------------------------------------------------------------ |
| `POST` | `/users` | `{username, kind}` | Create a user/service account (`kind`: `user` \| `service`); `409 user_exists` if the username is taken. |
| `GET` | `/users/{id}` | — | Get an account. |
| `GET` | `/users/by-username/{username}` | — | Look up an account by username (name -> id resolution). |
| `GET` | `/users/{id}/repositories` | — | List repositories owned by the account. |
| `POST` | `/users/{id}/ssh-keys` | `{title, publicKey}` | Register an SSH public key. |
| `GET` | `/users/{id}/ssh-keys` | — | List the account's SSH keys. |
Expand All @@ -135,12 +136,14 @@ Every request requires `Authorization: Bearer <ADMIN_TOKEN>`. Responses are enve
| -------- | --------------------------------------------- | ----------------------------- | ----------------------------------------------------------------- |
| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`); `409 repository_exists` if the owner already has one with that name. |
| `GET` | `/repositories/{id}` | — | Get repository metadata. |
| `GET` | `/repositories/by-path/{namespace}/{name}` | — | Look up a repository by owner username + name (name -> id resolution). |
| `PUT` | `/repositories/{id}/visibility` | `{visibility}` | Change visibility (`public` \| `private`). |
| `DELETE` | `/repositories/{id}` | — | Delete a repository (row + bare repo). |
| `GET` | `/repositories/{id}/permissions` | — | List collaborators. |
| `PUT` | `/repositories/{id}/permissions` | `{userId, role}` | Grant/update a collaborator role (`read` \| `write` \| `admin`). |
| `DELETE` | `/repositories/{id}/permissions/{userId}` | — | Revoke a collaborator. |
| `POST` | `/repositories/{id}/webhooks` | `{url}` | Register a push webhook; the signing secret is returned **once**. `409 webhook_exists` if the URL is already registered on the repo. |
| `GET` | `/repositories/{id}/webhooks` | — | List the repository's webhooks (never the secret). |
| `DELETE` | `/repositories/{id}/webhooks/{hookId}` | — | Delete a webhook. |
| `GET` | `/repositories/{id}/path-policies` | — | List the repository's path policies. |
| `POST` | `/repositories/{id}/path-policies` | `{pattern, reason?}` | Block a path; see [Path policies](#path-policies). |
Expand Down
19 changes: 19 additions & 0 deletions internal/db/gen/users.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions internal/db/queries/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
select * from users
where id=? limit 1;

-- name: GetUserByUsername :one
select * from users
where username=? limit 1;

-- name: CreateUser :one
insert into users (
username, kind
Expand Down
18 changes: 18 additions & 0 deletions internal/server/control/repositories/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,21 @@ func (h *handlers) getRepository(w http.ResponseWriter, r *http.Request) error {

return response.Data(w, http.StatusOK, newRepository(repo))
}

func (h *handlers) getRepositoryByPath(w http.ResponseWriter, r *http.Request) error {
namespace, name := chi.URLParam(r, "namespace"), chi.URLParam(r, "name")
if namespace == "" || name == "" {
return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "namespace and name are required")
}

repo, err := h.service.GetRepositoryByPath(r.Context(), namespace, name)
switch {
case errors.Is(err, reposervice.ErrRepositoryNotFound):
return response.NewError(http.StatusNotFound, response.CodeRepositoryNotFound, "repository not found")
case err != nil:
h.logger.Error("failed to get repository by path", zap.Error(err))
return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to get repository")
}

return response.Data(w, http.StatusOK, newRepository(repo))
}
2 changes: 2 additions & 0 deletions internal/server/control/repositories/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
type RepositoryManager interface {
Create(ctx context.Context, ownerID int64, info domain.RepositoryInfo) (domain.Repository, error)
Get(ctx context.Context, repositoryID int64) (domain.Repository, error)
GetRepositoryByPath(ctx context.Context, namespace, name string) (domain.Repository, error)
Delete(ctx context.Context, repositoryID int64) error
SetVisibility(ctx context.Context, repositoryID int64, visibility domain.RepoVisibility) (domain.Repository, error)
ListByOwner(ctx context.Context, ownerID int64) ([]domain.Repository, error)
Expand Down Expand Up @@ -43,6 +44,7 @@ func NewHandlers(logger *zap.Logger, service RepositoryManager) *handlers {
func (h *handlers) RegisterRoutes(parent chi.Router) {
parent.Route("/repositories", func(r chi.Router) {
r.Post("/", response.Handler(h.logger, h.createRepository))
r.Get("/by-path/{namespace}/{name}", response.Handler(h.logger, h.getRepositoryByPath))
r.Post("/{repositoryID}/blobs", response.Handler(h.logger, h.uploadBlob))
r.Post("/{repositoryID}/commits", response.Handler(h.logger, h.createCommit))
r.Get("/{repositoryID}", response.Handler(h.logger, h.getRepository))
Expand Down
60 changes: 60 additions & 0 deletions internal/server/control/repositories/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,19 @@ type fakeManager struct {

createdRepo domain.Repository
createErr error

repoByPath domain.Repository
repoByPathErr error
}

func (f *fakeManager) Create(ctx context.Context, ownerID int64, info domain.RepositoryInfo) (domain.Repository, error) {
return f.createdRepo, f.createErr
}

func (f *fakeManager) GetRepositoryByPath(ctx context.Context, namespace, name string) (domain.Repository, error) {
return f.repoByPath, f.repoByPathErr
}

func (f *fakeManager) ListPathPolicies(ctx context.Context, repositoryID int64) ([]domain.PathPolicy, error) {
return f.policyList, f.policyErr
}
Expand Down Expand Up @@ -577,6 +584,59 @@ func TestCreateRepository(t *testing.T) {
}
}

func TestGetRepositoryByPath(t *testing.T) {
cases := []struct {
name string
target string
svcErr error
wantStatus int
wantCode string
}{
{"found", "/repositories/by-path/acme/api", nil, http.StatusOK, ""},
{"numeric namespace and name", "/repositories/by-path/123/456", nil, http.StatusOK, ""},
{"not found", "/repositories/by-path/acme/nope", reposervice.ErrRepositoryNotFound, http.StatusNotFound, "repository_not_found"},
{"internal", "/repositories/by-path/acme/api", io.ErrUnexpectedEOF, http.StatusInternalServerError, "internal_error"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fake := &fakeManager{
repoByPath: domain.Repository{ID: 7, OwnerID: 3, RepositoryName: "api", Visibility: domain.RepoVisibilityPrivate},
repoByPathErr: tc.svcErr,
}
rec := httptest.NewRecorder()
newTestRouter(fake).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.target, nil))

if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d: %s", rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantCode == "" {
var body struct {
Data Repository `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Data.ID != 7 || body.Data.Name != "api" {
t.Errorf("body = %+v", body.Data)
}
return
}
var body struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Error.Code != tc.wantCode {
t.Errorf("code = %q, want %q", body.Error.Code, tc.wantCode)
}
})
}
}

func TestPathPolicies(t *testing.T) {
policy := domain.PathPolicy{ID: 3, RepositoryID: 7, Pattern: "runtime", Kind: domain.PathPolicyBlock, Reason: "deploy state"}

Expand Down
18 changes: 18 additions & 0 deletions internal/server/control/users/getuser.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,21 @@ func (h *handlers) getUser(w http.ResponseWriter, r *http.Request) error {

return response.Data(w, http.StatusOK, newUserResponse(account))
}

func (h *handlers) getUserByUsername(w http.ResponseWriter, r *http.Request) error {
username := chi.URLParam(r, "username")
if username == "" {
return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "username is required")
}

account, err := h.users.GetByUsername(r.Context(), username)
switch {
case errors.Is(err, usersservice.ErrUserNotFound):
return response.NewError(http.StatusNotFound, response.CodeUserNotFound, "user not found")
case err != nil:
h.logger.Error("failed to get user by username", zap.Error(err))
return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to get user")
}

return response.Data(w, http.StatusOK, newUserResponse(account))
}
2 changes: 2 additions & 0 deletions internal/server/control/users/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
type UserManager interface {
Create(ctx context.Context, info domain.UserInfo) (domain.Account, error)
Get(ctx context.Context, userID int64) (domain.Account, error)
GetByUsername(ctx context.Context, username string) (domain.Account, error)
}

type CredentialManager interface {
Expand Down Expand Up @@ -43,6 +44,7 @@ func NewHandlers(logger *zap.Logger, users UserManager, creds CredentialManager)
func (h *handlers) RegisterRoutes(parent chi.Router) {
parent.Route("/users", func(r chi.Router) {
r.Post("/", response.Handler(h.logger, h.createUser))
r.Get("/by-username/{username}", response.Handler(h.logger, h.getUserByUsername))
r.Get("/{userID}", response.Handler(h.logger, h.getUser))

r.Get("/{userID}/ssh-keys", response.Handler(h.logger, h.listSSHKeys))
Expand Down
58 changes: 58 additions & 0 deletions internal/server/control/users/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,71 @@ func (f fakeUserManager) Create(ctx context.Context, info domain.UserInfo) (doma
return f.account, f.err
}

func (f fakeUserManager) GetByUsername(ctx context.Context, username string) (domain.Account, error) {
return f.account, f.err
}

// newTestRouter mounts the handlers the same way the control server does
func newTestRouter(users UserManager) http.Handler {
r := chi.NewRouter()
NewHandlers(zap.NewNop(), users, nil).RegisterRoutes(r)
return r
}

func TestGetUserByUsername(t *testing.T) {
cases := []struct {
name string
target string
svcErr error
wantStatus int
wantCode string
}{
{"found", "/users/by-username/alice", nil, http.StatusOK, ""},
{"numeric username", "/users/by-username/12345", nil, http.StatusOK, ""},
{"not found", "/users/by-username/ghost", usersservice.ErrUserNotFound, http.StatusNotFound, "user_not_found"},
{"internal", "/users/by-username/alice", context.DeadlineExceeded, http.StatusInternalServerError, "internal_error"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
router := newTestRouter(fakeUserManager{
account: domain.Account{UserID: 7, Username: "alice", Kind: domain.UserKindUser},
err: tc.svcErr,
})

rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.target, nil))

if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantCode == "" {
var envelope struct {
Data UserResponse `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode data envelope: %v", err)
}
if envelope.Data.ID != 7 || envelope.Data.Username != "alice" {
t.Errorf("body = %+v", envelope.Data)
}
return
}
var envelope struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode error envelope: %v", err)
}
if envelope.Error.Code != tc.wantCode {
t.Errorf("code = %q, want %q", envelope.Error.Code, tc.wantCode)
}
})
}
}

func TestCreateUser(t *testing.T) {
tests := []struct {
name string
Expand Down
2 changes: 2 additions & 0 deletions internal/server/control/webhooks/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
type WebhookManager interface {
RegisterWebhook(ctx context.Context, repoID int64, url string) (domain.Webhook, error)
DeleteWebhook(ctx context.Context, webhookID, repositoryID int64) error
ListWebhooks(ctx context.Context, repositoryID int64) ([]domain.Webhook, error)
}

type handlers struct {
Expand All @@ -29,6 +30,7 @@ func NewHandlers(logger *zap.Logger, webhooks WebhookManager) *handlers {
func (h *handlers) RegisterRoutes(parent chi.Router) {
parent.Route("/repositories/{repositoryID}/webhooks", func(r chi.Router) {
r.Post("/", response.Handler(h.logger, h.createWebhook))
r.Get("/", response.Handler(h.logger, h.listWebhooks))
r.Delete("/{webhookID}", response.Handler(h.logger, h.deleteWebhook))
})
}
47 changes: 47 additions & 0 deletions internal/server/control/webhooks/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,60 @@ func (f fakeManager) DeleteWebhook(ctx context.Context, webhookID, repositoryID
return f.err
}

func (f fakeManager) ListWebhooks(ctx context.Context, repositoryID int64) ([]domain.Webhook, error) {
if f.err != nil {
return nil, f.err
}
return []domain.Webhook{f.webhook}, nil
}

// newTestRouter mounts the handlers the same way the control server does
func newTestRouter(webhooks WebhookManager) http.Handler {
r := chi.NewRouter()
NewHandlers(zap.NewNop(), webhooks).RegisterRoutes(r)
return r
}

func TestListWebhooks(t *testing.T) {
t.Run("ok, never leaks the secret", func(t *testing.T) {
fake := fakeManager{webhook: domain.Webhook{ID: 3, RepositoryID: 7, URL: "https://example.com/hook", Secret: "super-secret"}}
rec := httptest.NewRecorder()
newTestRouter(fake).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/repositories/7/webhooks", nil))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var body struct {
Data []WebhookListItem `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body.Data) != 1 || body.Data[0].ID != 3 || body.Data[0].URL != "https://example.com/hook" {
t.Errorf("body = %+v", body.Data)
}
if strings.Contains(rec.Body.String(), "secret") || strings.Contains(rec.Body.String(), "super-secret") {
t.Errorf("list response leaks the secret: %s", rec.Body.String())
}
})

t.Run("invalid repo id", func(t *testing.T) {
rec := httptest.NewRecorder()
newTestRouter(fakeManager{}).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/repositories/abc/webhooks", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
})

t.Run("internal", func(t *testing.T) {
rec := httptest.NewRecorder()
newTestRouter(fakeManager{err: io.ErrUnexpectedEOF}).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/repositories/7/webhooks", nil))
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
})
}

func TestCreateWebhook(t *testing.T) {
cases := []struct {
name string
Expand Down
25 changes: 25 additions & 0 deletions internal/server/control/webhooks/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package webhooks

import (
"net/http"
"strconv"

"github.com/Axenos-dev/HeadlessGit/internal/server/response"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)

func (h *handlers) listWebhooks(w http.ResponseWriter, r *http.Request) error {
repoID, err := strconv.ParseInt(chi.URLParam(r, "repositoryID"), 10, 64)
if err != nil {
return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid repository id")
}

webhooks, err := h.webhooks.ListWebhooks(r.Context(), repoID)
if err != nil {
h.logger.Error("failed to list webhooks", zap.Error(err))
return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to list webhooks")
}

return response.Data(w, http.StatusOK, newWebhookListItems(webhooks))
}
Loading
Loading