From 143bc65d3b510a7c66b64be69160101c49c49641 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Thu, 30 Dec 2021 05:27:52 +0500 Subject: [PATCH] :recycle: Refactored ticket package --- internal/cmd/root.go | 26 +-- internal/cmd/start.go | 15 +- internal/domain/url.go | 14 ++ internal/ticket/delivery/http/ticket_http.go | 29 +-- .../ticket/delivery/http/ticket_http_test.go | 13 +- internal/ticket/repository.go | 15 ++ .../ticket/repository/http/http_ticket.go | 174 ++++++++++++++++++ .../repository/http/http_ticket_test.go | 104 +++++++++++ .../ticket/repository/memory/memory_ticket.go | 36 ++++ .../repository/memory/memory_ticket_test.go | 28 +++ internal/ticket/usecase.go | 2 +- internal/ticket/usecase/ticket_ucase.go | 13 +- internal/ticket/usecase/ticket_ucase_test.go | 13 +- 13 files changed, 430 insertions(+), 52 deletions(-) create mode 100644 internal/ticket/repository.go create mode 100644 internal/ticket/repository/http/http_ticket.go create mode 100644 internal/ticket/repository/http/http_ticket_test.go create mode 100644 internal/ticket/repository/memory/memory_ticket.go create mode 100644 internal/ticket/repository/memory/memory_ticket_test.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 1cc1689..571cda8 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -7,7 +7,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - http "github.com/valyala/fasthttp" "source.toby3d.me/website/oauth/internal/domain" ) @@ -67,17 +66,22 @@ func initConfig() { log.Fatalln("fail to read config:", err) } - u, logo, redirect := http.AcquireURI(), http.AcquireURI(), http.AcquireURI() - if err = u.Parse(nil, []byte(rootURL)); err != nil { - log.Fatalln("cannot parse client URL:", err) + url, err := domain.NewURL(rootURL) + if err != nil { + log.Fatalln("cannot parse root URL as client URL:", err) } - u.CopyTo(logo) - u.CopyTo(redirect) - redirect.SetPath("/callback") - logo.SetPath(config.Server.StaticURLPrefix + "/icon.svg") + logo, err := domain.NewURL(rootURL + config.Server.StaticURLPrefix + "/icon.svg") + if err != nil { + log.Fatalln("cannot parse root URL as client URL:", err) + } - client.URL = []*http.URI{u} - client.Logo = []*http.URI{logo} - client.RedirectURI = []*http.URI{redirect} + redirectURI, err := domain.NewURL(rootURL + "/callback") + if err != nil { + log.Fatalln("cannot parse root URL as client URL:", err) + } + + client.URL = []*domain.URL{url} + client.Logo = []*domain.URL{logo} + client.RedirectURI = []*domain.URL{redirectURI} } diff --git a/internal/cmd/start.go b/internal/cmd/start.go index 007c33f..46f7cb1 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -21,14 +21,11 @@ import ( "golang.org/x/text/message" clienthttpdelivery "source.toby3d.me/website/oauth/internal/client/delivery/http" - clientrepo "source.toby3d.me/website/oauth/internal/client/repository/http" - clientucase "source.toby3d.me/website/oauth/internal/client/usecase" healthhttpdelivery "source.toby3d.me/website/oauth/internal/health/delivery/http" metadatahttpdelivery "source.toby3d.me/website/oauth/internal/metadata/delivery/http" tickethttpdelivery "source.toby3d.me/website/oauth/internal/ticket/delivery/http" + ticketrepo "source.toby3d.me/website/oauth/internal/ticket/repository/http" ticketucase "source.toby3d.me/website/oauth/internal/ticket/usecase" - userrepo "source.toby3d.me/website/oauth/internal/user/repository/http" - userucase "source.toby3d.me/website/oauth/internal/user/usecase" ) const ( @@ -84,9 +81,15 @@ func startServer(cmd *cobra.Command, args []string) { healthhttpdelivery.NewRequestHandler().Register(r) metadatahttpdelivery.NewRequestHandler(config).Register(r) clienthttpdelivery.NewRequestHandler(config, client, matcher).Register(r) + /* + serverhttpdelivery.NewRequestHandler(serverhttpdelivery.Config{ + Config: config, + Clients: clientucase.NewClientUseCase(clientrepo.NewHTTPClientRepository(httpClient)), + Matcher: matcher, + }).Register(r) + */ tickethttpdelivery.NewRequestHandler( - ticketucase.NewTicketUseCase(httpClient), - userucase.NewUserUseCase(userrepo.NewHTTPUserRepository(httpClient)), + ticketucase.NewTicketUseCase(ticketrepo.NewHTTPTicketRepository(httpClient), httpClient), ).Register(r) if enablePprof { diff --git a/internal/domain/url.go b/internal/domain/url.go index 6ef003a..5cbbd08 100644 --- a/internal/domain/url.go +++ b/internal/domain/url.go @@ -1,6 +1,7 @@ package domain import ( + "net/url" "strconv" "testing" @@ -59,3 +60,16 @@ func (u *URL) UnmarshalJSON(v []byte) error { return nil } + +func (u *URL) URL() *url.URL { + if u.URI == nil { + return nil + } + + result, err := url.ParseRequestURI(u.URI.String()) + if err != nil { + return nil + } + + return result +} diff --git a/internal/ticket/delivery/http/ticket_http.go b/internal/ticket/delivery/http/ticket_http.go index 20d5691..7d58737 100644 --- a/internal/ticket/delivery/http/ticket_http.go +++ b/internal/ticket/delivery/http/ticket_http.go @@ -12,7 +12,6 @@ import ( "source.toby3d.me/website/oauth/internal/common" "source.toby3d.me/website/oauth/internal/domain" "source.toby3d.me/website/oauth/internal/ticket" - "source.toby3d.me/website/oauth/internal/user" ) type ( @@ -24,19 +23,18 @@ type ( Resource *domain.URL `form:"resource"` // The access token should be used when acting on behalf of this URL. + // WARN(toby3d): deadcode for now Subject *domain.Me `form:"subject"` } RequestHandler struct { - users user.UseCase - tickets ticket.UseCase + useCase ticket.UseCase } ) -func NewRequestHandler(tickets ticket.UseCase, users user.UseCase) *RequestHandler { +func NewRequestHandler(useCase ticket.UseCase) *RequestHandler { return &RequestHandler{ - tickets: tickets, - users: users, + useCase: useCase, } } @@ -58,20 +56,11 @@ func (h *RequestHandler) update(ctx *http.RequestCtx) { return } - // TODO(toby3d): fetch token endpoint on Resource URL instead - u, err := h.users.Fetch(ctx, req.Subject) - if err != nil { - ctx.SetStatusCode(http.StatusBadRequest) - encoder.Encode(domain.Error{ - Code: "invalid_request", - Description: err.Error(), - Frame: xerrors.Caller(1), - }) - - return - } - - token, err := h.tickets.Redeem(ctx, u.TokenEndpoint, req.Ticket) + token, err := h.useCase.Redeem(ctx, &domain.Ticket{ + Ticket: req.Ticket, + Resource: req.Resource, + Subject: req.Subject, + }) if err != nil { ctx.SetStatusCode(http.StatusBadRequest) encoder.Encode(domain.Error{ diff --git a/internal/ticket/delivery/http/ticket_http_test.go b/internal/ticket/delivery/http/ticket_http_test.go index 642d99a..6c42691 100644 --- a/internal/ticket/delivery/http/ticket_http_test.go +++ b/internal/ticket/delivery/http/ticket_http_test.go @@ -15,9 +15,8 @@ import ( "source.toby3d.me/website/oauth/internal/domain" "source.toby3d.me/website/oauth/internal/testing/httptest" delivery "source.toby3d.me/website/oauth/internal/ticket/delivery/http" + ticketrepo "source.toby3d.me/website/oauth/internal/ticket/repository/memory" ucase "source.toby3d.me/website/oauth/internal/ticket/usecase" - userrepo "source.toby3d.me/website/oauth/internal/user/repository/memory" - userucase "source.toby3d.me/website/oauth/internal/user/usecase" ) // TODO(toby3d): looks ugly, refactor this? @@ -30,7 +29,10 @@ func TestUpdate(t *testing.T) { token := domain.TestToken(t) store := new(sync.Map) - store.Store(path.Join(userrepo.DefaultPathPrefix, ticket.Subject.String()), domain.TestUser(t)) + store.Store( + path.Join(ticketrepo.DefaultPathPrefix, ticket.Resource.String()), + domain.TestURL(t, "https://example.com/token"), + ) userClient, _, userCleanup := httptest.New(t, func(ctx *http.RequestCtx) { ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, fmt.Sprintf(`{ @@ -47,9 +49,8 @@ func TestUpdate(t *testing.T) { client, _, cleanup := httptest.New(t, r.Handler) t.Cleanup(cleanup) - delivery.NewRequestHandler( - ucase.NewTicketUseCase(userClient), userucase.NewUserUseCase(userrepo.NewMemoryUserRepository(store)), - ).Register(r) + delivery.NewRequestHandler(ucase.NewTicketUseCase(ticketrepo.NewMemoryTicketRepository(store), userClient)). + Register(r) req := httptest.NewRequest(http.MethodPost, "https://example.com/ticket", []byte( `ticket=`+ticket.Ticket+ diff --git a/internal/ticket/repository.go b/internal/ticket/repository.go new file mode 100644 index 0000000..8179110 --- /dev/null +++ b/internal/ticket/repository.go @@ -0,0 +1,15 @@ +package ticket + +import ( + "context" + "errors" + + "source.toby3d.me/website/oauth/internal/domain" +) + +type Repository interface { + // Get returns token endpoint founded by resource URL. + Get(ctx context.Context, resource *domain.URL) (*domain.URL, error) +} + +var ErrNotExist = errors.New("token_endpoint not found on resource URL") diff --git a/internal/ticket/repository/http/http_ticket.go b/internal/ticket/repository/http/http_ticket.go new file mode 100644 index 0000000..07662e6 --- /dev/null +++ b/internal/ticket/repository/http/http_ticket.go @@ -0,0 +1,174 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/tomnomnom/linkheader" + http "github.com/valyala/fasthttp" + "willnorris.com/go/microformats" + + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/ticket" +) + +type ( + //nolint: tagliatelle + Response struct { + // The server's issuer identifier. The issuer identifier is a + // URL that uses the "https" scheme and has no query or fragment + // components. The identifier MUST be a prefix of the + // indieauth-metadata URL. e.g. for an indieauth-metadata + // endpoint + // https://example.com/.well-known/oauth-authorization-server, + // the issuer URL could be https://example.com/, or for a + // metadata URL of + // https://example.com/wp-json/indieauth/1.0/metadata, the + // issuer URL could be https://example.com/wp-json/indieauth/1.0 + Issuer *domain.URL `json:"issuer"` + + // The Authorization Endpoint. + AuthorizationEndpoint *domain.URL `json:"authorization_endpoint"` + + // The Token Endpoint. + TokenEndpoint *domain.URL `json:"token_endpoint"` + + // JSON array containing scope values supported by the + // IndieAuth server. Servers MAY choose not to advertise some + // supported scope values even when this parameter is used. + ScopesSupported domain.Scopes `json:"scopes_supported,omitempty"` + + // JSON array containing the response_type values supported. + // This differs from RFC8414 in that this parameter is OPTIONAL + // and that, if omitted, the default is code. + ResponseTypesSupported []domain.ResponseType `json:"response_types_supported,omitempty"` + + // JSON array containing grant type values supported. If + // omitted, the default value differs from RFC8414 and is + // authorization_code. + GrantTypesSupported []domain.GrantType `json:"grant_types_supported,omitempty"` + + // URL of a page containing human-readable information that + // developers might need to know when using the server. This + // might be a link to the IndieAuth spec or something more + // personal to your implementation. + ServiceDocumentation *domain.URL `json:"service_documentation,omitempty"` + + // JSON array containing the methods supported for PKCE. This + // parameter differs from RFC8414 in that it is not optional as + // PKCE is REQUIRED. + CodeChallengeMethodsSupported []domain.CodeChallengeMethod `json:"code_challenge_methods_supported"` + + // Boolean parameter indicating whether the authorization server + // provides the iss parameter. If omitted, the default value is + // false. As the iss parameter is REQUIRED, this is provided for + // compatibility with OAuth 2.0 servers implementing the + // parameter. + // + //nolint: lll + AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported,omitempty"` + } + + httpTicketRepository struct { + client *http.Client + } +) + +const ( + relIndieAuthMetadata string = "indieauth-metadata" + relTokenEndpoint string = "token_endpoint" +) + +func NewHTTPTicketRepository(client *http.Client) ticket.Repository { + return &httpTicketRepository{ + client: client, + } +} + +func (repo *httpTicketRepository) Get(_ context.Context, resource *domain.URL) (*domain.URL, error) { + req := http.AcquireRequest() + defer http.ReleaseRequest(req) + req.Header.SetMethod(http.MethodGet) + req.SetRequestURIBytes(resource.FullURI()) + + resp := http.AcquireResponse() + defer http.ReleaseResponse(resp) + + if err := repo.client.Do(req, resp); err != nil { + return nil, fmt.Errorf("cannot fetch resource URL: %w", err) + } + + if metadataEndpoint := extractEndpoint(resp, relIndieAuthMetadata); metadataEndpoint != nil { + if result, err := extractFromMetadata(repo.client, metadataEndpoint); err == nil && result != nil { + return result, nil + } + } + + if result := extractEndpoint(resp, relTokenEndpoint); result != nil { + return result, nil + } + + return nil, ticket.ErrNotExist +} + +func extractEndpoint(resp *http.Response, name string) *domain.URL { + u, err := extractEndpointFromHeader(resp, name) + if err == nil && u != nil { + return u + } + + if u, err = extractEndpointFromBody(resp, name); err == nil && u != nil { + return u + } + + return nil +} + +func extractEndpointFromHeader(resp *http.Response, name string) (*domain.URL, error) { + for _, link := range linkheader.Parse(string(resp.Header.Peek(http.HeaderLink))) { + if !strings.EqualFold(link.Rel, name) { + continue + } + + u := http.AcquireURI() + if err := u.Parse(resp.Header.Peek(http.HeaderHost), []byte(link.URL)); err != nil { + return nil, err + } + + return &domain.URL{URI: u}, nil + } + + return nil, nil +} + +func extractEndpointFromBody(resp *http.Response, name string) (*domain.URL, error) { + host, err := url.Parse(string(resp.Header.Peek(http.HeaderHost))) + if err != nil { + return nil, fmt.Errorf("cannot parse host header: %w", err) + } + + endpoints, ok := microformats.Parse(bytes.NewReader(resp.Body()), host).Rels[name] + if !ok || len(endpoints) == 0 { + return nil, nil + } + + return domain.NewURL(endpoints[len(endpoints)-1]) +} + +func extractFromMetadata(client *http.Client, endpoint *domain.URL) (*domain.URL, error) { + _, body, err := client.Get(nil, endpoint.String()) + if err != nil { + return nil, err + } + + resp := new(Response) + if err = json.Unmarshal(body, resp); err != nil { + return nil, err + } + + return resp.TokenEndpoint, nil +} diff --git a/internal/ticket/repository/http/http_ticket_test.go b/internal/ticket/repository/http/http_ticket_test.go new file mode 100644 index 0000000..a102d43 --- /dev/null +++ b/internal/ticket/repository/http/http_ticket_test.go @@ -0,0 +1,104 @@ +package http_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/fasthttp/router" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + http "github.com/valyala/fasthttp" + + "source.toby3d.me/website/oauth/internal/common" + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/testing/httptest" + repository "source.toby3d.me/website/oauth/internal/ticket/repository/http" +) + +type TestCase struct { + name string + bodyLinks map[string]string + metadata string +} + +const testBody string = ` + + + + + + Secret + %s + + +

Nothing to see here.

+ + +` + +func TestGet(t *testing.T) { + t.Parallel() + + resource := domain.TestURL(t, "https://alice.example.com/private") + endpoint := domain.TestURL(t, "https://example.org/token") + + for _, testCase := range []TestCase{{ + name: "link", + bodyLinks: map[string]string{ + "token_endpoint": endpoint.String(), + }, + metadata: `{"token_endpoint": ""}`, + }, { + name: "metadata", + bodyLinks: map[string]string{ + "indieauth-metadata": "https://example.com/.well-known/oauth-authorization-server", + }, + metadata: `{"token_endpoint": "` + endpoint.String() + `"}`, + }, { + name: "fallback", + bodyLinks: map[string]string{ + "token_endpoint": endpoint.String(), + "indieauth-metadata": "https://example.com/.well-known/oauth-authorization-server", + }, + metadata: `{}`, + }, { + name: "priority", + bodyLinks: map[string]string{ + "token_endpoint": "dont-touch-me", + "indieauth-metadata": "https://example.com/.well-known/oauth-authorization-server", + }, + metadata: `{"token_endpoint": "` + endpoint.String() + `"}`, + }} { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + r := router.New() + r.GET("/.well-known/oauth-authorization-server", func(ctx *http.RequestCtx) { + ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, testCase.metadata) + }) + r.GET("/private", func(ctx *http.RequestCtx) { + bodyLinks := make([]string, 0) + for k, v := range testCase.bodyLinks { + bodyLinks = append(bodyLinks, ``) + } + + ctx.SuccessString( + common.MIMETextHTMLCharsetUTF8, + fmt.Sprintf(testBody, strings.Join(bodyLinks, "\n")), + ) + }) + + client, _, cleanup := httptest.New(t, r.Handler) + t.Cleanup(cleanup) + + result, err := repository.NewHTTPTicketRepository(client). + Get(context.Background(), resource) + require.NoError(t, err) + assert.Equal(t, endpoint.String(), result.String()) + }) + } +} diff --git a/internal/ticket/repository/memory/memory_ticket.go b/internal/ticket/repository/memory/memory_ticket.go new file mode 100644 index 0000000..8723094 --- /dev/null +++ b/internal/ticket/repository/memory/memory_ticket.go @@ -0,0 +1,36 @@ +package memory + +import ( + "context" + "path" + "sync" + + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/ticket" +) + +type memoryTicketRepository struct { + store *sync.Map +} + +const DefaultPathPrefix string = "tickets" + +func NewMemoryTicketRepository(store *sync.Map) ticket.Repository { + return &memoryTicketRepository{ + store: store, + } +} + +func (repo *memoryTicketRepository) Get(_ context.Context, resource *domain.URL) (*domain.URL, error) { + src, ok := repo.store.Load(path.Join(DefaultPathPrefix, resource.String())) + if !ok { + return nil, ticket.ErrNotExist + } + + result, ok := src.(*domain.URL) + if !ok { + return nil, ticket.ErrNotExist + } + + return result, nil +} diff --git a/internal/ticket/repository/memory/memory_ticket_test.go b/internal/ticket/repository/memory/memory_ticket_test.go new file mode 100644 index 0000000..eb57d40 --- /dev/null +++ b/internal/ticket/repository/memory/memory_ticket_test.go @@ -0,0 +1,28 @@ +package memory_test + +import ( + "context" + "path" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "source.toby3d.me/website/oauth/internal/domain" + repository "source.toby3d.me/website/oauth/internal/ticket/repository/memory" +) + +func TestGet(t *testing.T) { + t.Parallel() + + ticket := domain.TestTicket(t) + user := domain.TestUser(t) + + store := new(sync.Map) + store.Store(path.Join(repository.DefaultPathPrefix, ticket.Resource.String()), user.TokenEndpoint) + + result, err := repository.NewMemoryTicketRepository(store).Get(context.Background(), ticket.Resource) + require.NoError(t, err) + assert.Equal(t, user.TokenEndpoint.String(), result.String()) +} diff --git a/internal/ticket/usecase.go b/internal/ticket/usecase.go index 3a2e34e..a43cfbe 100644 --- a/internal/ticket/usecase.go +++ b/internal/ticket/usecase.go @@ -8,5 +8,5 @@ import ( type UseCase interface { // Redeem transform received ticket into access token. - Redeem(ctx context.Context, endpoint *domain.URL, ticket string) (*domain.Token, error) + Redeem(ctx context.Context, ticket *domain.Ticket) (*domain.Token, error) } diff --git a/internal/ticket/usecase/ticket_ucase.go b/internal/ticket/usecase/ticket_ucase.go index 1546b5a..5dc0e2a 100644 --- a/internal/ticket/usecase/ticket_ucase.go +++ b/internal/ticket/usecase/ticket_ucase.go @@ -23,16 +23,23 @@ type ( ticketUseCase struct { client *http.Client + repo ticket.Repository } ) -func NewTicketUseCase(client *http.Client) ticket.UseCase { +func NewTicketUseCase(repo ticket.Repository, client *http.Client) ticket.UseCase { return &ticketUseCase{ client: client, + repo: repo, } } -func (useCase *ticketUseCase) Redeem(ctx context.Context, endpoint *domain.URL, ticket string) (*domain.Token, error) { +func (useCase *ticketUseCase) Redeem(ctx context.Context, ticket *domain.Ticket) (*domain.Token, error) { + endpoint, err := useCase.repo.Get(ctx, ticket.Resource) + if err != nil { + return nil, fmt.Errorf("cannot discovery token endpoint: %w", err) + } + req := http.AcquireRequest() defer http.ReleaseRequest(req) req.Header.SetMethod(http.MethodPost) @@ -40,7 +47,7 @@ func (useCase *ticketUseCase) Redeem(ctx context.Context, endpoint *domain.URL, req.Header.SetContentType(common.MIMEApplicationForm) req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON) req.PostArgs().Set("grant_type", domain.GrantTypeTicket.String()) - req.PostArgs().Set("ticket", ticket) + req.PostArgs().Set("ticket", ticket.Ticket) resp := http.AcquireResponse() defer http.ReleaseResponse(resp) diff --git a/internal/ticket/usecase/ticket_ucase_test.go b/internal/ticket/usecase/ticket_ucase_test.go index 5c567ab..f9b7a64 100644 --- a/internal/ticket/usecase/ticket_ucase_test.go +++ b/internal/ticket/usecase/ticket_ucase_test.go @@ -14,8 +14,8 @@ import ( "source.toby3d.me/website/oauth/internal/common" "source.toby3d.me/website/oauth/internal/domain" "source.toby3d.me/website/oauth/internal/testing/httptest" + repo "source.toby3d.me/website/oauth/internal/ticket/repository/memory" ucase "source.toby3d.me/website/oauth/internal/ticket/usecase" - userrepo "source.toby3d.me/website/oauth/internal/user/repository/memory" ) func TestRedeem(t *testing.T) { @@ -25,20 +25,23 @@ func TestRedeem(t *testing.T) { ticket := domain.TestTicket(t) store := new(sync.Map) - store.Store(path.Join(userrepo.DefaultPathPrefix, ticket.Subject.String()), domain.TestUser(t)) + store.Store( + path.Join(repo.DefaultPathPrefix, ticket.Resource.String()), + domain.TestURL(t, "https://example.com/token"), + ) client, _, cleanup := httptest.New(t, func(ctx *http.RequestCtx) { ctx.SuccessString(common.MIMEApplicationJSONCharsetUTF8, fmt.Sprintf(`{ - "access_token": "%s", "token_type": "Bearer", + "access_token": "%s", "scope": "%s", "me": "%s" }`, token.AccessToken, token.Scope.String(), token.Me.String())) }) t.Cleanup(cleanup) - result, err := ucase.NewTicketUseCase(client). - Redeem(context.Background(), domain.TestURL(t, "https://bob.example.com/token"), ticket.Ticket) + result, err := ucase.NewTicketUseCase(repo.NewMemoryTicketRepository(store), client). + Redeem(context.Background(), ticket) require.NoError(t, err) assert.Equal(t, token.AccessToken, result.AccessToken) assert.Equal(t, token.Me.String(), result.Me.String())