Files
kandev/apps/backend/internal/task/share/http_test.go
T
João Salavisa 07a9784d1c fix(share): route share links through gist.githack so big tasks render (#1008)
* fix(share): route share links through gist.githack so big tasks render

gistpreview.github.io fetches gists via the public GitHub API, which returns
`content: ""` for files past GitHub's per-response content budget (~1 MB).
For non-trivial sessions, share.html and snapshot.json land past that
budget and gistpreview's `document.write(content)` renders a blank page.
gist.githack.com proxies the raw file content directly, so multi-MB
share.html renders correctly — at the cost of a one-time Cloudflare
anti-phishing interstitial on first visit, accepted as the price of
fidelity for big tasks.

displayURL now normalises bare gist URLs and unpinned githack URLs to the
canonical `gist.githack.com/<owner>/<id>/raw/share.html` form; legacy
gistpreview URLs pass through unchanged (the owner is not recoverable
from a gistpreview URL, so users can revoke + re-share to get the
githack form going forward).

* fix(share): address PR #1008 review feedback

- displayURL now re-pins legacy gistpreview URLs stored without /share.html
  so they hit the styled view instead of silently falling back to the
  alphabetically-first README.md (flagged by Greptile).
- Spec: unify two stale gistpreview references (API endpoint description
  and the publish scenario) to the canonical githack form, with a note
  that legacy gistpreview rows pass through. Flagged by CodeRabbit and
  Claude — the spec is the public contract; one stale mention undermines
  the migration.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-22 11:40:36 +01:00

314 lines
9.4 KiB
Go

package share
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/task/models"
)
func init() { gin.SetMode(gin.TestMode) }
// stubGHAuth lets tests toggle the IsAuthenticated response without owning
// the full Client interface.
type stubGHAuth struct {
authed bool
}
func (s *stubGHAuth) IsAuthenticated(context.Context) (bool, error) { return s.authed, nil }
// embedNoop fills the rest of the Client surface with the noop implementation
// so we don't have to re-implement 30 methods just for a handler test.
type embedNoop struct {
*stubGHAuth
*github.NoopClient
}
func (e *embedNoop) IsAuthenticated(ctx context.Context) (bool, error) {
return e.stubGHAuth.IsAuthenticated(ctx)
}
func newHandlersForTest(t *testing.T, reader TaskReader, backend Backend, authed bool) (*HTTPHandlers, *Service) {
t.Helper()
repo := newTestRepo(t)
svc := New(repo, reader, backend, nil, "v-test")
gh := &embedNoop{stubGHAuth: &stubGHAuth{authed: authed}, NoopClient: &github.NoopClient{}}
return NewHTTPHandlers(svc, gh, nil), svc
}
func newGinRouter(h *HTTPHandlers) *gin.Engine {
r := gin.New()
h.RegisterRoutes(r)
return r
}
func TestHTTP_Create_HappyPath(t *testing.T) {
t.Parallel()
reader := completedSession()
// Real GistBackend returns the gist.githack.com rendered URL — match
// that shape so the test mirrors production.
backend := &mockBackend{nextID: "gist-x", nextURL: "https://gist.githack.com/jane/gist-x/raw/share.html"}
handlers, _ := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/t-1/sessions/s-1/shares", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
}
var body shareResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.URL != "https://gist.githack.com/jane/gist-x/raw/share.html" {
t.Fatalf("unexpected url: %s", body.URL)
}
}
func TestHTTP_Create_RejectsCreatedSessionWith409(t *testing.T) {
t.Parallel()
reader := completedSession()
reader.session.State = models.TaskSessionStateCreated
backend := &mockBackend{}
handlers, _ := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/t-1/sessions/s-1/shares", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
}
var body errorBody
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body.Code != "session_not_shareable" {
t.Fatalf("expected code session_not_shareable, got %q", body.Code)
}
}
func TestHTTP_Create_BlocksWhenGitHubUnauthenticated(t *testing.T) {
t.Parallel()
reader := completedSession()
backend := &mockBackend{}
handlers, _ := newHandlersForTest(t, reader, backend, false)
router := newGinRouter(handlers)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/t-1/sessions/s-1/shares", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusPreconditionFailed {
t.Fatalf("want 412, got %d", rec.Code)
}
var body errorBody
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body.Code != "github_credential_missing" {
t.Fatalf("expected code github_credential_missing, got %q", body.Code)
}
}
func TestHTTP_Create_DryRunReturnsSnapshotWithoutUpload(t *testing.T) {
t.Parallel()
reader := completedSession()
backend := &mockBackend{}
// Auth check is skipped for dry-run; flip authed to false to prove that.
handlers, _ := newHandlersForTest(t, reader, backend, false)
router := newGinRouter(handlers)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/t-1/sessions/s-1/shares?dry_run=true", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
}
var snap Snapshot
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
t.Fatalf("decode: %v", err)
}
if snap.Task.Title == "" {
t.Fatalf("expected non-empty snapshot, got %+v", snap)
}
if backend.uploads != 0 {
t.Fatalf("dry-run must not call backend Upload, got %d uploads", backend.uploads)
}
}
func TestHTTP_List_ReturnsSharesNewestFirst(t *testing.T) {
t.Parallel()
reader := completedSession()
backend := &mockBackend{nextID: "gist-a"}
handlers, svc := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
if _, err := svc.CreateShare(context.Background(), "s-1"); err != nil {
t.Fatalf("create: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/t-1/sessions/s-1/shares", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rec.Code)
}
var body listSharesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Shares) != 1 {
t.Fatalf("expected 1 share, got %d", len(body.Shares))
}
}
func TestHTTP_Revoke_Success(t *testing.T) {
t.Parallel()
reader := completedSession()
backend := &mockBackend{nextID: "gist-z"}
handlers, svc := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
share, err := svc.CreateShare(context.Background(), "s-1")
if err != nil {
t.Fatalf("create: %v", err)
}
req := httptest.NewRequest(http.MethodDelete, "/api/v1/shares/"+share.ID, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
}
if len(backend.deletes) != 1 {
t.Fatalf("expected 1 backend delete, got %d", len(backend.deletes))
}
}
func TestDisplayURL_NormalizesAllFormats(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want string
}{
{
"bare_gist_url_converted_to_githack",
"https://gist.github.com/jane/abc123",
"https://gist.githack.com/jane/abc123/raw/share.html",
},
{
"githack_url_passthrough",
"https://gist.githack.com/jane/abc123/raw/share.html",
"https://gist.githack.com/jane/abc123/raw/share.html",
},
{
"githack_without_filename_repinned",
"https://gist.githack.com/jane/abc123",
"https://gist.githack.com/jane/abc123/raw/share.html",
},
{
// Legacy gistpreview rows (written before the githack switch)
// lack the owner segment we need to build a githack URL, so
// they pass through. Users on those rows still get a working
// link for small tasks; revoking + re-sharing surfaces the
// githack URL going forward.
"legacy_gistpreview_with_share_html_passthrough",
"https://gistpreview.github.io/?abc123/share.html",
"https://gistpreview.github.io/?abc123/share.html",
},
{
// Older gistpreview rows stored without /share.html still get
// re-pinned so gistpreview doesn't silently fall back to
// rendering README.md.
"legacy_gistpreview_without_filename_repinned",
"https://gistpreview.github.io/?abc123",
"https://gistpreview.github.io/?abc123/share.html",
},
{
"empty_string",
"",
"",
},
{
"unknown_url_passthrough",
"https://example.com/foo",
"https://example.com/foo",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if got := displayURL(tc.input); got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestHTTP_List_NormalizesLegacyURLsOnDisplay(t *testing.T) {
t.Parallel()
reader := completedSession()
backend := &mockBackend{}
handlers, svc := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
// Seed two legacy rows from earlier URL formats — bare gist and a
// githack URL with no filename pinned. Both should surface as the
// canonical /raw/share.html githack form.
for _, row := range []*Share{
{ID: "old-1", TaskSessionID: "s-1", Backend: BackendGitHubGist, ExternalID: "abc123",
ExternalURL: "https://gist.github.com/jane/abc123"},
{ID: "old-2", TaskSessionID: "s-1", Backend: BackendGitHubGist, ExternalID: "def456",
ExternalURL: "https://gist.githack.com/jane/def456"},
} {
if err := svc.repo.Create(context.Background(), row); err != nil {
t.Fatalf("seed %s: %v", row.ID, err)
}
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/t-1/sessions/s-1/shares", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rec.Code)
}
var body listSharesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Shares) != 2 {
t.Fatalf("expected 2 shares, got %d", len(body.Shares))
}
for _, s := range body.Shares {
if !strings.HasSuffix(s.URL, "/raw/share.html") ||
!strings.HasPrefix(s.URL, "https://gist.githack.com/") {
t.Fatalf("expected gist.githack.com /raw/share.html URL, got %q", s.URL)
}
}
}
func TestHTTP_Revoke_NotFound(t *testing.T) {
t.Parallel()
reader := completedSession()
backend := &mockBackend{}
handlers, _ := newHandlersForTest(t, reader, backend, true)
router := newGinRouter(handlers)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/shares/does-not-exist", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", rec.Code)
}
}