1package daemon23import (4 "context"5 "encoding/json"6 "fmt"7 "net/http"8 "net/http/httptest"9 "os"10 "os/exec"11 "path/filepath"12 "strings"13 "testing"14 "time"1516 "github.com/FelineStateMachine/tinyrelay/internal/event"17 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"18 "github.com/FelineStateMachine/tinyrelay/internal/relay"19 "github.com/FelineStateMachine/tinyrelay/internal/storage"20)2122func TestPrivatePeerGitAndMetadataSyncAcrossTenants(t *testing.T) {23 ctx := context.Background()24 sourceSecret := strings.Repeat("1", 64)25 destSecret := strings.Repeat("2", 64)26 sourceOwner, err := event.PublicKey(sourceSecret)27 if err != nil {28 t.Fatal(err)29 }30 destOwner, err := event.PublicKey(destSecret)31 if err != nil {32 t.Fatal(err)33 }34 _, source, sourceServer := privatePeerTenant(t, "source", sourceOwner)35 _, dest, destServer := privatePeerTenant(t, "dest", destOwner)36 configurePrivatePeer(t, source, sourceOwner, dest.records.PublicKey(), destServer.URL)37 configurePrivatePeer(t, dest, destOwner, source.records.PublicKey(), sourceServer.URL)38 if _, err := source.community.Execute(ctx, sourceOwner, "setmember", []json.RawMessage{json.RawMessage(fmt.Sprintf(`%q`, destOwner)), json.RawMessage(`{"name":"peer-owner"}`)}); err != nil {39 t.Fatal(err)40 }4142 identifier := "private"43 repo, announcement, state := seedPrivateRepository(t, dest, destOwner, destSecret, identifier, destServer.URL)44 probe, err := destServer.Client().Get(repo.Clone[0] + "/info/refs?service=git-upload-pack")45 if err != nil {46 t.Fatal(err)47 }48 probe.Body.Close()49 if probe.StatusCode != 401 {50 t.Fatalf("private clone probe status=%d url=%s", probe.StatusCode, repo.Clone[0])51 }52 authHeader, err := source.privateHTTPAuth(ctx, "GET", repo.Clone[0]+"/info/refs?service=git-upload-pack", "")53 if err != nil {54 t.Fatal(err)55 }56 request, err := http.NewRequest(http.MethodGet, repo.Clone[0]+"/info/refs?service=git-upload-pack", nil)57 if err != nil {58 t.Fatal(err)59 }60 request.Header.Set("Authorization", authHeader)61 checked, err := destServer.Client().Do(request)62 if err != nil {63 t.Fatal(err)64 }65 checked.Body.Close()66 if checked.StatusCode != 200 {67 t.Fatalf("authenticated clone probe status=%d url=%s owner=%s", checked.StatusCode, repo.Clone[0], repo.Owner)68 }69 if err := source.gitSync(ctx, repo); err != nil {70 t.Fatalf("private Git sync: %v", err)71 }72 var untrustedAuth string73 untrustedPeer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {74 untrustedAuth = r.Header.Get("Authorization")75 http.NotFound(w, r)76 }))77 t.Cleanup(untrustedPeer.Close)78 publicRepo := repo79 // Use an empty local repository so this actually exercises the peer path;80 // complete local objects intentionally need no network request.81 publicRepo.Identifier = "unconfigured-peer"82 publicRepo.Clone = []string{untrustedPeer.URL + "/private.git"}83 if err := source.git.FetchMissing(ctx, publicRepo, publicRepo.Clone, publicRepo.Refs); err == nil {84 t.Fatal("unconfigured public peer unexpectedly supplied private Git objects")85 }86 if untrustedAuth != "" {87 t.Fatalf("unconfigured public peer received credentials: %q", untrustedAuth)88 }89 if !gitObjectExists(t, source.meta.Paths.Git, repo, repo.Refs["refs/heads/main"]) {90 t.Fatal("source tenant did not receive the signed Git object")91 }92 if err := source.gitEventSync(ctx, repo); err != nil {93 t.Fatalf("private metadata sync: %v", err)94 }95 for _, item := range []event.Event{announcement} {96 rows, err := source.store.Query(ctx, event.Filter{IDs: []string{item.ID}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})97 if err != nil || len(rows.Events) != 1 {98 t.Fatalf("metadata event %s missing after sync: %v", item.ID, err)99 }100 }101 var rawStateID string102 if err := source.store.DB().QueryRowContext(ctx, "SELECT id FROM events WHERE kind=30618 AND pubkey=? AND d=?", state.PubKey, identifier).Scan(&rawStateID); err != nil || rawStateID != state.ID {103 t.Fatalf("state metadata was not persisted: id=%q err=%v", rawStateID, err)104 }105106 if err := source.git.Reload(ctx); err != nil {107 t.Fatal(err)108 }109 if !gitObjectExists(t, source.meta.Paths.Git, repo, repo.Refs["refs/heads/main"]) {110 t.Fatal("source Git object disappeared after reload")111 }112 var publicAuth string113 guardPeer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {114 publicAuth = r.Header.Get("Authorization")115 if r.URL.Path == "/" {116 w.Header().Set("Content-Type", "application/nostr+json")117 _, _ = w.Write([]byte(`{"supported_grasps":["GRASP-08"]}`))118 return119 }120 http.NotFound(w, r)121 }))122 t.Cleanup(guardPeer.Close)123 publicPolicy := source.Policy()124 publicPolicy.Features.Grasp08 = false125 publicPolicy.Reads = "open"126 publicPolicy.PrivatePeers = []string{guardPeer.URL}127 if err := source.applyPolicy(ctx, publicPolicy); err == nil {128 t.Fatal("private tenant policy was made public while private repository metadata existed")129 }130 _, publicTenant := testTenant(t)131 publicPolicy = publicTenant.Policy()132 publicPolicy.Features.Grasp = true133 publicPolicy.PrivatePeers = []string{guardPeer.URL}134 if err := publicTenant.applyPolicy(ctx, publicPolicy); err != nil {135 t.Fatal(err)136 }137 publicCheckRepo := repo138 publicCheckRepo.Identifier = "public-profile-peer"139 publicCheckRepo.Clone = []string{guardPeer.URL + "/private.git"}140 if err := publicTenant.gitSync(ctx, publicCheckRepo); err == nil {141 t.Fatal("public profile unexpectedly fetched from a private peer")142 }143 if publicAuth != "" {144 t.Fatalf("public profile sent private peer credentials: %q", publicAuth)145 }146}147148func privatePeerTenant(t *testing.T, name, owner string) (*App, *Tenant, *httptest.Server) {149 t.Helper()150 ctx := context.Background()151 app, err := New(ctx, Config{DataDir: t.TempDir(), DefaultTenant: name, AllowPrivateRelays: true})152 if err != nil {153 t.Fatal(err)154 }155 t.Cleanup(func() { _ = app.Close(ctx) })156 meta, err := app.Create(ctx, CreateOptions{Name: name, Owner: owner, Template: "default"})157 if err != nil {158 t.Fatal(err)159 }160 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { app.ServeHTTP(w, r) }))161 t.Cleanup(server.Close)162 tenant, err := app.tenant(ctx, meta, server.URL)163 if err != nil {164 t.Fatal(err)165 }166 p := tenant.Policy()167 p.Features.Grasp = true168 p.Features.Grasp02 = true169 p.Features.Grasp03 = true170 p.Features.Grasp05 = true171 p.Features.Grasp08 = true172 p.Reads = "members"173 if err := tenant.applyPolicy(ctx, p); err != nil {174 t.Fatal(err)175 }176 return app, tenant, server177}178179func configurePrivatePeer(t *testing.T, tenant *Tenant, owner, member, peer string) {180 t.Helper()181 if _, err := tenant.community.Execute(context.Background(), owner, "setmember", []json.RawMessage{json.RawMessage(fmt.Sprintf(`%q`, member)), json.RawMessage(`{"name":"peer"}`)}); err != nil {182 t.Fatal(err)183 }184 p := tenant.Policy()185 p.PrivatePeers = []string{peer}186 if err := tenant.applyPolicy(context.Background(), p); err != nil {187 t.Fatal(err)188 }189}190191func seedPrivateRepository(t *testing.T, tenant *Tenant, owner, secret, identifier, baseURL string) (gitrelay.Repository, event.Event, event.Event) {192 t.Helper()193 path := filepath.Join(tenant.meta.Paths.Git, owner, identifier+".git")194 if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {195 t.Fatal(err)196 }197 peerGitRun(t, "init", "--bare", path)198 work := filepath.Join(t.TempDir(), "work")199 peerGitRun(t, "init", work)200 peerGitRun(t, "-C", work, "config", "user.email", "peer@example.test")201 peerGitRun(t, "-C", work, "config", "user.name", "Peer")202 if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("private peer\n"), 0600); err != nil {203 t.Fatal(err)204 }205 if err := os.WriteFile(filepath.Join(work, "medium.bin"), bytesOf(128<<10), 0600); err != nil {206 t.Fatal(err)207 }208 peerGitRun(t, "-C", work, "add", ".")209 peerGitRun(t, "-C", work, "commit", "-m", "private peer")210 peerGitRun(t, "-C", work, "branch", "-M", "main")211 sha := strings.TrimSpace(peerGitOutput(t, "-C", work, "rev-parse", "HEAD"))212 peerGitRun(t, "-C", work, "push", path, "HEAD:refs/heads/main")213 ownerNPub := testNpub(owner)214 cloneURL := baseURL + "/" + ownerNPub + "/" + identifier + ".git"215 announcement := event.Event{Kind: event.KIND_REPO, CreatedAt: time.Now().Unix(), Tags: [][]string{{"d", identifier}, {"clone", cloneURL}, {"relays", tenant.RelayURL()}}}216 if err := event.Sign(&announcement, secret); err != nil {217 t.Fatal(err)218 }219 if _, err := tenant.router.Publish(context.Background(), announcement, relay.Session{PubKeys: []string{owner}}); err != nil {220 t.Fatal(err)221 }222 storedAnnouncement, err := tenant.store.Query(context.Background(), event.Filter{IDs: []string{announcement.ID}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})223 if err != nil || len(storedAnnouncement.Events) != 1 {224 t.Fatalf("destination announcement was not stored: %v", err)225 }226 state := event.Event{Kind: event.KIND_REPO_STATE, CreatedAt: time.Now().Unix(), Tags: [][]string{{"d", identifier}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}}}227 if err := event.Sign(&state, secret); err != nil {228 t.Fatal(err)229 }230 if _, err := tenant.router.Publish(context.Background(), state, relay.Session{PubKeys: []string{owner}}); err != nil {231 t.Fatal(err)232 }233 repo := gitrelay.Repository{Owner: owner, Identifier: identifier, EventID: announcement.ID, Clone: []string{cloneURL}, Relays: []string{tenant.RelayURL()}, Refs: map[string]string{"refs/heads/main": sha}, Head: "ref: refs/heads/main"}234 if err := tenant.git.CommitAfterStore(context.Background(), announcement, repo); err != nil {235 t.Fatal(err)236 }237 if err := tenant.git.CommitAfterStore(context.Background(), state, repo); err != nil {238 t.Fatal(err)239 }240 return repo, announcement, state241}242243func gitObjectExists(t *testing.T, root string, repo gitrelay.Repository, object string) bool {244 t.Helper()245 path := filepath.Join(root, repo.Owner, repo.Identifier+".git")246 cmd := exec.Command("git", "--git-dir", path, "cat-file", "-e", object)247 return cmd.Run() == nil248}249250func peerGitRun(t *testing.T, args ...string) {251 t.Helper()252 if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {253 t.Fatalf("git %v: %v: %s", args, err, out)254 }255}256func peerGitOutput(t *testing.T, args ...string) string {257 t.Helper()258 out, err := exec.Command("git", args...).Output()259 if err != nil {260 t.Fatal(err)261 }262 return string(out)263}264func bytesOf(n int) []byte { return make([]byte, n) }265266func testNpub(hexKey string) string {267 data := make([]byte, len(hexKey)/2)268 for i := range data {269 fmt.Sscanf(hexKey[i*2:i*2+2], "%02x", &data[i])270 }271 converted := convertBits(data, 8, 5, true)272 charset := "qpzry9x8gf2tvdw0s3jn54khce6mua7l"273 values := converted274 values = append(values, bech32Checksum("npub", values)...)275 out := "npub1"276 for _, value := range values {277 out += string(charset[value])278 }279 return out280}281282func convertBits(data []byte, from, to uint, pad bool) []byte {283 acc, bits := 0, uint(0)284 max := (1 << to) - 1285 out := make([]byte, 0, len(data)*int(from)/int(to)+1)286 for _, value := range data {287 acc = (acc << from) | int(value)288 bits += from289 for bits >= to {290 bits -= to291 out = append(out, byte((acc>>bits)&max))292 }293 }294 if pad && bits > 0 {295 out = append(out, byte((acc<<(to-bits))&max))296 }297 return out298}299300func bech32Checksum(hrp string, data []byte) []byte {301 values := make([]int, 0, len(hrp)*2+len(data)+6)302 for _, char := range hrp {303 values = append(values, int(char>>5))304 }305 values = append(values, 0)306 for _, char := range hrp {307 values = append(values, int(char&31))308 }309 for _, value := range data {310 values = append(values, int(value))311 }312 values = append(values, 0, 0, 0, 0, 0, 0)313 polymod := bech32Polymod(values) ^ 1314 checksum := make([]byte, 6)315 for i := range checksum {316 checksum[i] = byte((polymod >> uint(5*(5-i))) & 31)317 }318 return checksum319}320321func bech32Polymod(values []int) uint32 {322 generators := [...]uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}323 value := uint32(1)324 for _, item := range values {325 top := value >> 25326 value = (value&0x1ffffff)<<5 ^ uint32(item)327 for i := 0; i < 5; i++ {328 if (top>>uint(i))&1 != 0 {329 value ^= generators[i]330 }331 }332 }333 return value334}