@cryptotaxi247 / kubo / commits / a3c70a11e

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