feat(gateway): IPNS record response format (IPIP-351) (#9399)
* feat(gateway): IPNS record response format * docs(rpc): mark as experimental: routing provide, get, put Co-authored-by: Marcin Rataj <lidel@lidel.org>
Henrique Dias committed
Jan 27, 2023 at 04:46 UTC
a3c70a11e68bae6f56763bf971a9477762dd22fe
13 files changed
+219
-117
core/commands/routing.go
+19
-108
@@ -2,7 +2,6 @@ package commands
2
3
import (
4
"context"
5
- "encoding/base64"
5
"errors"
6
"fmt"
7
"io"
@@ -135,6 +134,7 @@ const (
134
)
135
136
var provideRefRoutingCmd = &cmds.Command{
137
+ Status: cmds.Experimental,
138
Helptext: cmds.HelpText{
139
Tagline: "Announce to the network that you are providing given values.",
140
},
@@ -346,6 +346,7 @@ var findPeerRoutingCmd = &cmds.Command{
346
}
347
348
var getValueRoutingCmd = &cmds.Command{
349
+ Status: cmds.Experimental,
350
Helptext: cmds.HelpText{
351
Tagline: "Given a key, query the routing system for its best value.",
352
ShortDescription: `
@@ -362,78 +363,30 @@ Different key types can specify other 'best' rules.
363
Arguments: []cmds.Argument{
364
cmds.StringArg("key", true, true, "The key to find a value for."),
365
},
365
- Options: []cmds.Option{
366
- cmds.BoolOption(dhtVerboseOptionName, "v", "Print extra information."),
367
- },
366
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
369
- nd, err := cmdenv.GetNode(env)
367
+ api, err := cmdenv.GetApi(env, req)
368
if err != nil {
369
return err
370
}
371
374
- if !nd.IsOnline {
375
- return ErrNotOnline
376
- }
377
-
378
- dhtkey, err := escapeDhtKey(req.Arguments[0])
372
+ r, err := api.Routing().Get(req.Context, req.Arguments[0])
373
if err != nil {
374
return err
375
}
376
383
- ctx, cancel := context.WithCancel(req.Context)
384
- ctx, events := routing.RegisterForQueryEvents(ctx)
385
-
386
- var getErr error
387
- go func() {
388
- defer cancel()
389
- var val []byte
390
- val, getErr = nd.Routing.GetValue(ctx, dhtkey)
391
- if getErr != nil {
392
- routing.PublishQueryEvent(ctx, &routing.QueryEvent{
393
- Type: routing.QueryError,
394
- Extra: getErr.Error(),
395
- })
396
- } else {
397
- routing.PublishQueryEvent(ctx, &routing.QueryEvent{
398
- Type: routing.Value,
399
- Extra: base64.StdEncoding.EncodeToString(val),
400
- })
401
- }
402
- }()
403
-
404
- for e := range events {
405
- if err := res.Emit(e); err != nil {
406
- return err
407
- }
408
- }
409
-
410
- return getErr
377
+ return res.Emit(r)
378
},
379
Encoders: cmds.EncoderMap{
413
- cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *routing.QueryEvent) error {
414
- pfm := pfuncMap{
415
- routing.Value: func(obj *routing.QueryEvent, out io.Writer, verbose bool) error {
416
- if verbose {
417
- _, err := fmt.Fprintf(out, "got value: '%s'\n", obj.Extra)
418
- return err
419
- }
420
- res, err := base64.StdEncoding.DecodeString(obj.Extra)
421
- if err != nil {
422
- return err
423
- }
424
- _, err = out.Write(res)
425
- return err
426
- },
427
- }
428
-
429
- verbose, _ := req.Options[dhtVerboseOptionName].(bool)
430
- return printEvent(out, w, verbose, pfm)
380
+ cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out []byte) error {
381
+ _, err := w.Write(out)
382
+ return err
383
}),
384
},
433
- Type: routing.QueryEvent{},
385
+ Type: []byte{},
386
}
387
388
var putValueRoutingCmd = &cmds.Command{
389
+ Status: cmds.Experimental,
390
Helptext: cmds.HelpText{
391
Tagline: "Write a key/value pair to the routing system.",
392
ShortDescription: `
@@ -459,20 +412,8 @@ identified by QmFoo.
412
cmds.StringArg("key", true, false, "The key to store the value at."),
413
cmds.FileArg("value-file", true, false, "A path to a file containing the value to store.").EnableStdin(),
414
},
462
- Options: []cmds.Option{
463
- cmds.BoolOption(dhtVerboseOptionName, "v", "Print extra information."),
464
- },
415
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
466
- nd, err := cmdenv.GetNode(env)
467
- if err != nil {
468
- return err
469
- }
470
-
471
- if !nd.IsOnline {
472
- return ErrNotOnline
473
- }
474
-
475
- key, err := escapeDhtKey(req.Arguments[0])
416
+ api, err := cmdenv.GetApi(env, req)
417
if err != nil {
418
return err
419
}
@@ -488,50 +429,20 @@ identified by QmFoo.
429
return err
430
}
431
491
- ctx, cancel := context.WithCancel(req.Context)
492
- ctx, events := routing.RegisterForQueryEvents(ctx)
493
-
494
- var putErr error
495
- go func() {
496
- defer cancel()
497
- putErr = nd.Routing.PutValue(ctx, key, []byte(data))
498
- if putErr != nil {
499
- routing.PublishQueryEvent(ctx, &routing.QueryEvent{
500
- Type: routing.QueryError,
501
- Extra: putErr.Error(),
502
- })
503
- }
504
- }()
505
-
506
- for e := range events {
507
- if err := res.Emit(e); err != nil {
508
- return err
509
- }
432
+ err = api.Routing().Put(req.Context, req.Arguments[0], data)
433
+ if err != nil {
434
+ return err
435
}
436
512
- return putErr
437
+ return res.Emit([]byte(fmt.Sprintf("%s added", req.Arguments[0])))
438
},
439
Encoders: cmds.EncoderMap{
515
- cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *routing.QueryEvent) error {
516
- pfm := pfuncMap{
517
- routing.FinalPeer: func(obj *routing.QueryEvent, out io.Writer, verbose bool) error {
518
- if verbose {
519
- fmt.Fprintf(out, "* closest peer %s\n", obj.ID)
520
- }
521
- return nil
522
- },
523
- routing.Value: func(obj *routing.QueryEvent, out io.Writer, verbose bool) error {
524
- fmt.Fprintf(out, "%s\n", obj.ID.Pretty())
525
- return nil
526
- },
527
- }
528
-
529
- verbose, _ := req.Options[dhtVerboseOptionName].(bool)
530
-
531
- return printEvent(out, w, verbose, pfm)
440
+ cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out []byte) error {
441
+ _, err := w.Write(out)
442
+ return err
443
}),
444
},
534
- Type: routing.QueryEvent{},
445
+ Type: []byte{},
446
}
447
448
type printFunc func(obj *routing.QueryEvent, out io.Writer, verbose bool) error
core/coreapi/coreapi.go
+5
@@ -144,6 +144,11 @@ func (api *CoreAPI) PubSub() coreiface.PubSubAPI {
144
return (*PubSubAPI)(api)
145
}
146
147
+// Routing returns the RoutingAPI interface implementation backed by the kubo node
148
+func (api *CoreAPI) Routing() coreiface.RoutingAPI {
149
+ return (*RoutingAPI)(api)
150
+}
151
+
152
// WithOptions returns api with global options applied
153
func (api *CoreAPI) WithOptions(opts ...options.ApiOption) (coreiface.CoreAPI, error) {
154
settings := api.parentOpts // make sure to copy
core/coreapi/routing.go
new
+53
@@ -0,0 +1,53 @@
1
+package coreapi
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+
7
+ "github.com/ipfs/go-path"
8
+ coreiface "github.com/ipfs/interface-go-ipfs-core"
9
+ peer "github.com/libp2p/go-libp2p/core/peer"
10
+)
11
+
12
+type RoutingAPI CoreAPI
13
+
14
+func (r *RoutingAPI) Get(ctx context.Context, key string) ([]byte, error) {
15
+ if !r.nd.IsOnline {
16
+ return nil, coreiface.ErrOffline
17
+ }
18
+
19
+ dhtKey, err := normalizeKey(key)
20
+ if err != nil {
21
+ return nil, err
22
+ }
23
+
24
+ return r.routing.GetValue(ctx, dhtKey)
25
+}
26
+
27
+func (r *RoutingAPI) Put(ctx context.Context, key string, value []byte) error {
28
+ if !r.nd.IsOnline {
29
+ return coreiface.ErrOffline
30
+ }
31
+
32
+ dhtKey, err := normalizeKey(key)
33
+ if err != nil {
34
+ return err
35
+ }
36
+
37
+ return r.routing.PutValue(ctx, dhtKey, value)
38
+}
39
+
40
+func normalizeKey(s string) (string, error) {
41
+ parts := path.SplitList(s)
42
+ if len(parts) != 3 ||
43
+ parts[0] != "" ||
44
+ !(parts[1] == "ipns" || parts[1] == "pk") {
45
+ return "", errors.New("invalid key")
46
+ }
47
+
48
+ k, err := peer.Decode(parts[2])
49
+ if err != nil {
50
+ return "", err
51
+ }
52
+ return path.Join(append(parts[:2], string(k))), nil
53
+}
core/corehttp/gateway.go
+4
@@ -33,6 +33,10 @@ type NodeAPI interface {
33
// Dag returns an implementation of Dag API
34
Dag() coreiface.APIDagService
35
36
+ // Routing returns an implementation of Routing API.
37
+ // Used for returning signed IPNS records, see IPIP-0328
38
+ Routing() coreiface.RoutingAPI
39
+
40
// ResolvePath resolves the path using Unixfs resolver
41
ResolvePath(context.Context, path.Path) (path.Resolved, error)
42
}
core/corehttp/gateway_handler.go
+7
-1
@@ -444,6 +444,9 @@ func (i *gatewayHandler) getOrHeadHandler(w http.ResponseWriter, r *http.Request
444
case "application/vnd.ipld.dag-json", "application/vnd.ipld.dag-cbor":
445
logger.Debugw("serving codec", "path", contentPath)
446
i.serveCodec(r.Context(), w, r, resolvedPath, contentPath, begin, responseFormat)
447
+ case "application/vnd.ipfs.ipns-record":
448
+ logger.Debugw("serving ipns record", "path", contentPath)
449
+ i.serveIpnsRecord(r.Context(), w, r, resolvedPath, contentPath, begin, logger)
450
return
451
default: // catch-all for unsuported application/vnd.*
452
err := fmt.Errorf("unsupported format %q", responseFormat)
@@ -885,6 +888,8 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string]
888
return "application/vnd.ipld.dag-json", nil, nil
889
case "dag-cbor":
890
return "application/vnd.ipld.dag-cbor", nil, nil
891
+ case "ipns-record":
892
+ return "application/vnd.ipfs.ipns-record", nil, nil
893
}
894
}
895
// Browsers and other user agents will send Accept header with generic types like:
@@ -898,7 +903,8 @@ func customResponseFormat(r *http.Request) (mediaType string, params map[string]
903
if strings.HasPrefix(accept, "application/vnd.ipld") ||
904
strings.HasPrefix(accept, "application/x-tar") ||
905
strings.HasPrefix(accept, "application/json") ||
901
- strings.HasPrefix(accept, "application/cbor") {
906
+ strings.HasPrefix(accept, "application/cbor") ||
907
+ strings.HasPrefix(accept, "application/vnd.ipfs") {
908
mediatype, params, err := mime.ParseMediaType(accept)
909
if err != nil {
910
return "", nil, err
core/corehttp/gateway_handler_ipns_record.go
new
+71
@@ -0,0 +1,71 @@
1
+package corehttp
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/gogo/protobuf/proto"
12
+ ipns_pb "github.com/ipfs/go-ipns/pb"
13
+ path "github.com/ipfs/go-path"
14
+ ipath "github.com/ipfs/interface-go-ipfs-core/path"
15
+ "go.uber.org/zap"
16
+)
17
+
18
+func (i *gatewayHandler) serveIpnsRecord(ctx context.Context, w http.ResponseWriter, r *http.Request, resolvedPath ipath.Resolved, contentPath ipath.Path, begin time.Time, logger *zap.SugaredLogger) {
19
+ if contentPath.Namespace() != "ipns" {
20
+ err := fmt.Errorf("%s is not an IPNS link", contentPath.String())
21
+ webError(w, err.Error(), err, http.StatusBadRequest)
22
+ return
23
+ }
24
+
25
+ key := contentPath.String()
26
+ key = strings.TrimSuffix(key, "/")
27
+ if strings.Count(key, "/") > 2 {
28
+ err := errors.New("cannot find ipns key for subpath")
29
+ webError(w, err.Error(), err, http.StatusBadRequest)
30
+ return
31
+ }
32
+
33
+ rawRecord, err := i.api.Routing().Get(ctx, key)
34
+ if err != nil {
35
+ webError(w, err.Error(), err, http.StatusInternalServerError)
36
+ return
37
+ }
38
+
39
+ var record ipns_pb.IpnsEntry
40
+ err = proto.Unmarshal(rawRecord, &record)
41
+ if err != nil {
42
+ webError(w, err.Error(), err, http.StatusInternalServerError)
43
+ return
44
+ }
45
+
46
+ // Set cache control headers based on the TTL set in the IPNS record. If the
47
+ // TTL is not present, we use the Last-Modified tag. We are tracking IPNS
48
+ // caching on: https://github.com/ipfs/kubo/issues/1818.
49
+ // TODO: use addCacheControlHeaders once #1818 is fixed.
50
+ w.Header().Set("Etag", getEtag(r, resolvedPath.Cid()))
51
+ if record.Ttl != nil {
52
+ seconds := int(time.Duration(*record.Ttl).Seconds())
53
+ w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", seconds))
54
+ } else {
55
+ w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
56
+ }
57
+
58
+ // Set Content-Disposition
59
+ var name string
60
+ if urlFilename := r.URL.Query().Get("filename"); urlFilename != "" {
61
+ name = urlFilename
62
+ } else {
63
+ name = path.SplitList(key)[2] + ".ipns-record"
64
+ }
65
+ setContentDispositionHeader(w, name, "attachment")
66
+
67
+ w.Header().Set("Content-Type", "application/vnd.ipfs.ipns-record")
68
+ w.Header().Set("X-Content-Type-Options", "nosniff")
69
+
70
+ _, _ = w.Write(rawRecord)
71
+}
docs/examples/kubo-as-a-library/go.mod
+1
-1
@@ -8,7 +8,7 @@ replace github.com/ipfs/kubo => ./../../..
8
9
require (
10
github.com/ipfs/go-libipfs v0.3.0
11
- github.com/ipfs/interface-go-ipfs-core v0.9.0
11
+ github.com/ipfs/interface-go-ipfs-core v0.10.0
12
github.com/ipfs/kubo v0.0.0-00010101000000-000000000000
13
github.com/libp2p/go-libp2p v0.24.2
14
github.com/multiformats/go-multiaddr v0.8.0
docs/examples/kubo-as-a-library/go.sum
+2
-2
@@ -594,8 +594,8 @@ github.com/ipfs/go-unixfsnode v1.5.1/go.mod h1:ed79DaG9IEuZITJVQn4U6MZDftv6I3ygU
594
github.com/ipfs/go-verifcid v0.0.1/go.mod h1:5Hrva5KBeIog4A+UpqlaIU+DEstipcJYQQZc0g37pY0=
595
github.com/ipfs/go-verifcid v0.0.2 h1:XPnUv0XmdH+ZIhLGKg6U2vaPaRDXb9urMyNVCE7uvTs=
596
github.com/ipfs/go-verifcid v0.0.2/go.mod h1:40cD9x1y4OWnFXbLNJYRe7MpNvWlMn3LZAG5Wb4xnPU=
597
-github.com/ipfs/interface-go-ipfs-core v0.9.0 h1:+RCouVtSU/SldgkqWufjIu1smmGaSyKgUIfbYwLukgI=
598
-github.com/ipfs/interface-go-ipfs-core v0.9.0/go.mod h1:F3EcmDy53GFkF0H3iEJpfJC320fZ/4G60eftnItrrJ0=
597
+github.com/ipfs/interface-go-ipfs-core v0.10.0 h1:b/psL1oqJcySdQAsIBfW5ZJJkOAsYlhWtC0/Qvr4WiM=
598
+github.com/ipfs/interface-go-ipfs-core v0.10.0/go.mod h1:F3EcmDy53GFkF0H3iEJpfJC320fZ/4G60eftnItrrJ0=
599
github.com/ipld/edelweiss v0.2.0 h1:KfAZBP8eeJtrLxLhi7r3N0cBCo7JmwSRhOJp3WSpNjk=
600
github.com/ipld/edelweiss v0.2.0/go.mod h1:FJAzJRCep4iI8FOFlRriN9n0b7OuX3T/S9++NpBDmA4=
601
github.com/ipld/go-car v0.4.0 h1:U6W7F1aKF/OJMHovnOVdst2cpQE5GhmHibQkAixgNcQ=
go.mod
+1
-1
@@ -62,7 +62,7 @@ require (
62
github.com/ipfs/go-unixfs v0.4.2
63
github.com/ipfs/go-unixfsnode v1.5.1
64
github.com/ipfs/go-verifcid v0.0.2
65
- github.com/ipfs/interface-go-ipfs-core v0.9.0
65
+ github.com/ipfs/interface-go-ipfs-core v0.10.0
66
github.com/ipld/go-car v0.4.0
67
github.com/ipld/go-car/v2 v2.5.1
68
github.com/ipld/go-codec-dagpb v1.5.0
go.sum
+2
-2
@@ -620,8 +620,8 @@ github.com/ipfs/go-unixfsnode v1.5.1/go.mod h1:ed79DaG9IEuZITJVQn4U6MZDftv6I3ygU
620
github.com/ipfs/go-verifcid v0.0.1/go.mod h1:5Hrva5KBeIog4A+UpqlaIU+DEstipcJYQQZc0g37pY0=
621
github.com/ipfs/go-verifcid v0.0.2 h1:XPnUv0XmdH+ZIhLGKg6U2vaPaRDXb9urMyNVCE7uvTs=
622
github.com/ipfs/go-verifcid v0.0.2/go.mod h1:40cD9x1y4OWnFXbLNJYRe7MpNvWlMn3LZAG5Wb4xnPU=
623
-github.com/ipfs/interface-go-ipfs-core v0.9.0 h1:+RCouVtSU/SldgkqWufjIu1smmGaSyKgUIfbYwLukgI=
624
-github.com/ipfs/interface-go-ipfs-core v0.9.0/go.mod h1:F3EcmDy53GFkF0H3iEJpfJC320fZ/4G60eftnItrrJ0=
623
+github.com/ipfs/interface-go-ipfs-core v0.10.0 h1:b/psL1oqJcySdQAsIBfW5ZJJkOAsYlhWtC0/Qvr4WiM=
624
+github.com/ipfs/interface-go-ipfs-core v0.10.0/go.mod h1:F3EcmDy53GFkF0H3iEJpfJC320fZ/4G60eftnItrrJ0=
625
github.com/ipld/edelweiss v0.2.0 h1:KfAZBP8eeJtrLxLhi7r3N0cBCo7JmwSRhOJp3WSpNjk=
626
github.com/ipld/edelweiss v0.2.0/go.mod h1:FJAzJRCep4iI8FOFlRriN9n0b7OuX3T/S9++NpBDmA4=
627
github.com/ipld/go-car v0.4.0 h1:U6W7F1aKF/OJMHovnOVdst2cpQE5GhmHibQkAixgNcQ=
test/sharness/t0124-gateway-ipns-record.sh
new
+52
@@ -0,0 +1,52 @@
1
+#!/usr/bin/env bash
2
+
3
+test_description="Test HTTP Gateway IPNS Record (application/vnd.ipfs.ipns-record) Support"
4
+
5
+. lib/test-lib.sh
6
+
7
+test_init_ipfs
8
+test_launch_ipfs_daemon
9
+
10
+test_expect_success "Create and Publish IPNS Key" '
11
+ FILE_CID=$(echo "Hello IPFS" | ipfs add --cid-version 1 -q) &&
12
+ IPNS_KEY=$(ipfs key gen ipns-record) &&
13
+ ipfs name publish /ipfs/$FILE_CID --key=ipns-record --ttl=30m &&
14
+ curl "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY" > curl_output_filename &&
15
+ test_should_contain "Hello IPFS" curl_output_filename
16
+'
17
+
18
+test_expect_success "GET KEY with format=ipns-record and validate key" '
19
+ curl "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY?format=ipns-record" > curl_output_filename &&
20
+ ipfs name inspect --verify $IPNS_KEY < curl_output_filename > verify_output &&
21
+ test_should_contain "$FILE_CID" verify_output
22
+'
23
+
24
+test_expect_success "GET KEY with 'Accept: application/vnd.ipfs.ipns-record' and validate key" '
25
+ curl -H "Accept: application/vnd.ipfs.ipns-record" "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY" > curl_output_filename &&
26
+ ipfs name inspect --verify $IPNS_KEY < curl_output_filename > verify_output &&
27
+ test_should_contain "$FILE_CID" verify_output
28
+'
29
+
30
+test_expect_success "GET KEY with format=ipns-record has expected HTTP headers" '
31
+ curl -sD - "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY?format=ipns-record" > curl_output_filename 2>&1 &&
32
+ test_should_contain "Content-Disposition: attachment;" curl_output_filename &&
33
+ test_should_contain "Content-Type: application/vnd.ipfs.ipns-record" curl_output_filename &&
34
+ test_should_contain "Cache-Control: public, max-age=1800" curl_output_filename
35
+'
36
+
37
+test_expect_success "GET KEY with 'Accept: application/vnd.ipfs.ipns-record' has expected HTTP headers" '
38
+ curl -H "Accept: application/vnd.ipfs.ipns-record" -sD - "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY" > curl_output_filename 2>&1 &&
39
+ test_should_contain "Content-Disposition: attachment;" curl_output_filename &&
40
+ test_should_contain "Content-Type: application/vnd.ipfs.ipns-record" curl_output_filename &&
41
+ test_should_contain "Cache-Control: public, max-age=1800" curl_output_filename
42
+'
43
+
44
+test_expect_success "GET KEY with expliciy ?filename= succeeds with modified Content-Disposition header" '
45
+ curl -sD - "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_KEY?format=ipns-record&filename=testтест.ipns-record" > curl_output_filename 2>&1 &&
46
+ grep -F "Content-Disposition: attachment; filename=\"test____.ipns-record\"; filename*=UTF-8'\'\''test%D1%82%D0%B5%D1%81%D1%82.ipns-record" curl_output_filename &&
47
+ test_should_contain "Content-Type: application/vnd.ipfs.ipns-record" curl_output_filename
48
+'
49
+
50
+test_kill_ipfs_daemon
51
+
52
+test_done
test/sharness/t0170-legacy-dht.sh
+1
-1
@@ -111,7 +111,7 @@ test_dht() {
111
test_must_fail ipfsi 0 dht put "/ipns/$PEERID_2" "get_result" 2>err_put &&
112
test_should_contain "this command must be run in online mode" err_findprovs &&
113
test_should_contain "this command must be run in online mode" err_findpeer &&
114
- test_should_contain "this command must be run in online mode" err_put
114
+ test_should_contain "this action must be run in online mode" err_put
115
'
116
}
117
test/sharness/t0170-routing-dht.sh
+1
-1
@@ -112,7 +112,7 @@ test_dht() {
112
test_must_fail ipfsi 0 routing put "/ipns/$PEERID_2" "get_result" 2>err_put &&
113
test_should_contain "this command must be run in online mode" err_findprovs &&
114
test_should_contain "this command must be run in online mode" err_findpeer &&
115
- test_should_contain "this command must be run in online mode" err_put
115
+ test_should_contain "this action must be run in online mode" err_put
116
'
117
}
118