1
+package cli
2
+
3
+import (
4
+ "errors"
5
+ "fmt"
6
+ "net"
7
+ "net/http"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/google/uuid"
12
+ "github.com/ipfs/kubo/test/cli/harness"
13
+ "github.com/ipfs/kubo/test/cli/testutils"
14
+ "github.com/ipfs/kubo/test/cli/testutils/pinningservice"
15
+ "github.com/stretchr/testify/assert"
16
+ "github.com/stretchr/testify/require"
17
+ "github.com/tidwall/gjson"
18
+ "github.com/tidwall/sjson"
19
+)
20
+
21
+func runPinningService(t *testing.T, authToken string) (*pinningservice.PinningService, string) {
22
+ svc := pinningservice.New()
23
+ router := pinningservice.NewRouter(authToken, svc)
24
+ server := &http.Server{Handler: router}
25
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
26
+ require.NoError(t, err)
27
+ go func() {
28
+ err := server.Serve(listener)
29
+ if err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
30
+ t.Logf("Serve error: %s", err)
31
+ }
32
+ }()
33
+ t.Cleanup(func() { listener.Close() })
34
+
35
+ return svc, fmt.Sprintf("http://%s/api/v1", listener.Addr().String())
36
+}
37
+
38
+func TestRemotePinning(t *testing.T) {
39
+ t.Parallel()
40
+ authToken := "testauthtoken"
41
+
42
+ t.Run("MFS pinning", func(t *testing.T) {
43
+ t.Parallel()
44
+ node := harness.NewT(t).NewNode().Init()
45
+ node.Runner.Env["MFS_PIN_POLL_INTERVAL"] = "10ms"
46
+
47
+ _, svcURL := runPinningService(t, authToken)
48
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
49
+ node.IPFS("config", "--json", "Pinning.RemoteServices.svc.Policies.MFS.RepinInterval", `"1s"`)
50
+ node.IPFS("config", "--json", "Pinning.RemoteServices.svc.Policies.MFS.PinName", `"test_pin"`)
51
+ node.IPFS("config", "--json", "Pinning.RemoteServices.svc.Policies.MFS.Enable", "true")
52
+
53
+ node.StartDaemon()
54
+
55
+ node.IPFS("files", "cp", "/ipfs/bafkqaaa", "/mfs-pinning-test-"+uuid.NewString())
56
+ node.IPFS("files", "flush")
57
+ res := node.IPFS("files", "stat", "/", "--enc=json")
58
+ hash := gjson.Get(res.Stdout.String(), "Hash").Str
59
+
60
+ assert.Eventually(t,
61
+ func() bool {
62
+ res = node.IPFS("pin", "remote", "ls",
63
+ "--service=svc",
64
+ "--name=test_pin",
65
+ "--status=queued,pinning,pinned,failed",
66
+ "--enc=json",
67
+ )
68
+ pinnedHash := gjson.Get(res.Stdout.String(), "Cid").Str
69
+ return hash == pinnedHash
70
+ },
71
+ 10*time.Second,
72
+ 10*time.Millisecond,
73
+ )
74
+
75
+ t.Run("MFS root is repinned on CID change", func(t *testing.T) {
76
+ node.IPFS("files", "cp", "/ipfs/bafkqaaa", "/mfs-pinning-repin-test-"+uuid.NewString())
77
+ node.IPFS("files", "flush")
78
+ res = node.IPFS("files", "stat", "/", "--enc=json")
79
+ hash := gjson.Get(res.Stdout.String(), "Hash").Str
80
+ assert.Eventually(t,
81
+ func() bool {
82
+ res := node.IPFS("pin", "remote", "ls",
83
+ "--service=svc",
84
+ "--name=test_pin",
85
+ "--status=queued,pinning,pinned,failed",
86
+ "--enc=json",
87
+ )
88
+ pinnedHash := gjson.Get(res.Stdout.String(), "Cid").Str
89
+ return hash == pinnedHash
90
+ },
91
+ 10*time.Second,
92
+ 10*time.Millisecond,
93
+ )
94
+ })
95
+ })
96
+
97
+ // Pinning.RemoteServices includes API.Key, so we give it the same treatment
98
+ // as Identity,PrivKey to prevent exposing it on the network
99
+ t.Run("access token security", func(t *testing.T) {
100
+ t.Parallel()
101
+ node := harness.NewT(t).NewNode().Init()
102
+ node.IPFS("pin", "remote", "service", "add", "1", "http://example1.com", "testkey")
103
+ res := node.RunIPFS("config", "Pinning")
104
+ assert.Equal(t, 1, res.ExitCode())
105
+ assert.Contains(t, res.Stderr.String(), "cannot show or change pinning services credentials")
106
+ assert.NotContains(t, res.Stdout.String(), "testkey")
107
+
108
+ res = node.RunIPFS("config", "Pinning.RemoteServices.1.API.Key")
109
+ assert.Equal(t, 1, res.ExitCode())
110
+ assert.Contains(t, res.Stderr.String(), "cannot show or change pinning services credentials")
111
+ assert.NotContains(t, res.Stdout.String(), "testkey")
112
+
113
+ configShow := node.RunIPFS("config", "show").Stdout.String()
114
+ assert.NotContains(t, configShow, "testkey")
115
+
116
+ t.Run("re-injecting config with 'ipfs config replace' preserves the API keys", func(t *testing.T) {
117
+ node.WriteBytes("config-show", []byte(configShow))
118
+ node.IPFS("config", "replace", "config-show")
119
+ assert.Contains(t, node.ReadFile(node.ConfigFile()), "testkey")
120
+ })
121
+
122
+ t.Run("injecting config with 'ipfs config replace' with API keys returns an error", func(t *testing.T) {
123
+ // remove Identity.PrivKey to ensure error is triggered by Pinning.RemoteServices
124
+ configJSON := MustVal(sjson.Delete(configShow, "Identity.PrivKey"))
125
+ configJSON = MustVal(sjson.Set(configJSON, "Pinning.RemoteServices.1.API.Key", "testkey"))
126
+ node.WriteBytes("new-config", []byte(configJSON))
127
+ res := node.RunIPFS("config", "replace", "new-config")
128
+ assert.Equal(t, 1, res.ExitCode())
129
+ assert.Contains(t, res.Stderr.String(), "cannot change remote pinning services api info with `config replace`")
130
+ })
131
+ })
132
+
133
+ t.Run("pin remote service ls --stat", func(t *testing.T) {
134
+ t.Parallel()
135
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
136
+ _, svcURL := runPinningService(t, authToken)
137
+
138
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
139
+ node.IPFS("pin", "remote", "service", "add", "invalid-svc", svcURL+"/invalidpath", authToken)
140
+
141
+ res := node.IPFS("pin", "remote", "service", "ls", "--stat")
142
+ assert.Contains(t, res.Stdout.String(), " 0/0/0/0")
143
+
144
+ stats := node.IPFS("pin", "remote", "service", "ls", "--stat", "--enc=json").Stdout.String()
145
+ assert.Equal(t, "valid", gjson.Get(stats, `RemoteServices.#(Service == "svc").Stat.Status`).Str)
146
+ assert.Equal(t, "invalid", gjson.Get(stats, `RemoteServices.#(Service == "invalid-svc").Stat.Status`).Str)
147
+
148
+ // no --stat returns no stat obj
149
+ t.Run("no --stat returns no stat obj", func(t *testing.T) {
150
+ res := node.IPFS("pin", "remote", "service", "ls", "--enc=json")
151
+ assert.False(t, gjson.Get(res.Stdout.String(), `RemoteServices.#(Service == "svc").Stat`).Exists())
152
+ })
153
+ })
154
+
155
+ t.Run("adding service with invalid URL fails", func(t *testing.T) {
156
+ t.Parallel()
157
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
158
+
159
+ res := node.RunIPFS("pin", "remote", "service", "add", "svc", "invalid-service.example.com", "key")
160
+ assert.Equal(t, 1, res.ExitCode())
161
+ assert.Contains(t, res.Stderr.String(), "service endpoint must be a valid HTTP URL")
162
+
163
+ res = node.RunIPFS("pin", "remote", "service", "add", "svc", "xyz://invalid-service.example.com", "key")
164
+ assert.Equal(t, 1, res.ExitCode())
165
+ assert.Contains(t, res.Stderr.String(), "service endpoint must be a valid HTTP URL")
166
+ })
167
+
168
+ t.Run("unauthorized pinning service calls fail", func(t *testing.T) {
169
+ t.Parallel()
170
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
171
+ _, svcURL := runPinningService(t, authToken)
172
+
173
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, "othertoken")
174
+
175
+ res := node.RunIPFS("pin", "remote", "ls", "--service=svc")
176
+ assert.Equal(t, 1, res.ExitCode())
177
+ assert.Contains(t, res.Stderr.String(), "access denied")
178
+ })
179
+
180
+ t.Run("pinning service calls fail when there is a wrong path", func(t *testing.T) {
181
+ t.Parallel()
182
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
183
+ _, svcURL := runPinningService(t, authToken)
184
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL+"/invalid-path", authToken)
185
+
186
+ res := node.RunIPFS("pin", "remote", "ls", "--service=svc")
187
+ assert.Equal(t, 1, res.ExitCode())
188
+ assert.Contains(t, res.Stderr.String(), "404")
189
+ })
190
+
191
+ t.Run("pinning service calls fail when DNS resolution fails", func(t *testing.T) {
192
+ t.Parallel()
193
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
194
+ node.IPFS("pin", "remote", "service", "add", "svc", "https://invalid-service.example.com", authToken)
195
+
196
+ res := node.RunIPFS("pin", "remote", "ls", "--service=svc")
197
+ assert.Equal(t, 1, res.ExitCode())
198
+ assert.Contains(t, res.Stderr.String(), "no such host")
199
+ })
200
+
201
+ t.Run("pin remote service rm", func(t *testing.T) {
202
+ t.Parallel()
203
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
204
+ node.IPFS("pin", "remote", "service", "add", "svc", "https://example.com", authToken)
205
+ node.IPFS("pin", "remote", "service", "rm", "svc")
206
+ res := node.IPFS("pin", "remote", "service", "ls")
207
+ assert.NotContains(t, res.Stdout.String(), "svc")
208
+ })
209
+
210
+ t.Run("remote pinning", func(t *testing.T) {
211
+ t.Parallel()
212
+
213
+ verifyStatus := func(node *harness.Node, name, hash, status string) {
214
+ resJSON := node.IPFS("pin", "remote", "ls",
215
+ "--service=svc",
216
+ "--enc=json",
217
+ "--name="+name,
218
+ "--status="+status,
219
+ ).Stdout.String()
220
+
221
+ assert.Equal(t, status, gjson.Get(resJSON, "Status").Str)
222
+ assert.Equal(t, hash, gjson.Get(resJSON, "Cid").Str)
223
+ assert.Equal(t, name, gjson.Get(resJSON, "Name").Str)
224
+ }
225
+
226
+ t.Run("'ipfs pin remote add --background=true'", func(t *testing.T) {
227
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
228
+ svc, svcURL := runPinningService(t, authToken)
229
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
230
+
231
+ // retain a ptr to the pin that's in the DB so we can directly mutate its status
232
+ // to simulate async work
233
+ pinCh := make(chan *pinningservice.PinStatus, 1)
234
+ svc.PinAdded = func(req *pinningservice.AddPinRequest, pin *pinningservice.PinStatus) {
235
+ pinCh <- pin
236
+ }
237
+
238
+ hash := node.IPFSAddStr("foo")
239
+ node.IPFS("pin", "remote", "add",
240
+ "--background=true",
241
+ "--service=svc",
242
+ "--name=pin1",
243
+ hash,
244
+ )
245
+
246
+ pin := <-pinCh
247
+
248
+ transitionStatus := func(status string) {
249
+ pin.M.Lock()
250
+ pin.Status = status
251
+ pin.M.Unlock()
252
+ }
253
+
254
+ verifyStatus(node, "pin1", hash, "queued")
255
+
256
+ transitionStatus("pinning")
257
+ verifyStatus(node, "pin1", hash, "pinning")
258
+
259
+ transitionStatus("pinned")
260
+ verifyStatus(node, "pin1", hash, "pinned")
261
+
262
+ transitionStatus("failed")
263
+ verifyStatus(node, "pin1", hash, "failed")
264
+ })
265
+
266
+ t.Run("'ipfs pin remote add --background=false'", func(t *testing.T) {
267
+ t.Parallel()
268
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
269
+ svc, svcURL := runPinningService(t, authToken)
270
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
271
+
272
+ svc.PinAdded = func(req *pinningservice.AddPinRequest, pin *pinningservice.PinStatus) {
273
+ pin.M.Lock()
274
+ defer pin.M.Unlock()
275
+ pin.Status = "pinned"
276
+ }
277
+ hash := node.IPFSAddStr("foo")
278
+ node.IPFS("pin", "remote", "add",
279
+ "--background=false",
280
+ "--service=svc",
281
+ "--name=pin2",
282
+ hash,
283
+ )
284
+ verifyStatus(node, "pin2", hash, "pinned")
285
+ })
286
+
287
+ t.Run("'ipfs pin remote ls' with multiple statuses", func(t *testing.T) {
288
+ t.Parallel()
289
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
290
+ svc, svcURL := runPinningService(t, authToken)
291
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
292
+
293
+ hash := node.IPFSAddStr("foo")
294
+ desiredStatuses := map[string]string{
295
+ "pin-queued": "queued",
296
+ "pin-pinning": "pinning",
297
+ "pin-pinned": "pinned",
298
+ "pin-failed": "failed",
299
+ }
300
+ var pins []*pinningservice.PinStatus
301
+ svc.PinAdded = func(req *pinningservice.AddPinRequest, pin *pinningservice.PinStatus) {
302
+ pin.M.Lock()
303
+ defer pin.M.Unlock()
304
+ pins = append(pins, pin)
305
+ // this must be "pinned" for the 'pin remote add' command to return
306
+ // after 'pin remote add', we change the status to its real status
307
+ pin.Status = "pinned"
308
+ }
309
+
310
+ for pinName := range desiredStatuses {
311
+ node.IPFS("pin", "remote", "add",
312
+ "--service=svc",
313
+ "--name="+pinName,
314
+ hash,
315
+ )
316
+ }
317
+ for _, pin := range pins {
318
+ pin.M.Lock()
319
+ pin.Status = desiredStatuses[pin.Pin.Name]
320
+ pin.M.Unlock()
321
+ }
322
+
323
+ res := node.IPFS("pin", "remote", "ls",
324
+ "--service=svc",
325
+ "--status=queued,pinning,pinned,failed",
326
+ "--enc=json",
327
+ )
328
+ actualStatuses := map[string]string{}
329
+ for _, line := range res.Stdout.Lines() {
330
+ name := gjson.Get(line, "Name").Str
331
+ status := gjson.Get(line, "Status").Str
332
+ // drop statuses of other pins we didn't add
333
+ if _, ok := desiredStatuses[name]; ok {
334
+ actualStatuses[name] = status
335
+ }
336
+ }
337
+ assert.Equal(t, desiredStatuses, actualStatuses)
338
+ })
339
+
340
+ t.Run("'ipfs pin remote ls' by CID", func(t *testing.T) {
341
+ t.Parallel()
342
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
343
+ svc, svcURL := runPinningService(t, authToken)
344
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
345
+
346
+ transitionedCh := make(chan struct{}, 1)
347
+ svc.PinAdded = func(req *pinningservice.AddPinRequest, pin *pinningservice.PinStatus) {
348
+ pin.M.Lock()
349
+ defer pin.M.Unlock()
350
+ pin.Status = "pinned"
351
+ transitionedCh <- struct{}{}
352
+ }
353
+ hash := node.IPFSAddStr(string(testutils.RandomBytes(1000)))
354
+ node.IPFS("pin", "remote", "add", "--background=false", "--service=svc", hash)
355
+ <-transitionedCh
356
+ res := node.IPFS("pin", "remote", "ls", "--service=svc", "--cid="+hash, "--enc=json").Stdout.String()
357
+ assert.Contains(t, res, hash)
358
+ })
359
+
360
+ t.Run("'ipfs pin remote rm --name' without --force when multiple pins match", func(t *testing.T) {
361
+ t.Parallel()
362
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
363
+ svc, svcURL := runPinningService(t, authToken)
364
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
365
+
366
+ svc.PinAdded = func(req *pinningservice.AddPinRequest, pin *pinningservice.PinStatus) {
367
+ pin.M.Lock()
368
+ defer pin.M.Unlock()
369
+ pin.Status = "pinned"
370
+ }
371
+ hash := node.IPFSAddStr(string(testutils.RandomBytes(1000)))
372
+ node.IPFS("pin", "remote", "add", "--service=svc", "--name=force-test-name", hash)
373
+ node.IPFS("pin", "remote", "add", "--service=svc", "--name=force-test-name", hash)
374
+
375
+ t.Run("fails", func(t *testing.T) {
376
+ res := node.RunIPFS("pin", "remote", "rm", "--service=svc", "--name=force-test-name")
377
+ assert.Equal(t, 1, res.ExitCode())
378
+ assert.Contains(t, res.Stderr.String(), "Error: multiple remote pins are matching this query, add --force to confirm the bulk removal")
379
+ })
380
+
381
+ t.Run("matching pins are not removed", func(t *testing.T) {
382
+ lines := node.IPFS("pin", "remote", "ls", "--service=svc", "--name=force-test-name").Stdout.Lines()
383
+ assert.Contains(t, lines[0], "force-test-name")
384
+ assert.Contains(t, lines[1], "force-test-name")
385
+ })
386
+ })
387
+
388
+ t.Run("'ipfs pin remote rm --name --force' remove multiple pins", func(t *testing.T) {
389
+ t.Parallel()
390
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
391
+ svc, svcURL := runPinningService(t, authToken)
392
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
393
+
394
+ svc.PinAdded = func(req *pinningservice.AddPinRequest, pin *pinningservice.PinStatus) {
395
+ pin.M.Lock()
396
+ defer pin.M.Unlock()
397
+ pin.Status = "pinned"
398
+ }
399
+ hash := node.IPFSAddStr(string(testutils.RandomBytes(1000)))
400
+ node.IPFS("pin", "remote", "add", "--service=svc", "--name=force-test-name", hash)
401
+ node.IPFS("pin", "remote", "add", "--service=svc", "--name=force-test-name", hash)
402
+
403
+ node.IPFS("pin", "remote", "rm", "--service=svc", "--name=force-test-name", "--force")
404
+ out := node.IPFS("pin", "remote", "ls", "--service=svc", "--name=force-test-name").Stdout.Trimmed()
405
+ assert.Empty(t, out)
406
+ })
407
+
408
+ t.Run("'ipfs pin remote rm --force' removes all pins", func(t *testing.T) {
409
+ t.Parallel()
410
+ node := harness.NewT(t).NewNode().Init().StartDaemon()
411
+ svc, svcURL := runPinningService(t, authToken)
412
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
413
+
414
+ svc.PinAdded = func(req *pinningservice.AddPinRequest, pin *pinningservice.PinStatus) {
415
+ pin.M.Lock()
416
+ defer pin.M.Unlock()
417
+ pin.Status = "pinned"
418
+ }
419
+ for i := 0; i < 4; i++ {
420
+ hash := node.IPFSAddStr(string(testutils.RandomBytes(1000)))
421
+ name := fmt.Sprintf("--name=%d", i)
422
+ node.IPFS("pin", "remote", "add", "--service=svc", "--name="+name, hash)
423
+ }
424
+
425
+ lines := node.IPFS("pin", "remote", "ls", "--service=svc").Stdout.Lines()
426
+ assert.Len(t, lines, 4)
427
+
428
+ node.IPFS("pin", "remote", "rm", "--service=svc", "--force")
429
+
430
+ lines = node.IPFS("pin", "remote", "ls", "--service=svc").Stdout.Lines()
431
+ assert.Len(t, lines, 0)
432
+ })
433
+ })
434
+
435
+ t.Run("'ipfs pin remote add' shows a warning message when offline", func(t *testing.T) {
436
+ t.Parallel()
437
+ node := harness.NewT(t).NewNode().Init()
438
+ _, svcURL := runPinningService(t, authToken)
439
+ node.IPFS("pin", "remote", "service", "add", "svc", svcURL, authToken)
440
+
441
+ hash := node.IPFSAddStr(string(testutils.RandomBytes(1000)))
442
+ res := node.IPFS("pin", "remote", "add", "--service=svc", "--background", hash)
443
+ warningMsg := "WARNING: the local node is offline and remote pinning may fail if there is no other provider for this CID"
444
+ assert.Contains(t, res.Stdout.String(), warningMsg)
445
+ })
446
+}