From 5279dfb8a75d1c4b4b7ea3e01f1d3fb558dc8dcb Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Wed, 15 Nov 2023 15:24:05 +0100 Subject: [PATCH 1/7] first setup for policy backend API spec --- docs/_static/policy/v1.yaml | 133 ++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/_static/policy/v1.yaml diff --git a/docs/_static/policy/v1.yaml b/docs/_static/policy/v1.yaml new file mode 100644 index 0000000000..961f3bdc5d --- /dev/null +++ b/docs/_static/policy/v1.yaml @@ -0,0 +1,133 @@ +openapi: 3.1.0 +info: + title: Policy backend API specification + version: 0.1.0 +servers: + - url: "http://localhost:1323" +paths: + /{did}/presentation_definition: + parameters: + - name: did + in: path + description: URLEncoded DID. + required: true + example: did:web:example.com:1 + schema: + type: string + - name: scope + in: query + description: | + This is the scope used in the OpenID4VP authorization request. + It is a space separated list of scopes. + required: true + schema: + type: string + get: + summary: Returns a presentation definition for the given DID and scope. + description: | + The DID is used for tenant selection. Not all tenants will probably support the same scopes. + The scope is used as selection criteria for the presentation definition. + It could be the case that the presentation definition is not found. + In that case the response will be 201 with an empty body. + operationId: "presentationDefinition" + tags: + - policy + responses: + "200": + description: | + DID has been found and the scope is supported. + If the scope is supported but no presentation definition is required, the response will be 200 with a presentation definition without any input descriptors. + content: + application/json: + schema: + $ref: '#/components/schemas/PresentationDefinition' + "201": + description: The DID is known but the presented scope is not supported. + "404": + description: DID is not known to the policy backend. + /{did}/authorize: + parameters: + - name: did + in: path + description: URLEncoded DID. The DID is used for tenant selection. + required: true + example: did:web:example.com:1 + schema: + type: string + post: + summary: Authorize a resource request. + description: | + When an access token is used to request a resource, the resource server needs to know if the access token grants access to the requested resource. + The resource server will send a request to the policy backend to check if the access token grants access to the requested resource. + + operationId: "authorize" + tags: + - policy + requestBody: + description: Required params for policy backend to make an informed decision. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AuthorizeRequest' + responses: + "200": + description: A response that indicates if the access token grants access to the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/AuthorizeResponse' + "404": + description: DID is not known to the policy backend. +components: + schemas: + AuthorizeRequest: + description: | + The request contains all params involved with the request. + It might be the case that the caller mapped credential fields to additional params. + type: object + required: + - client_id + - scope + - request_url + - request_method + - presentation_submission + - vps + properties: + client_id: + description: The client ID of the client that requested the resource (DID). + type: string + scope: + description: The scope used in the authorization request. + type: string + request_url: + description: The URL of the resource request. + type: string + request_method: + description: The method of the resource request. + type: string + presentation_submission: + description: The presentation submission that was used to request the access token. + type: object + vps: + description: | + The verifiable presentations that were used to request the access token. + The verifiable presentations could be in JWT format or in JSON format. + type: array + AuthorizeResponse: + description: | + The response indicates if the access token grants access to the requested resource. + If the access token grants access, the response will be 200 with a boolean value set to true. + If the access token does not grant access, the response will be 200 with a boolean value set to false. + type: object + required: + - authorized + properties: + authorized: + description: Indicates if the access token grants access to the requested resource. + type: boolean + PresentationDefinition: + description: | + A presentation definition is a JSON object that describes the desired verifiable credentials and presentation formats. + Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ + A JSON schema is available at https://identity.foundation/presentation-exchange/#json-schema From b829742249bd8157cebb1fcbe68c961ad6b8a8df Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Fri, 17 Nov 2023 08:33:59 +0100 Subject: [PATCH 2/7] PR feedback --- docs/_static/policy/v1.yaml | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/docs/_static/policy/v1.yaml b/docs/_static/policy/v1.yaml index 961f3bdc5d..3c8bd90878 100644 --- a/docs/_static/policy/v1.yaml +++ b/docs/_static/policy/v1.yaml @@ -45,22 +45,14 @@ paths: description: The DID is known but the presented scope is not supported. "404": description: DID is not known to the policy backend. - /{did}/authorize: - parameters: - - name: did - in: path - description: URLEncoded DID. The DID is used for tenant selection. - required: true - example: did:web:example.com:1 - schema: - type: string + /authorized: post: - summary: Authorize a resource request. + summary: Check if a resource request is authorized. description: | When an access token is used to request a resource, the resource server needs to know if the access token grants access to the requested resource. The resource server will send a request to the policy backend to check if the access token grants access to the requested resource. - - operationId: "authorize" + All cryptographic and presentation exchange validations have already been done by the caller. + operationId: "authorized" tags: - policy requestBody: @@ -69,24 +61,25 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/AuthorizeRequest' + $ref: '#/components/schemas/AuthorizedRequest' responses: "200": description: A response that indicates if the access token grants access to the requested resource. content: application/json: schema: - $ref: '#/components/schemas/AuthorizeResponse' + $ref: '#/components/schemas/AuthorizedResponse' "404": description: DID is not known to the policy backend. components: schemas: - AuthorizeRequest: + AuthorizedRequest: description: | The request contains all params involved with the request. It might be the case that the caller mapped credential fields to additional params. type: object required: + - audience - client_id - scope - request_url @@ -94,6 +87,9 @@ components: - presentation_submission - vps properties: + audience: + description: The audience of the access token. This is the identifier (DID) of the authorizer and issuer of the access token. + type: string client_id: description: The client ID of the client that requested the resource (DID). type: string @@ -114,7 +110,7 @@ components: The verifiable presentations that were used to request the access token. The verifiable presentations could be in JWT format or in JSON format. type: array - AuthorizeResponse: + AuthorizedResponse: description: | The response indicates if the access token grants access to the requested resource. If the access token grants access, the response will be 200 with a boolean value set to true. From b878710ea65341602acd6713618fee750dc28de5 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Fri, 17 Nov 2023 15:23:24 +0100 Subject: [PATCH 3/7] generated client code for policy api, implemented presentation_definition call --- codegen/configs/policy_client_v1.yaml | 11 + docs/_static/policy/v1.yaml | 15 +- makefile | 1 + policy/api/v1/client/client.go | 83 +++++ policy/api/v1/client/client_test.go | 106 ++++++ policy/api/v1/client/generated.go | 455 ++++++++++++++++++++++++++ policy/api/v1/client/types.go | 27 ++ 7 files changed, 693 insertions(+), 5 deletions(-) create mode 100644 codegen/configs/policy_client_v1.yaml create mode 100644 policy/api/v1/client/client.go create mode 100644 policy/api/v1/client/client_test.go create mode 100644 policy/api/v1/client/generated.go create mode 100644 policy/api/v1/client/types.go diff --git a/codegen/configs/policy_client_v1.yaml b/codegen/configs/policy_client_v1.yaml new file mode 100644 index 0000000000..5bd811ccf1 --- /dev/null +++ b/codegen/configs/policy_client_v1.yaml @@ -0,0 +1,11 @@ +package: client +generate: + echo-server: false + client: true + models: true + strict-server: true +output-options: + skip-prune: true + exclude-schemas: + - PresentationDefinition + - PresentationSubmission diff --git a/docs/_static/policy/v1.yaml b/docs/_static/policy/v1.yaml index 3c8bd90878..e221b36cef 100644 --- a/docs/_static/policy/v1.yaml +++ b/docs/_static/policy/v1.yaml @@ -5,10 +5,10 @@ info: servers: - url: "http://localhost:1323" paths: - /{did}/presentation_definition: + /presentation_definition: parameters: - - name: did - in: path + - name: authorizer + in: query description: URLEncoded DID. required: true example: did:web:example.com:1 @@ -52,7 +52,7 @@ paths: When an access token is used to request a resource, the resource server needs to know if the access token grants access to the requested resource. The resource server will send a request to the policy backend to check if the access token grants access to the requested resource. All cryptographic and presentation exchange validations have already been done by the caller. - operationId: "authorized" + operationId: "checkAuthorized" tags: - policy requestBody: @@ -104,7 +104,7 @@ components: type: string presentation_submission: description: The presentation submission that was used to request the access token. - type: object + $ref: '#/components/schemas/PresentationSubmission' vps: description: | The verifiable presentations that were used to request the access token. @@ -127,3 +127,8 @@ components: A presentation definition is a JSON object that describes the desired verifiable credentials and presentation formats. Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ A JSON schema is available at https://identity.foundation/presentation-exchange/#json-schema + PresentationSubmission: + description: | + A presentation submission is a JSON object that maps requirements from the Presentation Definition to the verifiable presentations that were used to request an access token. + Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ + A JSON schema is available at https://identity.foundation/presentation-exchange/#json-schema diff --git a/makefile b/makefile index 6f6efae5be..31b5d65065 100644 --- a/makefile +++ b/makefile @@ -68,6 +68,7 @@ gen-api: oapi-codegen --config codegen/configs/auth_iam.yaml docs/_static/auth/iam.yaml | gofmt > auth/api/iam/generated.go oapi-codegen --config codegen/configs/didman_v1.yaml docs/_static/didman/v1.yaml | gofmt > didman/api/v1/generated.go oapi-codegen --config codegen/configs/crypto_store_client.yaml https://raw.githubusercontent.com/nuts-foundation/secret-store-api/main/nuts-storage-api-v1.yaml | gofmt > crypto/storage/external/generated.go + oapi-codegen --config codegen/configs/policy_client_v1.yaml docs/_static/policy/v1.yaml | gofmt > policy/api/v1/client/generated.go gen-protobuf: protoc --go_out=paths=source_relative:network -I network network/transport/v2/protocol.proto diff --git a/policy/api/v1/client/client.go b/policy/api/v1/client/client.go new file mode 100644 index 0000000000..1e8b21b1d8 --- /dev/null +++ b/policy/api/v1/client/client.go @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "github.com/nuts-foundation/go-did/did" + "io" + "net/http" + "net/url" + "time" + + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +// HTTPClient holds the server address and other basic settings for the http client +type HTTPClient struct { + strictMode bool + httpClient core.HTTPRequestDoer +} + +// NewHTTPClient creates a new api client. +func NewHTTPClient(strictMode bool, timeout time.Duration, tlsConfig *tls.Config) HTTPClient { + return HTTPClient{ + strictMode: strictMode, + httpClient: core.NewStrictHTTPClient(strictMode, timeout, tlsConfig), + } +} + +// PresentationDefinition retrieves the presentation definition from the presentation definition endpoint for the given scope and . +func (hb HTTPClient) PresentationDefinition(ctx context.Context, policyEndpoint string, authorizer did.DID, scopes string) (*pe.PresentationDefinition, error) { + presentationDefinitionURL, err := core.ParsePublicURL(policyEndpoint, hb.strictMode) + if err != nil { + return nil, err + } + presentationDefinitionURL.Path = fmt.Sprintf("%s/presentation_definition", presentationDefinitionURL.Path) + presentationDefinitionURL.RawQuery = url.Values{"scope": []string{scopes}, "authorizer": []string{authorizer.String()}}.Encode() + + // create a GET request with query params + request, err := http.NewRequestWithContext(ctx, http.MethodGet, presentationDefinitionURL.String(), nil) + if err != nil { + return nil, err + } + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("failed to call endpoint: %w", err) + } + if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil { + return nil, httpErr + } + + var presentationDefinition pe.PresentationDefinition + var data []byte + + if data, err = io.ReadAll(response.Body); err != nil { + return nil, fmt.Errorf("unable to read response: %w", err) + } + if err = json.Unmarshal(data, &presentationDefinition); err != nil { + return nil, fmt.Errorf("unable to unmarshal response: %w", err) + } + + return &presentationDefinition, nil +} diff --git a/policy/api/v1/client/client_test.go b/policy/api/v1/client/client_test.go new file mode 100644 index 0000000000..94749da224 --- /dev/null +++ b/policy/api/v1/client/client_test.go @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import ( + "context" + "encoding/json" + "github.com/nuts-foundation/go-did/did" + http2 "github.com/nuts-foundation/nuts-node/test/http" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHTTPClient_PresentationDefinition(t *testing.T) { + ctx := context.Background() + authorizer := did.MustParseDID("did:web:example.com") + definition := pe.PresentationDefinition{ + Id: "123", + } + + t.Run("ok", func(t *testing.T) { + var capturedRequest *http.Request + handler := func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/presentation_definition": + capturedRequest = request + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(definition) + writer.Write(bytes) + } + writer.WriteHeader(http.StatusNotFound) + } + tlsServer, client := testServerAndClient(t, http.HandlerFunc(handler)) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, authorizer, "test") + + require.NoError(t, err) + require.NotNil(t, definition) + assert.Equal(t, definition, *response) + require.NotNil(t, capturedRequest) + assert.Equal(t, "GET", capturedRequest.Method) + assert.Equal(t, "/presentation_definition", capturedRequest.URL.Path) + // check query params + require.NotNil(t, capturedRequest.URL.Query().Get("scope")) + assert.Equal(t, "test", capturedRequest.URL.Query().Get("scope")) + require.NotNil(t, capturedRequest.URL.Query().Get("authorizer")) + assert.Equal(t, authorizer.String(), capturedRequest.URL.Query().Get("authorizer")) + }) + t.Run("error - not found", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusNotFound} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, authorizer, "test") + + require.Error(t, err) + assert.EqualError(t, err, "server returned HTTP 404 (expected: 200)") + assert.Nil(t, response) + }) + t.Run("error - invalid URL", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusNotFound} + _, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, ":", authorizer, "test") + + require.Error(t, err) + assert.EqualError(t, err, "parse \":\": missing protocol scheme") + assert.Nil(t, response) + }) + t.Run("error - invalid response", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.PresentationDefinition(ctx, tlsServer.URL, authorizer, "test") + + require.Error(t, err) + assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value") + assert.Nil(t, response) + }) +} + +func testServerAndClient(t *testing.T, handler http.Handler) (*httptest.Server, *HTTPClient) { + tlsServer := http2.TestTLSServer(t, handler) + return tlsServer, &HTTPClient{ + httpClient: tlsServer.Client(), + } +} diff --git a/policy/api/v1/client/generated.go b/policy/api/v1/client/generated.go new file mode 100644 index 0000000000..ad20e5c8cd --- /dev/null +++ b/policy/api/v1/client/generated.go @@ -0,0 +1,455 @@ +// Package client provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen/v2 version v2.0.0 DO NOT EDIT. +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/oapi-codegen/runtime" +) + +// AuthorizedRequest The request contains all params involved with the request. +// It might be the case that the caller mapped credential fields to additional params. +type AuthorizedRequest struct { + // Audience The audience of the access token. This is the identifier (DID) of the authorizer and issuer of the access token. + Audience string `json:"audience"` + + // ClientId The client ID of the client that requested the resource (DID). + ClientId string `json:"client_id"` + + // PresentationSubmission A presentation submission is a JSON object that maps requirements from the Presentation Definition to the verifiable presentations that were used to request an access token. + // Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ + // A JSON schema is available at https://identity.foundation/presentation-exchange/#json-schema + PresentationSubmission PresentationSubmission `json:"presentation_submission"` + + // RequestMethod The method of the resource request. + RequestMethod string `json:"request_method"` + + // RequestUrl The URL of the resource request. + RequestUrl string `json:"request_url"` + + // Scope The scope used in the authorization request. + Scope string `json:"scope"` + + // Vps The verifiable presentations that were used to request the access token. + // The verifiable presentations could be in JWT format or in JSON format. + Vps []interface{} `json:"vps"` +} + +// AuthorizedResponse The response indicates if the access token grants access to the requested resource. +// If the access token grants access, the response will be 200 with a boolean value set to true. +// If the access token does not grant access, the response will be 200 with a boolean value set to false. +type AuthorizedResponse struct { + // Authorized Indicates if the access token grants access to the requested resource. + Authorized bool `json:"authorized"` +} + +// PresentationDefinitionParams defines parameters for PresentationDefinition. +type PresentationDefinitionParams struct { + // Authorizer URLEncoded DID. + Authorizer string `form:"authorizer" json:"authorizer"` + + // Scope This is the scope used in the OpenID4VP authorization request. + // It is a space separated list of scopes. + Scope string `form:"scope" json:"scope"` +} + +// CheckAuthorizedJSONRequestBody defines body for CheckAuthorized for application/json ContentType. +type CheckAuthorizedJSONRequestBody = AuthorizedRequest + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // CheckAuthorizedWithBody request with any body + CheckAuthorizedWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CheckAuthorized(ctx context.Context, body CheckAuthorizedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PresentationDefinition request + PresentationDefinition(ctx context.Context, params *PresentationDefinitionParams, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) CheckAuthorizedWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCheckAuthorizedRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CheckAuthorized(ctx context.Context, body CheckAuthorizedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCheckAuthorizedRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PresentationDefinition(ctx context.Context, params *PresentationDefinitionParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPresentationDefinitionRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewCheckAuthorizedRequest calls the generic CheckAuthorized builder with application/json body +func NewCheckAuthorizedRequest(server string, body CheckAuthorizedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCheckAuthorizedRequestWithBody(server, "application/json", bodyReader) +} + +// NewCheckAuthorizedRequestWithBody generates requests for CheckAuthorized with any type of body +func NewCheckAuthorizedRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/authorized") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewPresentationDefinitionRequest generates requests for PresentationDefinition +func NewPresentationDefinitionRequest(server string, params *PresentationDefinitionParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/presentation_definition") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "authorizer", runtime.ParamLocationQuery, params.Authorizer); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "scope", runtime.ParamLocationQuery, params.Scope); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // CheckAuthorizedWithBodyWithResponse request with any body + CheckAuthorizedWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CheckAuthorizedResponse, error) + + CheckAuthorizedWithResponse(ctx context.Context, body CheckAuthorizedJSONRequestBody, reqEditors ...RequestEditorFn) (*CheckAuthorizedResponse, error) + + // PresentationDefinitionWithResponse request + PresentationDefinitionWithResponse(ctx context.Context, params *PresentationDefinitionParams, reqEditors ...RequestEditorFn) (*PresentationDefinitionResponse, error) +} + +type CheckAuthorizedResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AuthorizedResponse +} + +// Status returns HTTPResponse.Status +func (r CheckAuthorizedResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CheckAuthorizedResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PresentationDefinitionResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PresentationDefinition +} + +// Status returns HTTPResponse.Status +func (r PresentationDefinitionResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PresentationDefinitionResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// CheckAuthorizedWithBodyWithResponse request with arbitrary body returning *CheckAuthorizedResponse +func (c *ClientWithResponses) CheckAuthorizedWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CheckAuthorizedResponse, error) { + rsp, err := c.CheckAuthorizedWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCheckAuthorizedResponse(rsp) +} + +func (c *ClientWithResponses) CheckAuthorizedWithResponse(ctx context.Context, body CheckAuthorizedJSONRequestBody, reqEditors ...RequestEditorFn) (*CheckAuthorizedResponse, error) { + rsp, err := c.CheckAuthorized(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCheckAuthorizedResponse(rsp) +} + +// PresentationDefinitionWithResponse request returning *PresentationDefinitionResponse +func (c *ClientWithResponses) PresentationDefinitionWithResponse(ctx context.Context, params *PresentationDefinitionParams, reqEditors ...RequestEditorFn) (*PresentationDefinitionResponse, error) { + rsp, err := c.PresentationDefinition(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParsePresentationDefinitionResponse(rsp) +} + +// ParseCheckAuthorizedResponse parses an HTTP response from a CheckAuthorizedWithResponse call +func ParseCheckAuthorizedResponse(rsp *http.Response) (*CheckAuthorizedResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CheckAuthorizedResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AuthorizedResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + +// ParsePresentationDefinitionResponse parses an HTTP response from a PresentationDefinitionWithResponse call +func ParsePresentationDefinitionResponse(rsp *http.Response) (*PresentationDefinitionResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PresentationDefinitionResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PresentationDefinition + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} diff --git a/policy/api/v1/client/types.go b/policy/api/v1/client/types.go new file mode 100644 index 0000000000..b9aad6779b --- /dev/null +++ b/policy/api/v1/client/types.go @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package client + +import "github.com/nuts-foundation/nuts-node/vcr/pe" + +// PresentationDefinition is a type alias for the PresentationDefinition from the nuts-node/vcr/pe package. +type PresentationDefinition = pe.PresentationDefinition + +// PresentationSubmission is a type alias for the PresentationSubmission from the nuts-node/vcr/pe package. +type PresentationSubmission = pe.PresentationSubmission From 6008f49e92aede522cdc638018bd8282d31efdb7 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Mon, 20 Nov 2023 15:31:29 +0100 Subject: [PATCH 4/7] added authorized client API and tests --- policy/api/v1/client/client.go | 48 +++++++++++++--- policy/api/v1/client/client_test.go | 85 +++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/policy/api/v1/client/client.go b/policy/api/v1/client/client.go index 1e8b21b1d8..20d735a6d6 100644 --- a/policy/api/v1/client/client.go +++ b/policy/api/v1/client/client.go @@ -26,7 +26,6 @@ import ( "github.com/nuts-foundation/go-did/did" "io" "net/http" - "net/url" "time" "github.com/nuts-foundation/nuts-node/core" @@ -48,20 +47,21 @@ func NewHTTPClient(strictMode bool, timeout time.Duration, tlsConfig *tls.Config } // PresentationDefinition retrieves the presentation definition from the presentation definition endpoint for the given scope and . -func (hb HTTPClient) PresentationDefinition(ctx context.Context, policyEndpoint string, authorizer did.DID, scopes string) (*pe.PresentationDefinition, error) { - presentationDefinitionURL, err := core.ParsePublicURL(policyEndpoint, hb.strictMode) +func (hb HTTPClient) PresentationDefinition(ctx context.Context, serverAddress string, authorizer did.DID, scopes string) (*pe.PresentationDefinition, error) { + _, err := core.ParsePublicURL(serverAddress, hb.strictMode) if err != nil { return nil, err } - presentationDefinitionURL.Path = fmt.Sprintf("%s/presentation_definition", presentationDefinitionURL.Path) - presentationDefinitionURL.RawQuery = url.Values{"scope": []string{scopes}, "authorizer": []string{authorizer.String()}}.Encode() - // create a GET request with query params - request, err := http.NewRequestWithContext(ctx, http.MethodGet, presentationDefinitionURL.String(), nil) + client, err := NewClient(serverAddress, WithHTTPClient(hb.httpClient)) if err != nil { return nil, err } - response, err := hb.httpClient.Do(request.WithContext(ctx)) + params := &PresentationDefinitionParams{ + Scope: scopes, + Authorizer: authorizer.String(), + } + response, err := client.PresentationDefinition(ctx, params) if err != nil { return nil, fmt.Errorf("failed to call endpoint: %w", err) } @@ -81,3 +81,35 @@ func (hb HTTPClient) PresentationDefinition(ctx context.Context, policyEndpoint return &presentationDefinition, nil } + +func (hb HTTPClient) Authorized(ctx context.Context, serverAddress string, request AuthorizedRequest) (bool, error) { + _, err := core.ParsePublicURL(serverAddress, hb.strictMode) + if err != nil { + return false, err + } + + client, err := NewClient(serverAddress, WithHTTPClient(hb.httpClient)) + if err != nil { + return false, err + } + + response, err := client.CheckAuthorized(ctx, request) + if err != nil { + return false, fmt.Errorf("failed to call endpoint: %w", err) + } + if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil { + return false, httpErr + } + + var authorized bool + var data []byte + + if data, err = io.ReadAll(response.Body); err != nil { + return false, fmt.Errorf("unable to read response: %w", err) + } + if err = json.Unmarshal(data, &authorized); err != nil { + return false, fmt.Errorf("unable to unmarshal response: %w", err) + } + + return authorized, nil +} diff --git a/policy/api/v1/client/client_test.go b/policy/api/v1/client/client_test.go index 94749da224..57ba5fa460 100644 --- a/policy/api/v1/client/client_test.go +++ b/policy/api/v1/client/client_test.go @@ -26,6 +26,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "io" "net/http" "net/http/httptest" "testing" @@ -98,6 +99,90 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { }) } +func TestHTTPClient_Authorized(t *testing.T) { + ctx := context.Background() + audience := did.MustParseDID("did:web:example.com:audience") + request := AuthorizedRequest{ + Audience: audience.String(), + ClientId: "did:web:example.com:client", + PresentationSubmission: PresentationSubmission{}, + RequestMethod: "GET", + RequestUrl: "/resource", + Scope: "test 1 2 3", + Vps: nil, + } + + t.Run("ok", func(t *testing.T) { + var capturedRequest *http.Request + var capturedRequestBody []byte + handler := func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/authorized": + capturedRequest = request + capturedRequestBody, _ = io.ReadAll(request.Body) + writer.WriteHeader(http.StatusOK) + writer.Write([]byte("true")) + } + writer.WriteHeader(http.StatusNotFound) + } + tlsServer, client := testServerAndClient(t, http.HandlerFunc(handler)) + + response, err := client.Authorized(ctx, tlsServer.URL, request) + + require.NoError(t, err) + assert.True(t, response) + require.NotNil(t, capturedRequest) + assert.Equal(t, "POST", capturedRequest.Method) + assert.Equal(t, "/authorized", capturedRequest.URL.Path) + // check body + require.NotNil(t, capturedRequest.Body) + var capturedRequestData AuthorizedRequest + err = json.Unmarshal(capturedRequestBody, &capturedRequestData) + require.NoError(t, err) + assert.Equal(t, request, capturedRequestData) + }) + t.Run("error - not found", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusNotFound} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.Authorized(ctx, tlsServer.URL, request) + + require.Error(t, err) + assert.EqualError(t, err, "server returned HTTP 404 (expected: 200)") + assert.False(t, response) + }) + t.Run("error - invalid URL", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusNotFound} + _, client := testServerAndClient(t, &handler) + + response, err := client.Authorized(ctx, ":", request) + + require.Error(t, err) + assert.EqualError(t, err, "parse \":\": missing protocol scheme") + assert.False(t, response) + }) + t.Run("error - invalid response", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.Authorized(ctx, tlsServer.URL, request) + + require.Error(t, err) + assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value") + assert.False(t, response) + }) + t.Run("error - invalid endpoint", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK} + _, client := testServerAndClient(t, &handler) + + response, err := client.Authorized(ctx, "http://::1:1", request) + + require.Error(t, err) + assert.EqualError(t, err, "failed to call endpoint: Post \"http://::1:1/authorized\": dial tcp [::1]:1: connect: connection refused") + assert.False(t, response) + }) +} + func testServerAndClient(t *testing.T, handler http.Handler) (*httptest.Server, *HTTPClient) { tlsServer := http2.TestTLSServer(t, handler) return tlsServer, &HTTPClient{ From e4cb0d8a8f699ca4c92beea497ff22af39bb0fe3 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Tue, 21 Nov 2023 15:55:14 +0100 Subject: [PATCH 5/7] test fix for remote builder --- policy/api/v1/client/client_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policy/api/v1/client/client_test.go b/policy/api/v1/client/client_test.go index 57ba5fa460..f1c376cf60 100644 --- a/policy/api/v1/client/client_test.go +++ b/policy/api/v1/client/client_test.go @@ -175,10 +175,10 @@ func TestHTTPClient_Authorized(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK} _, client := testServerAndClient(t, &handler) - response, err := client.Authorized(ctx, "http://::1:1", request) + response, err := client.Authorized(ctx, "http://test.test", request) require.Error(t, err) - assert.EqualError(t, err, "failed to call endpoint: Post \"http://::1:1/authorized\": dial tcp [::1]:1: connect: connection refused") + assert.EqualError(t, err, "failed to call endpoint: Post \"http://test.test/authorized\": dial tcp: lookup test.test: no such host") assert.False(t, response) }) } From 360a7e447a4e62eff1ba3580466560649267956b Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Wed, 22 Nov 2023 11:05:09 +0100 Subject: [PATCH 6/7] PR feedback --- policy/api/v1/client/client.go | 33 ++++++++++++----------------- policy/api/v1/client/client_test.go | 9 +++++--- test/http/handler.go | 4 ++++ 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/policy/api/v1/client/client.go b/policy/api/v1/client/client.go index 20d735a6d6..d8cfb3ade5 100644 --- a/policy/api/v1/client/client.go +++ b/policy/api/v1/client/client.go @@ -21,10 +21,8 @@ package client import ( "context" "crypto/tls" - "encoding/json" "fmt" "github.com/nuts-foundation/go-did/did" - "io" "net/http" "time" @@ -46,7 +44,7 @@ func NewHTTPClient(strictMode bool, timeout time.Duration, tlsConfig *tls.Config } } -// PresentationDefinition retrieves the presentation definition from the presentation definition endpoint for the given scope and . +// PresentationDefinition retrieves the presentation definition from the presentation definition endpoint for the given scope and authorizer. func (hb HTTPClient) PresentationDefinition(ctx context.Context, serverAddress string, authorizer did.DID, scopes string) (*pe.PresentationDefinition, error) { _, err := core.ParsePublicURL(serverAddress, hb.strictMode) if err != nil { @@ -69,19 +67,16 @@ func (hb HTTPClient) PresentationDefinition(ctx context.Context, serverAddress s return nil, httpErr } - var presentationDefinition pe.PresentationDefinition - var data []byte - - if data, err = io.ReadAll(response.Body); err != nil { - return nil, fmt.Errorf("unable to read response: %w", err) - } - if err = json.Unmarshal(data, &presentationDefinition); err != nil { + presentationDefinitionResponse, err := ParsePresentationDefinitionResponse(response) + if err != nil { return nil, fmt.Errorf("unable to unmarshal response: %w", err) } - return &presentationDefinition, nil + return presentationDefinitionResponse.JSON200, nil } +// Authorized checks if the given request is authorized by the policy backend. +// The AuthorizedRequest contains the information that is needed to check if the request is authorized. func (hb HTTPClient) Authorized(ctx context.Context, serverAddress string, request AuthorizedRequest) (bool, error) { _, err := core.ParsePublicURL(serverAddress, hb.strictMode) if err != nil { @@ -101,15 +96,15 @@ func (hb HTTPClient) Authorized(ctx context.Context, serverAddress string, reque return false, httpErr } - var authorized bool - var data []byte - - if data, err = io.ReadAll(response.Body); err != nil { - return false, fmt.Errorf("unable to read response: %w", err) - } - if err = json.Unmarshal(data, &authorized); err != nil { + authorizedResponse, err := ParseCheckAuthorizedResponse(response) + if err != nil { return false, fmt.Errorf("unable to unmarshal response: %w", err) } - return authorized, nil + // theoretically, the JSON200 field could be nil. The API should always return a response, so we return it as error. + if authorizedResponse.JSON200 == nil { + return false, fmt.Errorf("response is nil") + } + + return authorizedResponse.JSON200.Authorized, nil } diff --git a/policy/api/v1/client/client_test.go b/policy/api/v1/client/client_test.go index f1c376cf60..dc43b68132 100644 --- a/policy/api/v1/client/client_test.go +++ b/policy/api/v1/client/client_test.go @@ -45,6 +45,7 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { switch request.URL.Path { case "/presentation_definition": capturedRequest = request + writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) bytes, _ := json.Marshal(definition) writer.Write(bytes) @@ -57,6 +58,7 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { require.NoError(t, err) require.NotNil(t, definition) + require.NotNil(t, response) assert.Equal(t, definition, *response) require.NotNil(t, capturedRequest) assert.Equal(t, "GET", capturedRequest.Method) @@ -88,7 +90,7 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { assert.Nil(t, response) }) t.Run("error - invalid response", func(t *testing.T) { - handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}", ResponseHeader: http.Header{"Content-Type": []string{"application/json"}}} tlsServer, client := testServerAndClient(t, &handler) response, err := client.PresentationDefinition(ctx, tlsServer.URL, authorizer, "test") @@ -120,8 +122,9 @@ func TestHTTPClient_Authorized(t *testing.T) { case "/authorized": capturedRequest = request capturedRequestBody, _ = io.ReadAll(request.Body) + writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(http.StatusOK) - writer.Write([]byte("true")) + writer.Write([]byte("{\"authorized\":true}")) } writer.WriteHeader(http.StatusNotFound) } @@ -162,7 +165,7 @@ func TestHTTPClient_Authorized(t *testing.T) { assert.False(t, response) }) t.Run("error - invalid response", func(t *testing.T) { - handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}", ResponseHeader: http.Header{"Content-Type": []string{"application/json"}}} tlsServer, client := testServerAndClient(t, &handler) response, err := client.Authorized(ctx, tlsServer.URL, request) diff --git a/test/http/handler.go b/test/http/handler.go index 0f2159034b..6edd9f3b27 100644 --- a/test/http/handler.go +++ b/test/http/handler.go @@ -34,6 +34,7 @@ type Handler struct { StatusCode int RequestData []byte ResponseData interface{} + ResponseHeader http.Header } func (h *Handler) ServeHTTP(writer http.ResponseWriter, req *http.Request) { @@ -49,6 +50,9 @@ func (h *Handler) ServeHTTP(writer http.ResponseWriter, req *http.Request) { bytes, _ = json.Marshal(h.ResponseData) } + for k, v := range h.ResponseHeader { + writer.Header().Add(k, v[0]) + } writer.WriteHeader(h.StatusCode) writer.Write(bytes) } From 124df4fffd5273d21e43bcea67041f8b71a92a0f Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Thu, 23 Nov 2023 10:18:47 +0100 Subject: [PATCH 7/7] test fix for CI --- policy/api/v1/client/client_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policy/api/v1/client/client_test.go b/policy/api/v1/client/client_test.go index dc43b68132..8252325272 100644 --- a/policy/api/v1/client/client_test.go +++ b/policy/api/v1/client/client_test.go @@ -181,7 +181,9 @@ func TestHTTPClient_Authorized(t *testing.T) { response, err := client.Authorized(ctx, "http://test.test", request) require.Error(t, err) - assert.EqualError(t, err, "failed to call endpoint: Post \"http://test.test/authorized\": dial tcp: lookup test.test: no such host") + // CI has a slightly different error: lookup test.test vs lookup test.test on 127.0.0.x + assert.ErrorContains(t, err, "failed to call endpoint: Post \"http://test.test/authorized\": dial tcp: lookup test.test") + assert.ErrorContains(t, err, "no such host") assert.False(t, response) }) }