Files
projax/web/gitea_writeback_test.go
mAi 5a56ad91e5 feat(phase 3h gitea writeback): close/reopen/comment/create from projax
- gitea pkg: CloseIssue, ReopenIssue, CreateIssue, AddComment + ErrForbidden
  classification on 401/403. Client.do sets Content-Type on non-empty bodies.
- web handler: POST /i/{path}/issues/{close|reopen|comment|create}
  - authorisation guard: repo form value must match a gitea-repo item_link
    on the target item (rejects form-crafted writes to unrelated repos)
  - HTMX re-renders issues_section partial after each action
  - busts gitea per-repo cache (open + closed-recent) and dashboard 60s TTL
- templates: ✓ close button + reopen + collapsible comment box on every
  issue row; "+ new issue" disclosure per repo
- design.md §6 retitled "Phase 2.d read; 3h writeback" with auth/perm
  semantics + parked list
- 5 unit tests in gitea/, 5 integration tests in web/ covering happy paths
  + 403 → inline banner fallback
2026-05-15 19:22:11 +02:00

254 lines
8.1 KiB
Go

package web_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/m/projax/gitea"
"github.com/m/projax/web"
)
// gwbServer spins up a fake Gitea server with controllable responses for
// close/reopen/comment/create + a passthrough issues list.
type gwbServer struct {
URL string
closes atomic.Int32
reopens atomic.Int32
comments atomic.Int32
creates atomic.Int32
srv *httptest.Server
repoOwner string
repoName string
}
func newGwbServer(t *testing.T, owner, repo string) *gwbServer {
t.Helper()
g := &gwbServer{repoOwner: owner, repoName: repo}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/"+owner+"/"+repo+"/issues", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
_, _ = io.WriteString(w, "[]")
case http.MethodPost:
g.creates.Add(1)
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
w.WriteHeader(201)
_, _ = io.WriteString(w, `{"number": 1, "title": "`+body["title"]+`", "state": "open", "updated_at": "2026-05-15T19:00:00Z", "html_url": "https://x/1"}`)
}
})
mux.HandleFunc("/api/v1/repos/"+owner+"/"+repo+"/issues/42", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch {
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["state"] == "closed" {
g.closes.Add(1)
} else {
g.reopens.Add(1)
}
w.WriteHeader(201)
_, _ = io.WriteString(w, `{}`)
}
})
mux.HandleFunc("/api/v1/repos/"+owner+"/"+repo+"/issues/42/comments", func(w http.ResponseWriter, r *http.Request) {
g.comments.Add(1)
w.WriteHeader(201)
_, _ = io.WriteString(w, `{"id": 1, "body": "test", "html_url": "https://x/1", "created_at": "2026-05-15T19:00:00Z"}`)
})
g.srv = httptest.NewServer(mux)
g.URL = g.srv.URL
t.Cleanup(func() { g.srv.Close() })
return g
}
// seedItemWithGiteaLink inserts a projax item under "dev" with a gitea-repo
// link pointing at owner/repo, and returns the primary path + cleanup.
func seedItemWithGiteaLink(t *testing.T, srv *web.Server, repoRef string) (path string, cleanup func()) {
t.Helper()
// Use the underlying pool directly via test helpers from server_test.go.
pool := srv.Store.Pool
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "gwb-" + stamp
var dev, id string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, management)
values (array['project']::text[], 'gwb', $1, ARRAY[$2]::uuid[], ARRAY['mai'])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
values ($1, 'gitea-repo', $2, 'tracks')`,
id, repoRef,
); err != nil {
t.Fatalf("seed link: %v", err)
}
cleanup = func() {
_, _ = pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
}
return "dev." + slug, cleanup
}
func TestIssuesCloseRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
g := newGwbServer(t, "fake", "repo-close")
srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok"))
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-close")
defer cleanup()
h := srv.Routes()
form := url.Values{}
form.Set("repo", "fake/repo-close")
form.Set("number", "42")
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/close", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("close → %d body=%s", w.Result().StatusCode, body)
}
if g.closes.Load() != 1 {
t.Errorf("upstream close count = %d, want 1", g.closes.Load())
}
}
func TestIssuesCommentRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
g := newGwbServer(t, "fake", "repo-comment")
srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok"))
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-comment")
defer cleanup()
h := srv.Routes()
form := url.Values{}
form.Set("repo", "fake/repo-comment")
form.Set("number", "42")
form.Set("body", "looks good")
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/comment", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("comment → %d body=%s", w.Result().StatusCode, body)
}
if g.comments.Load() != 1 {
t.Errorf("upstream comment count = %d, want 1", g.comments.Load())
}
}
func TestIssuesCreateRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
g := newGwbServer(t, "fake", "repo-create")
srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok"))
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-create")
defer cleanup()
h := srv.Routes()
form := url.Values{}
form.Set("repo", "fake/repo-create")
form.Set("title", "New from projax")
form.Set("body", "filed via /admin")
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/create", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("create → %d body=%s", w.Result().StatusCode, body)
}
if g.creates.Load() != 1 {
t.Errorf("upstream create count = %d, want 1", g.creates.Load())
}
}
func TestIssuesReopenRoundTrip(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
g := newGwbServer(t, "fake", "repo-reopen")
srv.Gitea = web.NewGiteaDeps(gitea.New(g.URL, "tok"))
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-reopen")
defer cleanup()
h := srv.Routes()
form := url.Values{}
form.Set("repo", "fake/repo-reopen")
form.Set("number", "42")
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/reopen", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {
body, _ := io.ReadAll(w.Result().Body)
t.Fatalf("reopen → %d body=%s", w.Result().StatusCode, body)
}
if g.reopens.Load() != 1 {
t.Errorf("upstream reopen count = %d, want 1", g.reopens.Load())
}
}
func TestIssuesForbiddenRendersInlineBanner(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/fake/repo-403/issues", func(w http.ResponseWriter, r *http.Request) {
// GET returns []; POST returns 403.
if r.Method == http.MethodGet {
_, _ = io.WriteString(w, "[]")
return
}
http.Error(w, "forbidden", http.StatusForbidden)
})
fake := httptest.NewServer(mux)
defer fake.Close()
srv.Gitea = web.NewGiteaDeps(gitea.New(fake.URL, "tok"))
path, cleanup := seedItemWithGiteaLink(t, srv, "fake/repo-403")
defer cleanup()
h := srv.Routes()
form := url.Values{}
form.Set("repo", "fake/repo-403")
form.Set("title", "should 403")
req := httptest.NewRequest(http.MethodPost, "/i/"+path+"/issues/create", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {
t.Fatalf("expected graceful 200 with inline banner, got %d", w.Result().StatusCode)
}
body, _ := io.ReadAll(w.Result().Body)
if !strings.Contains(string(body), "lacks write access") {
t.Errorf("expected 403 banner about token write access — body:\n%s", string(body))
}
}