- 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
254 lines
8.1 KiB
Go
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))
|
|
}
|
|
}
|