access_test.mx raw
1 package protocol
2
3 import "testing"
4
5 const (
6 mintURL = "https://mint.example.com"
7 hostPub = "9999999999999999999999999999999999999999999999999999999999999999"
8 retailPk = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
9 filtHash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
10 aggID = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
11 aggID2 = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
12 )
13
14 // The access filter example from FEATURES_ACCESS.md / PROTOCOL_FLOWS A1.
15 func TestAccessFilterRoundTrip(t *testing.T) {
16 asset := &Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d"}
17 var want [][]string
18 want = appendKV(want, TagD, FanFilterD(asset))
19 want = appendKV(want, TagFilter, FilterBloom)
20 want = appendKV(want, TagFp, "1e-9")
21 want = appendKV(want, TagN, "1234")
22 want = appendKV(want, TagK, "30")
23 want = appendKV(want, TagX, filtHash)
24 want = appendKV(want, TagServer, "https://blossom.musiquay.com")
25 want = appendKV(want, TagMint, mintURL)
26 want = appendKV(want, TagRetailer, retailPk)
27 want = appendKV(want, TagExpiration, "1799999999")
28
29 f, err := ParseAccessFilter(want, "")
30 if err != nil {
31 t.Fatalf("parse: %s", err.Error())
32 }
33 if verr := f.Validate(); verr != nil {
34 t.Fatalf("validate: %s", verr.Error())
35 }
36 if f.D != "fan:32210:"|ownerPub|":track-d" {
37 t.Fatalf("d = %q", f.D)
38 }
39 if len(f.Mints) != 1 || f.Mints[0] != mintURL || len(f.Retailers) != 1 {
40 t.Fatalf("mints=%v retailers=%v", f.Mints, f.Retailers)
41 }
42 if got := f.Tags(); !sameTags(got, want) {
43 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
44 }
45 }
46
47 func TestFilterTierForms(t *testing.T) {
48 asset := &Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d"}
49
50 fan, err := ParseFilterD(FanFilterD(asset))
51 if err != nil {
52 t.Fatalf("fan: %s", err.Error())
53 }
54 if fan.Tier != TierFan || fan.Asset == nil || fan.Asset.D != "track-d" {
55 t.Fatalf("fan tier = %+v", fan)
56 }
57 if fan.String() != FanFilterD(asset) {
58 t.Fatalf("fan d round trip = %q", fan.String())
59 }
60
61 retail, err := ParseFilterD(RetailFilterD(asset))
62 if err != nil {
63 t.Fatalf("retail: %s", err.Error())
64 }
65 if retail.Tier != TierRetail || retail.Asset == nil {
66 t.Fatalf("retail tier = %+v", retail)
67 }
68
69 subD := SubFilterD(retailPk, "channel-one")
70 sub, err := ParseFilterD(subD)
71 if err != nil {
72 t.Fatalf("sub: %s", err.Error())
73 }
74 if sub.Tier != TierSub || sub.Retailer != retailPk || sub.Channel != "channel-one" {
75 t.Fatalf("sub tier = %+v", sub)
76 }
77 if sub.String() != subD {
78 t.Fatalf("sub d round trip = %q", sub.String())
79 }
80
81 for _, bad := range []string{
82 "",
83 "fan",
84 "owner:" | ownerPub | ":track-d",
85 "fan:32210:short:track-d",
86 "sub:" | retailPk,
87 "sub:short:chan",
88 "sub:" | retailPk | ":",
89 } {
90 if _, berr := ParseFilterD(bad); berr == nil {
91 t.Errorf("ParseFilterD(%q) accepted a bad value", bad)
92 }
93 }
94 }
95
96 // A subscription filter with no catalog coverage is what the spec leaves open:
97 // it warns rather than failing, and the open decision is visible.
98 func TestSubscriptionFilterCatalog(t *testing.T) {
99 asset := &Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d"}
100 f := &AccessFilter{
101 D: SubFilterD(retailPk, "chan"), Filter: FilterBloom,
102 Fp: "1e-9", N: "10", K: "30", X: filtHash,
103 }
104 if verr := f.Validate(); verr != nil {
105 t.Fatalf("validate: %s", verr.Error())
106 }
107 w := f.Warnings()
108 found := false
109 for i := range w {
110 if w[i] == "subscription filter with no catalog coverage" {
111 found = true
112 }
113 }
114 if !found {
115 t.Fatalf("warnings = %s", joinWarnings(w))
116 }
117 f.Catalog = push(f.Catalog, asset.String())
118 tags := f.Tags()
119 if len(tags) != 7 {
120 t.Fatalf("tag count with catalog = %d", len(tags))
121 }
122 var hasCatalog bool = false
123 for i := range tags {
124 if tags[i][0] == TagCatalog && tags[i][1] == asset.String() {
125 hasCatalog = true
126 }
127 }
128 if !hasCatalog {
129 t.Fatal("catalog coverage was not emitted")
130 }
131 }
132
133 func TestAccessFilterValidateRejects(t *testing.T) {
134 asset := &Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d"}
135 base := func() (f *AccessFilter) {
136 return &AccessFilter{
137 D: FanFilterD(asset), Filter: FilterBloom,
138 Fp: "1e-9", N: "1", K: "30", X: filtHash,
139 }
140 }
141 f := base()
142 f.X = "nothash"
143 if verr := f.Validate(); verr == nil {
144 t.Fatal("a bad blob hash was accepted")
145 }
146 f2 := base()
147 f2.Filter = "cuckoo"
148 if verr := f2.Validate(); verr == nil {
149 t.Fatal("an unknown filter structure was accepted")
150 }
151 f3 := base()
152 f3.Fp = ""
153 if verr := f3.Validate(); verr == nil {
154 t.Fatal("a filter with no rate was accepted")
155 }
156 f4 := base()
157 f4.Retailers = push(f4.Retailers, "short")
158 if verr := f4.Validate(); verr == nil {
159 t.Fatal("a short retailer pubkey was accepted")
160 }
161 f5 := base()
162 f5.D = "owner:" | ownerPub | ":" | asset.String()
163 if verr := f5.Validate(); verr == nil {
164 t.Fatal("a filter with a bad tier was accepted")
165 }
166 }
167
168 // The purchase order from PROTOCOL_FLOWS A4.
169 func TestPurchaseOrderRoundTrip(t *testing.T) {
170 var want [][]string
171 want = appendTag(want, (&Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d", Relay: "wss://relay.musiquay.com"}).Tag())
172 want = appendKV(want, TagP, ownerPub)
173 want = appendKV(want, TagTier, TierFan)
174 want = appendTag(want, (&Price{Amount: "500", Unit: "sat"}).Tag())
175 want = appendKV(want, TagPayment, PaymentCashu)
176 want = appendKV(want, TagMint, mintURL)
177 want = appendKV(want, TagToken, "cashuBtoken")
178
179 o, err := ParsePurchaseOrder(want, "")
180 if err != nil {
181 t.Fatalf("parse: %s", err.Error())
182 }
183 if verr := o.Validate(); verr != nil {
184 t.Fatalf("validate: %s", verr.Error())
185 }
186 if o.Asset == nil || o.Asset.Kind != KindTrack {
187 t.Fatalf("asset = %v", o.Asset)
188 }
189 if o.Price == nil || o.Price.Amount != "500" || o.Price.Unit != "sat" {
190 t.Fatalf("price = %v", o.Price)
191 }
192 if got := o.Tags(); !sameTags(got, want) {
193 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
194 }
195 }
196
197 func TestPurchaseOrderValidate(t *testing.T) {
198 asset := &Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d"}
199 base := func() (o *PurchaseOrder) {
200 return &PurchaseOrder{
201 Asset: asset, Seller: ownerPub, Tier: TierFan,
202 Price: &Price{Amount: "500", Unit: "sat"},
203 Payment: PaymentCashu, Mint: mintURL, Token: "cashuBx",
204 }
205 }
206 if verr := base().Validate(); verr != nil {
207 t.Fatalf("valid order rejected: %s", verr.Error())
208 }
209 o := base()
210 o.Seller = ""
211 if verr := o.Validate(); verr == nil {
212 t.Fatal("an order with no seller was accepted")
213 }
214 o2 := base()
215 o2.Payment = ""
216 if verr := o2.Validate(); verr == nil {
217 t.Fatal("an order with no payment leg was accepted")
218 }
219 o3 := base()
220 o3.Token = ""
221 if verr := o3.Validate(); verr == nil {
222 t.Fatal("a cashu order with no token was accepted")
223 }
224 o4 := base()
225 o4.Tier = TierSub
226 if verr := o4.Validate(); verr == nil {
227 t.Fatal("a subscription order with no channel or period was accepted")
228 }
229 o4.Channel = "chan"
230 o4.Period = "2592000"
231 if verr := o4.Validate(); verr != nil {
232 t.Fatalf("a complete subscription order was rejected: %s", verr.Error())
233 }
234 o5 := base()
235 o5.Payment = PaymentLightning
236 o5.Token = ""
237 o5.Mint = ""
238 if verr := o5.Validate(); verr != nil {
239 t.Fatalf("a lightning order was rejected: %s", verr.Error())
240 }
241 // The order is private: it must never be published to a relay.
242 if !IsPrivate(KindPurchaseOrder) {
243 t.Fatal("the order kind is not private")
244 }
245 }
246
247 // The delivery receipt from PROTOCOL_FLOWS D1.
248 func TestDeliveryReceiptRoundTrip(t *testing.T) {
249 var want [][]string
250 want = appendTag(want, (&Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d"}).Tag())
251 want = appendKV(want, TagP, buyerPub)
252 want = appendKV(want, TagX, mp3Hash)
253 want = appendKV(want, TagServer, "https://blossom.musiquay.com")
254 want = appendKV(want, TagMode, ModeDownload)
255 want = appendKV(want, TagStart, "1758130000")
256 want = appendKV(want, TagEnd, "1758130000")
257 want = appendKV(want, TagBytes, "48216387")
258 want = appendKV(want, TagAmountSats, "0")
259 want = appendKV(want, TagPaid, "tokenhash")
260
261 r, err := ParseDeliveryReceipt(want, "")
262 if err != nil {
263 t.Fatalf("parse: %s", err.Error())
264 }
265 if verr := r.Validate(); verr != nil {
266 t.Fatalf("validate: %s", verr.Error())
267 }
268 if r.Mode != ModeDownload || r.AmountSats != "0" || r.TokenHashesUsed() != 1 {
269 t.Fatalf("receipt = %+v", r)
270 }
271 if got := r.Tags(); !sameTags(got, want) {
272 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
273 }
274 // A stream receipt with a settled amount.
275 r2 := &DeliveryReceipt{
276 Asset: r.Asset, Buyer: buyerPub, Blob: mp3Hash,
277 Mode: ModeStream, Start: "1", End: "601", Bytes: "100", AmountSats: "10",
278 }
279 if verr := r2.Validate(); verr != nil {
280 t.Fatalf("stream receipt rejected: %s", verr.Error())
281 }
282 r3 := &DeliveryReceipt{Asset: r.Asset, Buyer: buyerPub, Blob: mp3Hash, Mode: "burn", AmountSats: "0"}
283 if verr := r3.Validate(); verr == nil {
284 t.Fatal("an unknown mode was accepted")
285 }
286 }
287
288 // The host aggregate from PROTOCOL_FLOWS D2: listeners is marked approximate.
289 func TestHostAggregateRoundTrip(t *testing.T) {
290 var want [][]string
291 want = appendTag(want, (&Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d"}).Tag())
292 want = appendKV(want, TagStart, "1758130000")
293 want = appendKV(want, TagEnd, "1760808400")
294 want = appendKV(want, TagPlays, "48213")
295 want = appendKV(want, TagDownloads, "3921")
296 want = appendKV(want, TagBytes, "21474836480")
297 want = appendKV(want, TagSats, "1510000")
298 lst := []string{:3}
299 lst[0] = TagListeners
300 lst[1] = "11054"
301 lst[2] = Approx
302 want = appendTag(want, lst)
303
304 a, err := ParseHostAggregate(want, "")
305 if err != nil {
306 t.Fatalf("parse: %s", err.Error())
307 }
308 if verr := a.Validate(); verr != nil {
309 t.Fatalf("validate: %s", verr.Error())
310 }
311 if !a.Approx || a.Listeners != "11054" {
312 t.Fatalf("aggregate = %+v", a)
313 }
314 if got := a.Tags(); !sameTags(got, want) {
315 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
316 }
317 // A bare listener count reads as exact and is rejected.
318 a2 := &HostAggregate{Asset: a.Asset, Start: "1", End: "2", Listeners: "10"}
319 if verr := a2.Validate(); verr == nil {
320 t.Fatal("an unmarked listener count was accepted")
321 }
322 }
323
324 // The publisher rollup from PROTOCOL_FLOWS D3: the e tags carry the host
325 // pubkey at index 3 so the authenticity chain is verifiable.
326 func TestPublisherRollupRoundTrip(t *testing.T) {
327 var want [][]string
328 want = appendTag(want, (&Address{Kind: KindTrack, Pubkey: ownerPub, D: "track-d"}).Tag())
329 want = appendKV(want, TagStart, "1758130000")
330 want = appendKV(want, TagEnd, "1760808400")
331 want = appendTag(want, (&AggregateRef{ID: aggID, Host: hostPub}).Tag())
332 want = appendTag(want, (&AggregateRef{ID: aggID2, Host: retailPk}).Tag())
333 want = appendKV(want, TagReceipts, "52134")
334 want = appendKV(want, TagPlays, "55123")
335 want = appendKV(want, TagDownloads, "4120")
336 want = appendKV(want, TagBytes, "22817013760")
337 want = appendKV(want, TagSats, "1730000")
338
339 r, err := ParsePublisherRollup(want, "")
340 if err != nil {
341 t.Fatalf("parse: %s", err.Error())
342 }
343 if verr := r.Validate(); verr != nil {
344 t.Fatalf("validate: %s", verr.Error())
345 }
346 if len(r.Aggregates) != 2 || r.Aggregates[0].Host != hostPub {
347 t.Fatalf("aggregates = %v", r.Aggregates)
348 }
349 if ref := (&AggregateRef{ID: aggID, Host: hostPub}).Tag(); len(ref) != 4 || ref[2] != "" {
350 t.Fatalf("aggregate ref tag = %s", joinTags([][]string{ref}))
351 }
352 if got := r.Tags(); !sameTags(got, want) {
353 t.Fatalf("round trip differs:\n got %s\nwant %s", joinTags(got), joinTags(want))
354 }
355 r2 := &PublisherRollup{Asset: r.Asset, Start: "1", End: "2"}
356 r2.Aggregates = push(r2.Aggregates, AggregateRef{ID: "short", Host: hostPub})
357 if verr := r2.Validate(); verr == nil {
358 t.Fatal("a short aggregate id was accepted")
359 }
360 }
361