mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 05:44:36 +03:00
243 lines
7.3 KiB
Go
243 lines
7.3 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"hash/crc32"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeGitHub stubs the slice of GitHub's REST API the relay uses:
|
|
// - Git Data API (refs / commits / blobs / trees) for batched uploads
|
|
// - Contents API (list / delete) for PruneStale
|
|
type fakeGitHub struct {
|
|
mu sync.Mutex
|
|
files map[string][]byte // repoPath → ciphertext (committed)
|
|
commits int // number of commits created (rate-limit metric)
|
|
blobs int // blob create count
|
|
deletes int // contents-api deletions
|
|
|
|
// Tree state — dumb counter; we don't model real Git history.
|
|
headSHA string
|
|
treeSHA string
|
|
nextSeq int
|
|
}
|
|
|
|
func (f *fakeGitHub) sha(prefix string) string {
|
|
f.nextSeq++
|
|
return prefix + "-" + strconv.Itoa(f.nextSeq)
|
|
}
|
|
|
|
func (f *fakeGitHub) handler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
path := strings.TrimPrefix(r.URL.Path, "/repos/owner/repo/")
|
|
|
|
// --- Git Data API ---------------------------------------------------
|
|
switch {
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "git/ref/heads/"):
|
|
if f.headSHA == "" {
|
|
f.headSHA = f.sha("commit")
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"object": map[string]any{"sha": f.headSHA},
|
|
})
|
|
return
|
|
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "git/commits/"):
|
|
if f.treeSHA == "" {
|
|
f.treeSHA = f.sha("tree")
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"tree": map[string]any{"sha": f.treeSHA},
|
|
})
|
|
return
|
|
|
|
case r.Method == http.MethodPost && path == "git/blobs":
|
|
var body struct{ Content string }
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
f.blobs++
|
|
s := f.sha("blob")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"sha": s})
|
|
return
|
|
|
|
case r.Method == http.MethodPost && path == "git/trees":
|
|
// SHA is *string so null serialises as JSON null and decodes back to nil.
|
|
var body struct {
|
|
BaseTree string `json:"base_tree"`
|
|
Tree []struct {
|
|
Path string `json:"path"`
|
|
SHA *string `json:"sha"`
|
|
} `json:"tree"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
for _, e := range body.Tree {
|
|
if e.SHA == nil {
|
|
delete(f.files, e.Path)
|
|
f.deletes++
|
|
} else {
|
|
f.files[e.Path] = []byte("committed")
|
|
}
|
|
}
|
|
f.treeSHA = f.sha("tree")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"sha": f.treeSHA})
|
|
return
|
|
|
|
case r.Method == http.MethodPost && path == "git/commits":
|
|
f.commits++
|
|
f.headSHA = f.sha("commit")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"sha": f.headSHA})
|
|
return
|
|
|
|
case r.Method == http.MethodPatch && strings.HasPrefix(path, "git/refs/heads/"):
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// --- Contents API (used only for the directory listing in PruneStale) ---
|
|
if r.Method == http.MethodGet {
|
|
repoPath := strings.TrimPrefix(path, "contents/")
|
|
items := []map[string]any{}
|
|
prefix := repoPath + "/"
|
|
for k, v := range f.files {
|
|
if strings.HasPrefix(k, prefix) {
|
|
items = append(items, map[string]any{
|
|
"path": k, "sha": "sha-" + k, "type": "file", "size": len(v),
|
|
})
|
|
}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(items)
|
|
}
|
|
})
|
|
}
|
|
|
|
func newFakeGitHub(t *testing.T) (*fakeGitHub, func()) {
|
|
f := &fakeGitHub{files: map[string][]byte{}}
|
|
srv := httptest.NewServer(f.handler())
|
|
prev := githubAPI
|
|
githubAPI = srv.URL
|
|
t.Cleanup(func() { githubAPI = prev; srv.Close() })
|
|
return f, srv.Close
|
|
}
|
|
|
|
func TestGitHubRelayUploadAndDedup(t *testing.T) {
|
|
fk, _ := newFakeGitHub(t)
|
|
r := NewGitHubRelay(GitHubRelayConfig{Enabled: true, Token: "tok", Repo: "owner/repo", MaxBytes: 1 << 20, TTLMinutes: 60}, "feed.example.com", "test-passphrase")
|
|
if r == nil {
|
|
t.Fatal("relay should activate with full config")
|
|
}
|
|
|
|
body := []byte("hello relay world")
|
|
if err := r.Upload(context.Background(), body); err != nil {
|
|
t.Fatalf("first upload: %v", err)
|
|
}
|
|
// Second upload of the same content must dedup before reaching GitHub.
|
|
if err := r.Upload(context.Background(), body); err != nil {
|
|
t.Fatalf("second upload: %v", err)
|
|
}
|
|
// Force the batch to commit synchronously.
|
|
if err := r.Flush(context.Background()); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
if fk.commits != 1 {
|
|
t.Errorf("commits = %d, want 1 (one batch)", fk.commits)
|
|
}
|
|
if fk.blobs != 1 {
|
|
t.Errorf("blobs = %d, want 1 (dedup before flush)", fk.blobs)
|
|
}
|
|
if !r.Has(int64(len(body)), crc32.ChecksumIEEE(body)) {
|
|
t.Errorf("Has should return true after upload")
|
|
}
|
|
// A third Flush with no new uploads must be a no-op (no new commit).
|
|
if err := r.Flush(context.Background()); err != nil {
|
|
t.Fatalf("noop flush: %v", err)
|
|
}
|
|
if fk.commits != 1 {
|
|
t.Errorf("commits after noop flush = %d, want 1", fk.commits)
|
|
}
|
|
}
|
|
|
|
func TestGitHubRelayMaxBytes(t *testing.T) {
|
|
newFakeGitHub(t)
|
|
r := NewGitHubRelay(GitHubRelayConfig{Enabled: true, Token: "tok", Repo: "owner/repo", MaxBytes: 16, TTLMinutes: 60}, "ex.test", "pp")
|
|
err := r.Upload(context.Background(), bytes.Repeat([]byte("x"), 32))
|
|
if !errors.Is(err, ErrTooLarge) {
|
|
t.Fatalf("err = %v, want ErrTooLarge", err)
|
|
}
|
|
}
|
|
|
|
func TestGitHubRelayPruneStale(t *testing.T) {
|
|
fk, _ := newFakeGitHub(t)
|
|
r := NewGitHubRelay(GitHubRelayConfig{Enabled: true, Token: "tok", Repo: "owner/repo", MaxBytes: 1 << 20, TTLMinutes: 1}, "ex.test", "pp")
|
|
if err := r.Upload(context.Background(), []byte("stays")); err != nil {
|
|
t.Fatalf("upload stays: %v", err)
|
|
}
|
|
if err := r.Upload(context.Background(), []byte("goes")); err != nil {
|
|
t.Fatalf("upload goes: %v", err)
|
|
}
|
|
// Commit the batch so PruneStale can find files in the listing.
|
|
if err := r.Flush(context.Background()); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
// Roll back the lastSeen of the "goes" entry so PruneStale removes it.
|
|
// "stays" is 5 bytes, "goes" is 4 — match by size.
|
|
r.mu.Lock()
|
|
for _, e := range r.known {
|
|
if e.size == 4 {
|
|
e.lastSeen = time.Now().Add(-2 * time.Hour)
|
|
}
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
commitsBefore := fk.commits
|
|
removed, err := r.PruneStale(context.Background(), time.Now().Add(-time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("prune: %v", err)
|
|
}
|
|
if removed != 1 {
|
|
t.Errorf("removed = %d, want 1", removed)
|
|
}
|
|
if fk.deletes != 1 {
|
|
t.Errorf("tree-deletes = %d, want 1", fk.deletes)
|
|
}
|
|
if got := fk.commits - commitsBefore; got != 1 {
|
|
t.Errorf("prune commits = %d, want 1 (single batched commit)", got)
|
|
}
|
|
}
|
|
|
|
// TestGitHubRelayStatePersistence: known map survives a fresh relay
|
|
// instance pointed at the same statePath.
|
|
func TestGitHubRelayStatePersistence(t *testing.T) {
|
|
newFakeGitHub(t)
|
|
dir := t.TempDir()
|
|
statePath := dir + "/gh_relay_state.json"
|
|
|
|
cfg := GitHubRelayConfig{Enabled: true, Token: "tok", Repo: "owner/repo", MaxBytes: 1 << 20, TTLMinutes: 60, StatePath: statePath}
|
|
r1 := NewGitHubRelay(cfg, "ex.test", "pp")
|
|
if err := r1.Upload(context.Background(), []byte("survive me")); err != nil {
|
|
t.Fatalf("upload: %v", err)
|
|
}
|
|
if err := r1.Flush(context.Background()); err != nil {
|
|
t.Fatalf("flush: %v", err)
|
|
}
|
|
body := []byte("survive me")
|
|
if !r1.Has(int64(len(body)), crc32.ChecksumIEEE(body)) {
|
|
t.Fatal("r1 should know the file after flush")
|
|
}
|
|
|
|
r2 := NewGitHubRelay(cfg, "ex.test", "pp")
|
|
if !r2.Has(int64(len(body)), crc32.ChecksumIEEE(body)) {
|
|
t.Fatal("r2 should have loaded the file from statePath")
|
|
}
|
|
}
|
|
|