| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 12 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 13 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 14 | ) |
| 15 | |
| 16 | const collaborationCoordinateRepo = "collaboration" |
| 17 | |
| 18 | func collaborationFixtureRepo(t *testing.T, tenant *Tenant) (string, string) { |
| 19 | t.Helper() |
| 20 | ctx := context.Background() |
| 21 | ownerSecret := strings.Repeat("0" , 63) + "1" |
| 22 | owner := tenant.Policy().Owner |
| 23 | serviceURL, err := url.Parse(tenant.RelayURL()) |
| 24 | if err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | cloneURL := "http://" + serviceURL.Host + "/" + owner + "/" + collaborationCoordinateRepo + ".git" |
| 28 | relayURL := "ws://" + serviceURL.Host |
| 29 | announcement := event.Event{ |
| 30 | Kind: event.KIND_REPO, |
| 31 | PubKey: owner, |
| 32 | CreatedAt: 100, |
| 33 | Tags: [][]string{{"d" , collaborationCoordinateRepo}, {"clone" , cloneURL}, {"relays" , relayURL}}, |
| 34 | } |
| 35 | if err := event.Sign(&announcement, ownerSecret); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | if _, err := tenant.router.Publish(ctx, announcement, relay.Session{PubKeys: []string{owner}}); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | return owner, "30617:" + owner + ":" + collaborationCoordinateRepo |
| 42 | } |
| 43 | |
| 44 | func signedCollaborationEvent(t *testing.T, secret string, kind int, created int64, tags [][]string, content string) event.Event { |
| 45 | t.Helper() |
| 46 | e := event.Event{Kind: kind, CreatedAt: created, Tags: tags, Content: content} |
| 47 | if err := event.Sign(&e, secret); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | return e |
| 51 | } |
| 52 | |
| 53 | func saveCollaborationEvents(t *testing.T, tenant *Tenant, rows ...event.Event) { |
| 54 | t.Helper() |
| 55 | for _, row := range rows { |
| 56 | if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func browseCollaboration(t *testing.T, tenant *Tenant, actor, method string, args map[string]any) map[string]any { |
| 63 | t.Helper() |
| 64 | params, err := json.Marshal(args) |
| 65 | if err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{params}) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | result, ok := value.(map[string]any) |
| 73 | if !ok { |
| 74 | t.Fatalf("%s result = %T, want object" , method, value) |
| 75 | } |
| 76 | return result |
| 77 | } |
| 78 | |
| 79 | func browseCollaborationDetail(t *testing.T, tenant *Tenant, actor, method string, args map[string]any) collaborationDetail { |
| 80 | t.Helper() |
| 81 | params, err := json.Marshal(args) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{params}) |
| 86 | if err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | detail, ok := value.(collaborationDetail) |
| 90 | if !ok { |
| 91 | t.Fatalf("%s result = %T, want collaboration detail" , method, value) |
| 92 | } |
| 93 | return detail |
| 94 | } |
| 95 | |
| 96 | func TestCollaborationListPaginationKeepsTimestampTies(t *testing.T) { |
| 97 | _, tenant := testTenant(t) |
| 98 | p := tenant.Policy() |
| 99 | p.Features.Grasp = true |
| 100 | p.Features.Grasp02 = true |
| 101 | p.Features.Grasp05 = true |
| 102 | p.GuestReplies = true |
| 103 | if err := tenant.applyPolicy(context.Background(), p); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | owner, coordinate := collaborationFixtureRepo(t, tenant) |
| 107 | secret := strings.Repeat("1" , 63) + "2" |
| 108 | var ids []string |
| 109 | for i := 0; i < 9; i++ { |
| 110 | row := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 200, |
| 111 | [][]string{{"a" , coordinate}, {"subject" , fmt.Sprintf("Issue %02d" , i)}}, "body" ) |
| 112 | saveCollaborationEvents(t, tenant, row) |
| 113 | ids = append(ids, row.ID) |
| 114 | } |
| 115 | seen := map[string]bool{} |
| 116 | cursor := "" |
| 117 | for page := 0; page < 20; page++ { |
| 118 | result := browseCollaboration(t, tenant, owner, "browseissues" , map[string]any{"owner" : owner, "repo" : collaborationCoordinateRepo, "limit" : 2, "cursor" : cursor}) |
| 119 | items, ok := result["items" ].([]collaborationItem) |
| 120 | if !ok { |
| 121 | t.Fatalf("items = %T" , result["items" ]) |
| 122 | } |
| 123 | for _, item := range items { |
| 124 | id := item.ID |
| 125 | if seen[id] { |
| 126 | t.Fatalf("duplicate item %s" , id) |
| 127 | } |
| 128 | seen[id] = true |
| 129 | } |
| 130 | cursor, _ = result["next_cursor" ].(string) |
| 131 | if cursor == "" { |
| 132 | break |
| 133 | } |
| 134 | } |
| 135 | if len(seen) != len(ids) { |
| 136 | t.Fatalf("pagination returned %d issues, want %d" , len(seen), len(ids)) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestCollaborationSearchAndStatusReachPastFirstPage(t *testing.T) { |
| 141 | _, tenant := testTenant(t) |
| 142 | p := tenant.Policy() |
| 143 | p.Features.Grasp = true |
| 144 | p.Features.Grasp02 = true |
| 145 | p.Features.Grasp05 = true |
| 146 | p.GuestReplies = true |
| 147 | if err := tenant.applyPolicy(context.Background(), p); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | owner, coordinate := collaborationFixtureRepo(t, tenant) |
| 151 | secret := strings.Repeat("2" , 63) + "3" |
| 152 | for i := 0; i < 60; i++ { |
| 153 | row := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, int64(300+i), |
| 154 | [][]string{{"a" , coordinate}, {"subject" , fmt.Sprintf("Routine issue %02d" , i)}}, "ordinary body" ) |
| 155 | if i == 59 { |
| 156 | row.Tags = append(row.Tags, []string{"t" , "security" }) |
| 157 | row.Content = "Security regression" |
| 158 | } |
| 159 | saveCollaborationEvents(t, tenant, row) |
| 160 | if i == 59 { |
| 161 | status := signedCollaborationEvent(t, strings.Repeat("0" , 63)+"1" , 1632, 500, |
| 162 | [][]string{{"e" , row.ID}}, "closed" ) |
| 163 | saveCollaborationEvents(t, tenant, status) |
| 164 | } |
| 165 | } |
| 166 | result := browseCollaboration(t, tenant, owner, "browseissues" , map[string]any{"owner" : owner, "repo" : collaborationCoordinateRepo, "limit" : 2, "q" : "security" , "label" : "security" }) |
| 167 | items := result["items" ].([]collaborationItem) |
| 168 | if len(items) != 1 || items[0].Status != "closed" { |
| 169 | t.Fatalf("search result = %#v" , result) |
| 170 | } |
| 171 | filtered := browseCollaboration(t, tenant, owner, "browseissues" , map[string]any{"owner" : owner, "repo" : collaborationCoordinateRepo, "limit" : 2, "state" : "closed" }) |
| 172 | if len(filtered["items" ].([]collaborationItem)) != 1 { |
| 173 | t.Fatalf("status filter = %#v" , filtered) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestCollaborationReplyPaginationUsesCursorAndFiltersMalformedEvents(t *testing.T) { |
| 178 | _, tenant := testTenant(t) |
| 179 | p := tenant.Policy() |
| 180 | p.Features.Grasp = true |
| 181 | p.Features.Grasp02 = true |
| 182 | p.Features.Grasp05 = true |
| 183 | p.GuestReplies = true |
| 184 | if err := tenant.applyPolicy(context.Background(), p); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | owner, coordinate := collaborationFixtureRepo(t, tenant) |
| 188 | authorSecret := strings.Repeat("8" , 63) + "9" |
| 189 | author, err := event.PublicKey(authorSecret) |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | issue := signedCollaborationEvent(t, authorSecret, event.KIND_GIT_ISSUE, 800, |
| 194 | [][]string{{"a" , coordinate}, {"subject" , "Crowded thread" }}, "body" ) |
| 195 | rows := []event.Event{issue} |
| 196 | for i := 0; i < 205; i++ { |
| 197 | rows = append(rows, signedCollaborationEvent(t, authorSecret, 1111, 801, |
| 198 | [][]string{{"E" , issue.ID}, {"K" , "1621" }, {"P" , author}}, fmt.Sprintf("reply %03d" , i))) |
| 199 | } |
| 200 | rows = append(rows, signedCollaborationEvent(t, authorSecret, 1111, 801, |
| 201 | [][]string{{"E" , issue.ID}, {"K" , "1618" }, {"P" , author}}, "wrong root kind" )) |
| 202 | rows = append(rows, signedCollaborationEvent(t, authorSecret, 1111, 801, |
| 203 | [][]string{{"E" , issue.ID}, {"K" , "1621" }, {"P" , strings.Repeat("a" , 64)}}, "wrong root author" )) |
| 204 | saveCollaborationEvents(t, tenant, rows...) |
| 205 | seen := map[string]bool{} |
| 206 | cursor := "" |
| 207 | for page := 0; page < 20; page++ { |
| 208 | result := browseCollaborationDetail(t, tenant, owner, "browseissue" , map[string]any{"owner" : owner, "repo" : collaborationCoordinateRepo, "event" : issue.ID, "limit" : 37, "cursor" : cursor}) |
| 209 | if len(result.Replies) > 37 { |
| 210 | t.Fatalf("reply page has %d entries" , len(result.Replies)) |
| 211 | } |
| 212 | for _, reply := range result.Replies { |
| 213 | if seen[reply.ID] { |
| 214 | t.Fatalf("duplicate reply %s" , reply.ID) |
| 215 | } |
| 216 | seen[reply.ID] = true |
| 217 | } |
| 218 | cursor = result.ReplyCursor |
| 219 | if cursor == "" { |
| 220 | break |
| 221 | } |
| 222 | } |
| 223 | if len(seen) != 205 { |
| 224 | t.Fatalf("reply pagination returned %d replies, want 205" , len(seen)) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | func TestCollaborationDetailSeparatesRepliesAndAuthorizesStatus(t *testing.T) { |
| 229 | _, tenant := testTenant(t) |
| 230 | p := tenant.Policy() |
| 231 | p.Features.Grasp = true |
| 232 | p.Features.Grasp02 = true |
| 233 | p.Features.Grasp05 = true |
| 234 | p.GuestReplies = true |
| 235 | if err := tenant.applyPolicy(context.Background(), p); err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | owner, coordinate := collaborationFixtureRepo(t, tenant) |
| 239 | authorSecret := strings.Repeat("3" , 63) + "4" |
| 240 | author, err := event.PublicKey(authorSecret) |
| 241 | if err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | issue := signedCollaborationEvent(t, authorSecret, event.KIND_GIT_ISSUE, 600, |
| 245 | [][]string{{"a" , coordinate}, {"subject" , "Needs review" }, {"t" , "bug" }}, "Please investigate" ) |
| 246 | reply := signedCollaborationEvent(t, strings.Repeat("4" , 63)+"5" , 1111, 601, |
| 247 | [][]string{{"e" , issue.ID}, {"E" , issue.ID}, {"K" , "1621" }, {"P" , author}}, "I can reproduce this" ) |
| 248 | status := signedCollaborationEvent(t, authorSecret, 1631, 602, [][]string{{"e" , issue.ID}}, "resolved" ) |
| 249 | forged := signedCollaborationEvent(t, strings.Repeat("5" , 63)+"6" , 1632, 603, [][]string{{"e" , issue.ID}}, "closed" ) |
| 250 | saveCollaborationEvents(t, tenant, issue, reply, status, forged) |
| 251 | for _, actor := range []string{owner, author} { |
| 252 | result := browseCollaborationDetail(t, tenant, actor, "browseissue" , map[string]any{"owner" : owner, "repo" : collaborationCoordinateRepo, "event" : issue.ID}) |
| 253 | if !result.CanStatus { |
| 254 | t.Fatalf("actor %s cannot status: %#v" , actor, result) |
| 255 | } |
| 256 | if len(result.Replies) != 1 || len(result.Statuses) != 1 { |
| 257 | t.Fatalf("authorized detail thread leaked or duplicated events: %#v" , result) |
| 258 | } |
| 259 | } |
| 260 | unauthorized := strings.Repeat("6" , 63) + "7" |
| 261 | unauthorizedKey, err := event.PublicKey(unauthorized) |
| 262 | if err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | result := browseCollaborationDetail(t, tenant, unauthorizedKey, "browseissue" , map[string]any{"owner" : owner, "repo" : collaborationCoordinateRepo, "event" : issue.ID}) |
| 266 | if result.CanStatus { |
| 267 | t.Fatalf("unauthorized actor can status: %#v" , result) |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | func TestPrivateCollaborationRejectsAnonymousAndWrongRepository(t *testing.T) { |
| 272 | ctx := context.Background() |
| 273 | secret := strings.Repeat("7" , 64) |
| 274 | owner, err := event.PublicKey(secret) |
| 275 | if err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | _, tenant, server := privatePeerTenant(t, "collaboration-private" , owner) |
| 279 | repo, _, _ := seedPrivateRepository(t, tenant, owner, secret, "private" , server.URL) |
| 280 | coordinate := "30617:" + owner + ":" + repo.Identifier |
| 281 | issue := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 700, |
| 282 | [][]string{{"a" , coordinate}, {"subject" , "Private issue" }}, "secret" ) |
| 283 | saveCollaborationEvents(t, tenant, issue) |
| 284 | params := []json.RawMessage{json.RawMessage(fmt.Sprintf(`{"owner":%q,"repo":%q,"event":%q}` , owner, repo.Identifier, issue.ID))} |
| 285 | if _, err := tenant.Execute(ctx, "" , "browseissue" , params); err == nil { |
| 286 | t.Fatal("anonymous private collaboration detail was exposed" ) |
| 287 | } |
| 288 | _, _, _ = seedPrivateRepository(t, tenant, owner, secret, "other" , server.URL) |
| 289 | if _, err := tenant.Execute(ctx, owner, "browseissue" , []json.RawMessage{json.RawMessage(fmt.Sprintf(`{"owner":%q,"repo":%q,"event":%q}` , owner, "other" , issue.ID))}); err == nil { |
| 290 | t.Fatal("collaboration root crossed repository boundary" ) |
| 291 | } |
| 292 | } |