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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Every request requires `Authorization: Bearer <ADMIN_TOKEN>`. Responses are enve

| Method | Path | Body | Description |
| -------- | ------------------------------ | -------------------- | ------------------------------------------------------------ |
| `POST` | `/users` | `{username, kind}` | Create a user/service account (`kind`: `user` \| `service`). |
| `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/{id}/repositories` | — | List repositories owned by the account. |
| `POST` | `/users/{id}/ssh-keys` | `{title, publicKey}` | Register an SSH public key. |
Expand All @@ -133,14 +133,14 @@ Every request requires `Authorization: Bearer <ADMIN_TOKEN>`. Responses are enve

| Method | Path | Body | Description |
| -------- | --------------------------------------------- | ----------------------------- | ----------------------------------------------------------------- |
| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`). |
| `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. |
| `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**. |
| `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. |
| `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 Expand Up @@ -269,7 +269,7 @@ remote: push rejected: "runtime/state.json" is blocked by policy (.....)

## Webhooks

Register a webhook on a repository and `headlessgit` will `POST` to it after every ref change — a `git push` or a commit created through the [content API](#writing-without-a-clone) produce identical events.
Register a webhook on a repository and `headlessgit` will `POST` to it after every ref change — a `git push` or a commit created through the [content API](#writing-without-a-clone) produce identical events. A repository can have multiple webhooks, but each URL only once (`409 webhook_exists` on duplicates); to rotate a secret, delete the webhook and re-register it.

One delivery is sent **per changed ref** (a branch/tag create, update, or delete — not per file or commit). The JSON body:

Expand Down
25 changes: 25 additions & 0 deletions internal/db/db_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,31 @@ func TestPermissionUpsert(t *testing.T) {
}
}

func TestWebhookUniquePerRepoURL(t *testing.T) {
d := openTestDB(t)
ctx := context.Background()
owner, _ := d.CreateUser(ctx, gen.CreateUserParams{Username: "o", Kind: "user"})
repo, _ := d.CreateRepository(ctx, gen.CreateRepositoryParams{
OwnerID: owner.ID, RepositoryName: "r", StoragePath: "o/r.git", Visibility: "private",
})

if _, err := d.CreateWebhook(ctx, gen.CreateWebhookParams{RepositoryID: repo.ID, Secret: "s1", Url: "https://example.com/hook"}); err != nil {
t.Fatalf("create webhook: %v", err)
}
// duplicate (repo, url): "on conflict do nothing returning *" -> no rows
if _, err := d.CreateWebhook(ctx, gen.CreateWebhookParams{RepositoryID: repo.ID, Secret: "s2", Url: "https://example.com/hook"}); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("duplicate webhook: expected ErrNoRows, got %v", err)
}

// the same url on a different repo is a separate registration
repo2, _ := d.CreateRepository(ctx, gen.CreateRepositoryParams{
OwnerID: owner.ID, RepositoryName: "r2", StoragePath: "o/r2.git", Visibility: "private",
})
if _, err := d.CreateWebhook(ctx, gen.CreateWebhookParams{RepositoryID: repo2.ID, Secret: "s3", Url: "https://example.com/hook"}); err != nil {
t.Fatalf("same url, other repo: %v", err)
}
}

func TestEnsureAdminUserIdempotent(t *testing.T) {
d := openTestDB(t)
ctx := context.Background()
Expand Down
3 changes: 2 additions & 1 deletion internal/db/gen/repositories.sql.go

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

3 changes: 2 additions & 1 deletion internal/db/gen/users.sql.go

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

3 changes: 2 additions & 1 deletion internal/db/gen/webhooks.sql.go

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

12 changes: 12 additions & 0 deletions internal/db/migrations/0006_webhooks_unique_url.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- +goose Up
-- drop duplicate registrations before adding the constraint
delete from webhooks
where id not in (
select min(id) from webhooks
group by repository_id, url
);

create unique index if not exists idx_webhooks_repository_id_url on webhooks(repository_id, url);

-- +goose Down
drop index if exists idx_webhooks_repository_id_url;
3 changes: 2 additions & 1 deletion internal/db/queries/repositories.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ insert into repositories (
owner_id, repository_name, storage_path, visibility
) values (
?, ?, ?, ?
) returning *;
) on conflict(owner_id, repository_name) do nothing
returning *;

-- name: UpdateRepositoryVisibility :one
update repositories
Expand Down
3 changes: 2 additions & 1 deletion internal/db/queries/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ insert into users (
username, kind
) values (
?, ?
) returning *;
) on conflict(username) do nothing
returning *;

-- name: EnsureAdminUser :one
insert into users (
Expand Down
3 changes: 2 additions & 1 deletion internal/db/queries/webhooks.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ insert into webhooks (
repository_id, secret, url
) values (
?, ?, ?
) returning *;
) on conflict(repository_id, url) do nothing
returning *;

-- name: ListWebhooksForRepository :many
select * from webhooks
Expand Down
2 changes: 2 additions & 0 deletions internal/server/control/repositories/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func (h *handlers) createRepository(w http.ResponseWriter, r *http.Request) erro
switch {
case errors.Is(err, reposervice.ErrInvalidRepositoryName):
return response.NewError(http.StatusBadRequest, response.CodeInvalidRequest, "invalid repository name")
case errors.Is(err, reposervice.ErrRepositoryExists):
return response.NewError(http.StatusConflict, response.CodeRepositoryExists, "repository already exists")
case err != nil:
h.logger.Error("failed to create repository", zap.Error(err))
return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to create repository")
Expand Down
53 changes: 53 additions & 0 deletions internal/server/control/repositories/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ type fakeManager struct {
policyErr error
policyPattern string
policyReason string

createdRepo domain.Repository
createErr error
}

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

func (f *fakeManager) ListPathPolicies(ctx context.Context, repositoryID int64) ([]domain.PathPolicy, error) {
Expand Down Expand Up @@ -524,6 +531,52 @@ func TestCreateCommitErrors(t *testing.T) {
}
}

func TestCreateRepository(t *testing.T) {
cases := []struct {
name string
body string
createErr error
wantStatus int
wantCode string
}{
{"created", `{"ownerId":3,"name":"demo","visibility":"private"}`, nil, http.StatusCreated, ""},
{"duplicate", `{"ownerId":3,"name":"demo","visibility":"private"}`, reposervice.ErrRepositoryExists, http.StatusConflict, "repository_exists"},
{"invalid name", `{"ownerId":3,"name":"..","visibility":"private"}`, reposervice.ErrInvalidRepositoryName, http.StatusBadRequest, "invalid_request"},
{"internal", `{"ownerId":3,"name":"demo","visibility":"private"}`, io.ErrUnexpectedEOF, http.StatusInternalServerError, "internal_error"},
{"invalid body", `not json`, nil, http.StatusBadRequest, "invalid_request"},
{"missing owner", `{"name":"demo","visibility":"private"}`, nil, http.StatusBadRequest, "invalid_request"},
{"bad visibility", `{"ownerId":3,"name":"demo","visibility":"hidden"}`, nil, http.StatusBadRequest, "invalid_request"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fake := &fakeManager{
createdRepo: domain.Repository{ID: 7, OwnerID: 3, RepositoryName: "demo", Visibility: domain.RepoVisibilityPrivate},
createErr: tc.createErr,
}
rec := httptest.NewRecorder()
newTestRouter(fake).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/repositories", strings.NewReader(tc.body)))

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 {
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
7 changes: 6 additions & 1 deletion internal/server/control/users/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package users

import (
"encoding/json"
"errors"
"net/http"

"github.com/Axenos-dev/HeadlessGit/internal/domain"
"github.com/Axenos-dev/HeadlessGit/internal/server/response"
usersservice "github.com/Axenos-dev/HeadlessGit/internal/services/users"
"go.uber.org/zap"
)

Expand All @@ -22,7 +24,10 @@ func (h *handlers) createUser(w http.ResponseWriter, r *http.Request) error {
Username: req.Username,
Kind: domain.UserKind(req.Kind),
})
if err != nil {
switch {
case errors.Is(err, usersservice.ErrUserExists):
return response.NewError(http.StatusConflict, response.CodeUserExists, "user already exists")
case err != nil:
h.logger.Error("failed to create user", zap.Error(err))
return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to create user")
}
Expand Down
81 changes: 81 additions & 0 deletions internal/server/control/users/handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package users

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/Axenos-dev/HeadlessGit/internal/domain"
usersservice "github.com/Axenos-dev/HeadlessGit/internal/services/users"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)

// fakeUserManager stubs UserManager for handler tests: embed the interface
// and override only what the endpoint under test touches
type fakeUserManager struct {
UserManager
account domain.Account
err error
}

func (f fakeUserManager) Create(ctx context.Context, info domain.UserInfo) (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 TestCreateUser(t *testing.T) {
tests := []struct {
name string
body string
svcErr error
wantStatus int
wantCode string
}{
{"created", `{"username":"alice","kind":"user"}`, nil, http.StatusCreated, ""},
{"duplicate", `{"username":"alice","kind":"user"}`, usersservice.ErrUserExists, http.StatusConflict, "user_exists"},
{"service error", `{"username":"alice","kind":"user"}`, context.DeadlineExceeded, http.StatusInternalServerError, "internal_error"},
{"invalid body", `not json`, nil, http.StatusBadRequest, "invalid_request"},
{"missing username", `{"kind":"user"}`, nil, http.StatusBadRequest, "invalid_request"},
{"bad kind", `{"username":"alice","kind":"robot"}`, nil, http.StatusBadRequest, "invalid_request"},
}

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

req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tt.body))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, tt.wantStatus, rec.Body.String())
}
if tt.wantCode != "" {
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 != tt.wantCode {
t.Errorf("code = %q, want %q", envelope.Error.Code, tt.wantCode)
}
}
})
}
}
7 changes: 6 additions & 1 deletion internal/server/control/webhooks/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package webhooks

import (
"encoding/json"
"errors"
"net/http"
"strconv"

"github.com/Axenos-dev/HeadlessGit/internal/server/response"
webhookservice "github.com/Axenos-dev/HeadlessGit/internal/services/webhooks"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
Expand All @@ -25,7 +27,10 @@ func (h *handlers) createWebhook(w http.ResponseWriter, r *http.Request) error {
}

webhook, err := h.webhooks.RegisterWebhook(r.Context(), repoID, req.URL)
if err != nil {
switch {
case errors.Is(err, webhookservice.ErrWebhookExists):
return response.NewError(http.StatusConflict, response.CodeWebhookExists, "webhook already exists")
case err != nil:
h.logger.Error("failed to register webhook", zap.Error(err))
return response.NewError(http.StatusInternalServerError, response.CodeInternalError, "failed to register webhook")
}
Expand Down
Loading
Loading