diff --git a/examples/book-collection/README.md b/examples/book-collection/README.md index a6db6c8..64dde97 100644 --- a/examples/book-collection/README.md +++ b/examples/book-collection/README.md @@ -1,8 +1,8 @@ # Book Collection Example This is an example, written in javascript (node.js), of a RES service with collections and resource references to books, which can be created, edited and deleted. -* It exposes a collection, `bookService.books`, containing book model references. -* It exposes book models, `bookService.book.`, of each book. +* It exposes a collection, `library.books`, containing book model references. +* It exposes book models, `library.book.`, of each book. * It allows setting the books' *title* and *author* property through the `set` method. * It allows creating new books that are added to the collection with the `new` method. * It allows deleting existing books from the collection with the `delete` method. @@ -42,17 +42,17 @@ Run the client on two separate devices. Disconnect one device, then make changes ### Get book collection ``` -GET http://localhost:8080/api/bookService/books +GET http://localhost:8080/api/library/books ``` ### Get book ``` -GET http://localhost:8080/api/bookService/book/ +GET http://localhost:8080/api/library/book/ ``` ### Update book properties ``` -POST http://localhost:8080/api/bookService/book//set +POST http://localhost:8080/api/library/book//set ``` *Body* ``` @@ -61,7 +61,7 @@ POST http://localhost:8080/api/bookService/book//set ### Add new book ``` -POST http://localhost:8080/api/bookService/books/add +POST http://localhost:8080/api/library/books/add ``` *Body* ``` @@ -70,7 +70,7 @@ POST http://localhost:8080/api/bookService/books/add ### Delete book ``` -POST http://localhost:8080/api/bookService/books/delete +POST http://localhost:8080/api/library/books/delete ``` *Body* ``` diff --git a/examples/edit-text/package-lock.json b/examples/edit-text/package-lock.json index a85983f..d4bdae9 100644 --- a/examples/edit-text/package-lock.json +++ b/examples/edit-text/package-lock.json @@ -441,9 +441,9 @@ "dev": true }, "ini": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", + "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", "dev": true }, "is-arrayish": { diff --git a/examples/hello-world/package-lock.json b/examples/hello-world/package-lock.json index efa0e0b..c511e28 100644 --- a/examples/hello-world/package-lock.json +++ b/examples/hello-world/package-lock.json @@ -441,9 +441,9 @@ "dev": true }, "ini": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", - "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", + "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", "dev": true }, "is-arrayish": { diff --git a/server/apiHandler.go b/server/apiHandler.go index 556a60e..30367ce 100644 --- a/server/apiHandler.go +++ b/server/apiHandler.go @@ -30,6 +30,9 @@ func (s *Service) initAPIHandler() error { // setCommonHeaders sets common headers such as Access-Control-*. // It returns error if the origin header does not match any allowed origin. func (s *Service) setCommonHeaders(w http.ResponseWriter, r *http.Request) error { + if s.cfg.HeaderAuth != nil { + w.Header().Set("Access-Control-Allow-Credentials", "true") + } if s.cfg.allowOrigin[0] == "*" { w.Header().Set("Access-Control-Allow-Origin", "*") return nil diff --git a/server/codec/codec.go b/server/codec/codec.go index c1746a6..5127918 100644 --- a/server/codec/codec.go +++ b/server/codec/codec.go @@ -10,10 +10,14 @@ import ( ) var ( - noQueryGetRequest = []byte(`{}`) - errMissingResult = reserr.InternalError(errors.New("response missing result")) - errInvalidResponse = reserr.InternalError(errors.New("invalid service response")) - errInvalidValue = reserr.InternalError(errors.New("invalid value")) + noQueryGetRequest = []byte(`{}`) + errMissingResult = reserr.InternalError(errors.New("response missing result")) + errInvalidResponse = reserr.InternalError(errors.New("invalid service response")) + errInvalidValue = reserr.InternalError(errors.New("invalid value")) + errInvalidValueEmptyRID = reserr.InternalError(errors.New(`invalid value: resource references requires a non-empty "rid" value`)) + errInvalidValueAmbiguous = reserr.InternalError(errors.New(`invalid value: ambiguous value type`)) + errInvalidValueObjectNotAllowed = reserr.InternalError(errors.New(`invalid value: nested json object must be wrapped as a data value`)) + errInvalidValueArrayNotAllowed = reserr.InternalError(errors.New(`invalid value: nested json array must be wrapped as a data value`)) ) const ( @@ -239,13 +243,16 @@ func (v *Value) UnmarshalJSON(data []byte) error { switch { case mvo.RID != nil: - // Invalid to have both RID and Action or Data set, or if RID is empty - if mvo.Action != nil || mvo.Data != nil || *mvo.RID == "" { - return errInvalidValue + if *mvo.RID == "" { + return errInvalidValueEmptyRID + } + // Invalid to have both RID and Action or Data set + if mvo.Action != nil || mvo.Data != nil { + return errInvalidValueAmbiguous } v.RID = *mvo.RID if !IsValidRID(v.RID, true) { - return errInvalidValue + return reserr.InternalError(errors.New(`invalid value: resource reference rid "` + v.RID + `" is invalid`)) } if mvo.Soft { v.Type = ValueTypeSoftReference @@ -254,8 +261,11 @@ func (v *Value) UnmarshalJSON(data []byte) error { } case mvo.Action != nil: // Invalid to have both Action and Data set, or if action is not actionDelete - if mvo.Data != nil || *mvo.Action != actionDelete { - return errInvalidValue + if mvo.Data != nil { + return errInvalidValueAmbiguous + } + if *mvo.Action != actionDelete { + return reserr.InternalError(errors.New(`invalid value: unknown action "` + *mvo.Action + `"`)) } v.Type = ValueTypeDelete case mvo.Data != nil: @@ -269,10 +279,10 @@ func (v *Value) UnmarshalJSON(data []byte) error { v.Type = ValueTypePrimitive } default: - return errInvalidValue + return errInvalidValueObjectNotAllowed } case '[': - return errInvalidValue + return errInvalidValueArrayNotAllowed default: v.Type = ValueTypePrimitive } diff --git a/server/const.go b/server/const.go index 5a38b73..5c2697a 100644 --- a/server/const.go +++ b/server/const.go @@ -4,7 +4,7 @@ import "time" const ( // Version is the current version for the server. - Version = "1.6.1" + Version = "1.6.2" // ProtocolVersion is the implemented RES protocol version. ProtocolVersion = "1.2.1" diff --git a/server/wsConn.go b/server/wsConn.go index aff5d60..0314634 100644 --- a/server/wsConn.go +++ b/server/wsConn.go @@ -49,10 +49,8 @@ func (s *Service) newWSConn(ws *websocket.Conn, request *http.Request, protocol return nil } - cid := xid.New() - conn := &wsConn{ - cid: cid.String(), + cid: xid.New().String(), ws: ws, request: request, serv: s, diff --git a/test/14http_get_test.go b/test/14http_get_test.go index e09079b..6df9eb5 100644 --- a/test/14http_get_test.go +++ b/test/14http_get_test.go @@ -257,11 +257,11 @@ func TestHTTPGet_AllowOrigin_ExpectedResponse(t *testing.T) { ExpectedMissingHeaders []string // Expected response headers not to be included ExpectedBody interface{} // Expected response body }{ - {"http://localhost", "", "*", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "*"}, []string{"Vary"}, successResponse}, - {"http://localhost", "", "http://localhost", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "http://localhost", "Vary": "Origin"}, nil, successResponse}, - {"https://resgate.io", "", "http://localhost;https://resgate.io", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "https://resgate.io", "Vary": "Origin"}, nil, successResponse}, + {"http://localhost", "", "*", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "*"}, []string{"Vary", "Access-Control-Allow-Credentials"}, successResponse}, + {"http://localhost", "", "http://localhost", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "http://localhost", "Vary": "Origin"}, []string{"Access-Control-Allow-Credentials"}, successResponse}, + {"https://resgate.io", "", "http://localhost;https://resgate.io", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "https://resgate.io", "Vary": "Origin"}, []string{"Access-Control-Allow-Credentials"}, successResponse}, // Invalid requests - {"http://example.com", "", "http://localhost;https://resgate.io", http.StatusForbidden, map[string]string{"Access-Control-Allow-Origin": "http://localhost", "Vary": "Origin"}, nil, reserr.ErrForbiddenOrigin}, + {"http://example.com", "", "http://localhost;https://resgate.io", http.StatusForbidden, map[string]string{"Access-Control-Allow-Origin": "http://localhost", "Vary": "Origin"}, []string{"Access-Control-Allow-Credentials"}, reserr.ErrForbiddenOrigin}, // No Origin header in request {"", "", "*", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "*"}, []string{"Vary"}, successResponse}, {"", "", "http://localhost", http.StatusOK, nil, []string{"Access-Control-Allow-Origin", "Vary"}, successResponse}, @@ -300,3 +300,82 @@ func TestHTTPGet_AllowOrigin_ExpectedResponse(t *testing.T) { }) } } + +func TestHTTPGet_HeaderAuth_ExpectedResponse(t *testing.T) { + model := resourceData("test.model") + token := json.RawMessage(`{"user":"foo"}`) + successResponse := json.RawMessage(model) + + tbl := []struct { + AuthResponse interface{} // Response on auth request. requestTimeout means timeout. + Token interface{} // Token to send. noToken means no token events should be sent. + ExpectedHeaders map[string]string // Expected response Headers + }{ + // Without token + {requestTimeout, noToken, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {reserr.ErrNotFound, noToken, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {[]byte(`{]`), noToken, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {nil, noToken, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + // With token + {requestTimeout, token, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {reserr.ErrNotFound, token, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {[]byte(`{]`), token, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {nil, token, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + // With nil token + {requestTimeout, nil, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {reserr.ErrNotFound, nil, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {[]byte(`{]`), nil, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {nil, nil, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + } + + for i, l := range tbl { + l := l + runNamedTest(t, fmt.Sprintf("#%d", i+1), func(s *Session) { + hreq := s.HTTPRequest("GET", "/api/test/model", nil, func(req *http.Request) { + req.Header.Set("Origin", "example.com") + }) + + req := s.GetRequest(t) + req.AssertSubject(t, "auth.vault.method") + req.AssertPathPayload(t, "header.Origin", []string{"example.com"}) + // Send token + expectedToken := l.Token + if l.Token != noToken { + cid := req.PathPayload(t, "cid").(string) + s.ConnEvent(cid, "token", struct { + Token interface{} `json:"token"` + }{l.Token}) + } else { + expectedToken = nil + } + // Respond to auth request + if l.AuthResponse == requestTimeout { + req.Timeout() + } else if err, ok := l.AuthResponse.(*reserr.Error); ok { + req.RespondError(err) + } else if raw, ok := l.AuthResponse.([]byte); ok { + req.RespondRaw(raw) + } else { + req.RespondSuccess(l.AuthResponse) + } + + // Handle model get and access request + mreqs := s.GetParallelRequests(t, 2) + mreqs. + GetRequest(t, "access.test.model"). + AssertPathPayload(t, "token", expectedToken). + RespondSuccess(json.RawMessage(`{"get":true}`)) + mreqs. + GetRequest(t, "get.test.model"). + RespondSuccess(json.RawMessage(`{"model":` + model + `}`)) + + // Validate http response + hreq.GetResponse(t). + Equals(t, http.StatusOK, successResponse). + AssertHeaders(t, l.ExpectedHeaders) + }, func(cfg *server.Config) { + headerAuth := "vault.method" + cfg.HeaderAuth = &headerAuth + }) + } +} diff --git a/test/15http_post_test.go b/test/15http_post_test.go index fd77ad5..341c496 100644 --- a/test/15http_post_test.go +++ b/test/15http_post_test.go @@ -268,11 +268,11 @@ func TestHTTPPost_AllowOrigin_ExpectedResponse(t *testing.T) { ExpectedMissingHeaders []string // Expected response headers not to be included ExpectedBody interface{} // Expected response body }{ - {"http://localhost", "", "*", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "*"}, []string{"Vary"}, successResponse}, - {"http://localhost", "", "http://localhost", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "http://localhost", "Vary": "Origin"}, nil, successResponse}, - {"https://resgate.io", "", "http://localhost;https://resgate.io", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "https://resgate.io", "Vary": "Origin"}, nil, successResponse}, + {"http://localhost", "", "*", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "*"}, []string{"Vary", "Access-Control-Allow-Credentials"}, successResponse}, + {"http://localhost", "", "http://localhost", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "http://localhost", "Vary": "Origin"}, []string{"Access-Control-Allow-Credentials"}, successResponse}, + {"https://resgate.io", "", "http://localhost;https://resgate.io", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "https://resgate.io", "Vary": "Origin"}, []string{"Access-Control-Allow-Credentials"}, successResponse}, // Invalid requests - {"http://example.com", "", "http://localhost;https://resgate.io", http.StatusForbidden, map[string]string{"Access-Control-Allow-Origin": "http://localhost", "Vary": "Origin"}, nil, reserr.ErrForbiddenOrigin}, + {"http://example.com", "", "http://localhost;https://resgate.io", http.StatusForbidden, map[string]string{"Access-Control-Allow-Origin": "http://localhost", "Vary": "Origin"}, []string{"Access-Control-Allow-Credentials"}, reserr.ErrForbiddenOrigin}, // No Origin header in request {"", "", "*", http.StatusOK, map[string]string{"Access-Control-Allow-Origin": "*"}, []string{"Vary"}, successResponse}, {"", "", "http://localhost", http.StatusOK, nil, []string{"Access-Control-Allow-Origin", "Vary"}, successResponse}, @@ -311,3 +311,80 @@ func TestHTTPPost_AllowOrigin_ExpectedResponse(t *testing.T) { }) } } + +func TestHTTPPost_HeaderAuth_ExpectedResponse(t *testing.T) { + token := json.RawMessage(`{"user":"foo"}`) + successResponse := json.RawMessage(`{"foo":"bar"}`) + + tbl := []struct { + AuthResponse interface{} // Response on auth request. requestTimeout means timeout. + Token interface{} // Token to send. noToken means no token events should be sent. + ExpectedHeaders map[string]string // Expected response Headers + }{ + // Without token + {requestTimeout, noToken, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {reserr.ErrNotFound, noToken, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {[]byte(`{]`), noToken, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {nil, noToken, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + // With token + {requestTimeout, token, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {reserr.ErrNotFound, token, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {[]byte(`{]`), token, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {nil, token, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + // With nil token + {requestTimeout, nil, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {reserr.ErrNotFound, nil, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {[]byte(`{]`), nil, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + {nil, nil, map[string]string{"Access-Control-Allow-Credentials": "true"}}, + } + + for i, l := range tbl { + l := l + runNamedTest(t, fmt.Sprintf("#%d", i+1), func(s *Session) { + hreq := s.HTTPRequest("POST", "/api/test/model/method", nil, func(req *http.Request) { + req.Header.Set("Origin", "example.com") + }) + + req := s.GetRequest(t) + req.AssertSubject(t, "auth.vault.method") + req.AssertPathPayload(t, "header.Origin", []string{"example.com"}) + // Send token + expectedToken := l.Token + if l.Token != noToken { + cid := req.PathPayload(t, "cid").(string) + s.ConnEvent(cid, "token", struct { + Token interface{} `json:"token"` + }{l.Token}) + } else { + expectedToken = nil + } + // Respond to auth request + if l.AuthResponse == requestTimeout { + req.Timeout() + } else if err, ok := l.AuthResponse.(*reserr.Error); ok { + req.RespondError(err) + } else if raw, ok := l.AuthResponse.([]byte); ok { + req.RespondRaw(raw) + } else { + req.RespondSuccess(l.AuthResponse) + } + + // Handle model get and access request + s.GetRequest(t). + AssertSubject(t, "access.test.model"). + AssertPathPayload(t, "token", expectedToken). + RespondSuccess(json.RawMessage(`{"get":true,"call":"*"}`)) + s.GetRequest(t). + AssertSubject(t, "call.test.model.method"). + RespondSuccess(successResponse) + + // Validate http response + hreq.GetResponse(t). + Equals(t, http.StatusOK, successResponse). + AssertHeaders(t, l.ExpectedHeaders) + }, func(cfg *server.Config) { + headerAuth := "vault.method" + cfg.HeaderAuth = &headerAuth + }) + } +} diff --git a/test/21http_options_test.go b/test/21http_options_test.go index 63cd9d1..01de140 100644 --- a/test/21http_options_test.go +++ b/test/21http_options_test.go @@ -49,9 +49,9 @@ func TestHTTPOptions_RequestHeaders_ExpectedResponseHeaders(t *testing.T) { ExpectedHeaders map[string]string // Expected response Headers ExpectedMissingHeaders []string // Expected response headers not to be included }{ - {[]string{"Content-Type"}, map[string]string{"Access-Control-Allow-Headers": "Content-Type"}, nil}, - {[]string{"X-PINGOTHER", "Content-Type"}, map[string]string{"Access-Control-Allow-Headers": "X-PINGOTHER, Content-Type"}, nil}, - {[]string{"X-PINGOTHER", "Content-Type", "Authorization"}, map[string]string{"Access-Control-Allow-Headers": "X-PINGOTHER, Content-Type, Authorization"}, nil}, + {[]string{"Content-Type"}, map[string]string{"Access-Control-Allow-Headers": "Content-Type"}, []string{"Access-Control-Allow-Credentials"}}, + {[]string{"X-PINGOTHER", "Content-Type"}, map[string]string{"Access-Control-Allow-Headers": "X-PINGOTHER, Content-Type"}, []string{"Access-Control-Allow-Credentials"}}, + {[]string{"X-PINGOTHER", "Content-Type", "Authorization"}, map[string]string{"Access-Control-Allow-Headers": "X-PINGOTHER, Content-Type, Authorization"}, []string{"Access-Control-Allow-Credentials"}}, {nil, nil, []string{"Access-Control-Allow-Headers"}}, } @@ -71,3 +71,17 @@ func TestHTTPOptions_RequestHeaders_ExpectedResponseHeaders(t *testing.T) { }) } } + +func TestHTTPOptions_HeaderAuth_HasExpectedResponseHeaders(t *testing.T) { + + runTest(t, func(s *Session) { + hreq := s.HTTPRequest("OPTIONS", "/api/test/model", nil) + // Validate http response + hreq.GetResponse(t). + Equals(t, http.StatusOK, nil). + AssertHeaders(t, map[string]string{"Access-Control-Allow-Credentials": "true"}) + }, func(cfg *server.Config) { + headerAuth := "vault.method" + cfg.HeaderAuth = &headerAuth + }) +} diff --git a/test/resources.go b/test/resources.go index 0ee3816..449cc0b 100644 --- a/test/resources.go +++ b/test/resources.go @@ -104,6 +104,7 @@ var resources = map[string]resource{ const ( requestTimeout uint64 = iota noRequest + noToken ) type sequenceEvent struct {