httpserver: Add test coverage
continuous-integration/drone Build is passing Details

This commit is contained in:
Rob Watson 2022-05-20 21:52:54 +02:00
parent 8142349db4
commit c441ec03f5
12 changed files with 651 additions and 186 deletions

14
.drone.yml Normal file
View File

@ -0,0 +1,14 @@
---
kind: pipeline
type: kubernetes
name: default
steps:
- name: backend
image: golang:1.18
commands:
- go install honnef.co/go/tools/cmd/staticcheck@latest
- go build ./...
- go vet ./...
- staticcheck ./...
- go test -bench=. -benchmem -cover ./...

View File

@ -5,14 +5,14 @@ import (
"os"
)
type TwitterCredentials struct {
ClientID, ClientSecret, CallbackURL string
type TwitterConfig struct {
ClientID, ClientSecret, CallbackURL, AuthorizeURL, TokenURL string
}
type Config struct {
PublicPath string
SessionKey string
ListenAddr string
Twitter TwitterCredentials
Twitter TwitterConfig
}
func NewFromEnv() (Config, error) {
@ -28,10 +28,12 @@ func NewFromEnv() (Config, error) {
PublicPath: os.Getenv("ELON_PUBLIC_PATH"),
SessionKey: sessionKey,
ListenAddr: listenAddr,
Twitter: TwitterCredentials{
Twitter: TwitterConfig{
ClientID: os.Getenv("ELON_TWITTER_CLIENT_ID"),
ClientSecret: os.Getenv("ELON_TWITTER_CLIENT_SECRET"),
CallbackURL: os.Getenv("ELON_TWITTER_CALLBACK_URL"),
AuthorizeURL: os.Getenv("ELON_TWITTER_AUTHORIZE_URL"),
TokenURL: os.Getenv("ELON_TWITTER_TOKEN_URL"),
},
}, nil
}

87
generated/mocks/Store.go Normal file
View File

@ -0,0 +1,87 @@
// Code generated by mockery v2.12.2. DO NOT EDIT.
package mocks
import (
http "net/http"
sessions "github.com/gorilla/sessions"
mock "github.com/stretchr/testify/mock"
testing "testing"
)
// Store is an autogenerated mock type for the Store type
type Store struct {
mock.Mock
}
// Get provides a mock function with given fields: r, name
func (_m *Store) Get(r *http.Request, name string) (*sessions.Session, error) {
ret := _m.Called(r, name)
var r0 *sessions.Session
if rf, ok := ret.Get(0).(func(*http.Request, string) *sessions.Session); ok {
r0 = rf(r, name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*sessions.Session)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*http.Request, string) error); ok {
r1 = rf(r, name)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// New provides a mock function with given fields: r, name
func (_m *Store) New(r *http.Request, name string) (*sessions.Session, error) {
ret := _m.Called(r, name)
var r0 *sessions.Session
if rf, ok := ret.Get(0).(func(*http.Request, string) *sessions.Session); ok {
r0 = rf(r, name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*sessions.Session)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*http.Request, string) error); ok {
r1 = rf(r, name)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Save provides a mock function with given fields: r, w, s
func (_m *Store) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error {
ret := _m.Called(r, w, s)
var r0 error
if rf, ok := ret.Get(0).(func(*http.Request, http.ResponseWriter, *sessions.Session) error); ok {
r0 = rf(r, w, s)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewStore creates a new instance of Store. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewStore(t testing.TB) *Store {
mock := &Store{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

10
go.mod
View File

@ -4,15 +4,23 @@ go 1.18
require (
github.com/go-chi/chi v1.5.4
github.com/gorilla/sessions v1.2.1
github.com/stretchr/testify v1.7.1
go.uber.org/zap v1.21.0
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.4.2 // indirect
github.com/google/go-cmp v0.5.7 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.1.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
google.golang.org/appengine v1.6.6 // indirect
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

39
go.sum
View File

@ -33,6 +33,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@ -40,6 +42,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@ -107,22 +111,43 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@ -158,6 +183,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -184,6 +210,7 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -201,6 +228,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -226,8 +254,12 @@ golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -278,6 +310,7 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -359,9 +392,15 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

155
httpserver/handler.go Normal file
View File

@ -0,0 +1,155 @@
package httpserver
//go:generate mockery --recursive --srcpkg github.com/gorilla/sessions --name Store --output ../generated/mocks
import (
"context"
"html/template"
"io"
"net/http"
"git.netflux.io/rob/elon-eats-my-tweets/config"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/gorilla/sessions"
"go.uber.org/zap"
"golang.org/x/oauth2"
)
const (
sessionName = "elon_session"
sessionKeyState = "state"
sessionKeyPkceVerifier = "pkce_verifier"
stateLen = 64
pkceVerifierLen = 64
)
type handler struct {
templates *template.Template
oauth2Config *oauth2.Config
sessionStore sessions.Store
tokenGenerator TokenGenerator
logger *zap.SugaredLogger
}
func NewHandler(cfg config.Config, templates *template.Template, sessionStore sessions.Store, tokenGenerator TokenGenerator, logger *zap.Logger) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(loggerMiddleware(logger))
r.Use(middleware.Recoverer)
h := handler{
templates: templates,
oauth2Config: &oauth2.Config{
ClientID: cfg.Twitter.ClientID,
ClientSecret: cfg.Twitter.ClientSecret,
RedirectURL: cfg.Twitter.CallbackURL,
Scopes: []string{"tweet.read", "tweet.write", "users.read", "offline.access"},
Endpoint: oauth2.Endpoint{
AuthURL: cfg.Twitter.AuthorizeURL,
TokenURL: cfg.Twitter.TokenURL,
AuthStyle: oauth2.AuthStyleInHeader,
},
},
sessionStore: sessionStore,
tokenGenerator: tokenGenerator,
logger: logger.Sugar(),
}
r.Get("/", h.getIndex)
r.Get("/login", h.getLogin)
r.Get("/callback", h.getCallback)
return r
}
func (h *handler) getIndex(w http.ResponseWriter, r *http.Request) {
if err := h.templates.ExecuteTemplate(w, "index", nil); err != nil {
h.logger.With("err", err).Error("error rendering template")
http.Error(w, "error rendering template", http.StatusInternalServerError)
return
}
}
func (h *handler) getLogin(w http.ResponseWriter, r *http.Request) {
state := h.tokenGenerator.GenerateToken(stateLen)
pkceVerifier := h.tokenGenerator.GenerateToken(pkceVerifierLen)
session, _ := h.sessionStore.Get(r, sessionName)
session.Values[sessionKeyState] = state
session.Values[sessionKeyPkceVerifier] = pkceVerifier
if err := session.Save(r, w); err != nil {
h.logger.With("err", err).Error("error saving session")
http.Error(w, "unexpected error", http.StatusInternalServerError)
return
}
url := h.oauth2Config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("code_challenge", encodeSHA256(pkceVerifier)),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
http.Redirect(w, r, url, http.StatusFound)
}
func (h *handler) getCallback(w http.ResponseWriter, r *http.Request) {
session, err := h.sessionStore.Get(r, sessionName)
if err != nil {
h.logger.With("err", err).Error("error reading session")
http.Error(w, "error reading session", http.StatusBadRequest)
return
}
state, ok := session.Values[sessionKeyState]
if !ok || state == "" {
h.logger.Error("empty state parameter in oauth2 request")
http.Error(w, "error validating request", http.StatusBadRequest)
return
}
if state != r.URL.Query().Get("state") {
h.logger.Error("unexpected state in oauth2 request")
http.Error(w, "error validating request", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
h.logger.Error("empty code in oauth2 request")
http.Error(w, "invalid code", http.StatusBadRequest)
return
}
pkceVerifier, ok := session.Values[sessionKeyPkceVerifier]
if !ok || pkceVerifier == "" {
h.logger.Error("no pkce verifier found in session")
http.Error(w, "error reading session", http.StatusBadRequest)
return
}
token, err := h.oauth2Config.Exchange(context.Background(), code, oauth2.SetAuthURLParam("code_verifier", pkceVerifier.(string)))
if err != nil {
h.logger.With("err", err).Error("error exchanging code for access token")
http.Error(w, "error exchanging code", http.StatusForbidden)
return
}
client := h.oauth2Config.Client(context.Background(), token)
resp, err := client.Get("https://api.twitter.com/2/users/me")
if err != nil {
h.logger.With("err", err).Error("error fetching user")
http.Error(w, "error fetching user", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// TODO: do something sensible
body, _ := io.ReadAll(resp.Body)
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err = w.Write([]byte(body)); err != nil {
h.logger.With("err", err).Error("error writing response")
}
}

252
httpserver/handler_test.go Normal file
View File

@ -0,0 +1,252 @@
package httpserver_test
import (
"errors"
"html/template"
"io/ioutil"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"git.netflux.io/rob/elon-eats-my-tweets/config"
"git.netflux.io/rob/elon-eats-my-tweets/generated/mocks"
"git.netflux.io/rob/elon-eats-my-tweets/httpserver"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
var templates = template.Must(template.ParseGlob(filepath.Join("..", "public", "views", "*.html")))
// mockTokenGenerator implements httpserver.TokenGenerator.
type mockTokenGenerator struct {
i int
tokens []string
}
func (g *mockTokenGenerator) GenerateToken(_ int) string {
i := g.i
if len(g.tokens) <= i {
return ""
}
g.i++
return g.tokens[i]
}
func TestGetIndex(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler := httpserver.NewHandler(
config.Config{},
templates,
&mocks.Store{},
&mockTokenGenerator{},
zap.NewNop(),
)
handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, string(body), "Sign in with Twitter")
}
func TestLogin(t *testing.T) {
testCases := []struct {
name string
sessionSaveError error
wantStatusCode int
wantLocation string
wantRespBody string
}{
{
name: "successful login",
wantStatusCode: http.StatusFound,
wantLocation: "https://www.example.com/oauth/authorize?client_id=foo&code_challenge=RdoE4fOeAO8YelxeEEd70qNDoVgzl4844utzxsozlR4&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fwww.example.com%2Fcallback&response_type=code&scope=tweet.read+tweet.write+users.read+offline.access&state=state",
},
{
name: "error saving session",
wantStatusCode: http.StatusInternalServerError,
sessionSaveError: errors.New("boom"),
wantRespBody: "unexpected error",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var mockStore mocks.Store
sess := sessions.NewSession(&mockStore, "elon_session")
mockStore.On("Get", mock.Anything, "elon_session").Return(sess, nil)
mockStore.On("Save", mock.Anything, mock.Anything, sess).Return(tc.sessionSaveError)
handler := httpserver.NewHandler(
config.Config{
Twitter: config.TwitterConfig{
ClientID: "foo",
ClientSecret: "bar",
CallbackURL: "https://www.example.com/callback",
AuthorizeURL: "https://www.example.com/oauth/authorize",
TokenURL: "https://www.example.com/oauth/token",
},
},
templates,
&mockStore,
&mockTokenGenerator{tokens: []string{"state", "pkceVerifier"}},
zap.NewNop(),
)
req := httptest.NewRequest(http.MethodGet, "/login", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
assert.Equal(t, tc.wantStatusCode, res.StatusCode)
if tc.wantRespBody == "" {
assert.Equal(t, tc.wantLocation, res.Header.Get("Location"))
}
if tc.wantRespBody != "" {
body, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
assert.Contains(t, string(body), tc.wantRespBody)
}
})
}
}
func TestCallback(t *testing.T) {
testCases := []struct {
name string
state string
sessionState string
sessionPkceVerifier string
code string
sessionReadError error
oauth2StatusCode int
wantStatusCode int
wantError string
}{
{
name: "unable to read session",
sessionReadError: errors.New("boom"),
wantStatusCode: http.StatusBadRequest,
wantError: "error reading session",
},
{
name: "missing state",
state: "mystate",
sessionState: "",
wantStatusCode: http.StatusBadRequest,
wantError: "error validating request",
},
{
name: "unexpected state",
state: "mystate",
sessionState: "foostate",
wantStatusCode: http.StatusBadRequest,
wantError: "error validating request",
},
{
name: "empty code",
state: "mystate",
sessionState: "mystate",
code: "",
wantStatusCode: http.StatusBadRequest,
wantError: "invalid code",
},
{
name: "empty pkce verifier",
state: "mystate",
sessionState: "mystate",
code: "mycode",
sessionPkceVerifier: "",
wantStatusCode: http.StatusBadRequest,
wantError: "error reading session",
},
{
name: "error exchanging code",
state: "mystate",
sessionState: "mystate",
code: "mycode",
sessionPkceVerifier: "mypkceverifier",
oauth2StatusCode: http.StatusInternalServerError,
wantStatusCode: http.StatusForbidden,
wantError: "error exchanging code",
},
{
name: "successful exchange",
state: "mystate",
sessionState: "mystate",
code: "mycode",
sessionPkceVerifier: "mypkceverifier",
oauth2StatusCode: http.StatusOK,
wantStatusCode: http.StatusOK,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var mockStore mocks.Store
sess := sessions.NewSession(&mockStore, "elon_session")
sess.Values["state"] = tc.sessionState
sess.Values["pkce_verifier"] = tc.sessionPkceVerifier
mockStore.On("Get", mock.Anything, "elon_session").Return(sess, tc.sessionReadError)
const callbackURL = "https://www.example.com/callback"
oauthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/oauth/token", r.URL.Path)
require.NoError(t, r.ParseForm())
assert.Equal(t, tc.code, r.PostFormValue("code"))
assert.Equal(t, tc.sessionPkceVerifier, r.PostFormValue("code_verifier"))
assert.Equal(t, "authorization_code", r.PostFormValue("grant_type"))
assert.Equal(t, callbackURL, r.PostFormValue("redirect_uri"))
w.WriteHeader(tc.oauth2StatusCode)
if tc.oauth2StatusCode == http.StatusOK {
w.Write([]byte(`access_token=foo&expires_in=3600&refresh_token=&token_type=bearer"}`))
}
})
srv := httptest.NewServer(oauthHandler)
handler := httpserver.NewHandler(
config.Config{
Twitter: config.TwitterConfig{
ClientID: "foo",
ClientSecret: "bar",
CallbackURL: callbackURL,
AuthorizeURL: srv.URL + "/oauth/authorize",
TokenURL: srv.URL + "/oauth/token",
},
},
templates,
&mockStore,
nil,
zap.NewNop(),
)
req := httptest.NewRequest(http.MethodGet, "/callback?state="+tc.state+"&code="+tc.code, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
res := rec.Result()
defer res.Body.Close()
assert.Equal(t, tc.wantStatusCode, res.StatusCode)
if tc.wantError != "" {
body, err := ioutil.ReadAll(res.Body)
require.NoError(t, err)
assert.Contains(t, string(body), tc.wantError)
}
})
}
}

View File

@ -1,176 +1 @@
package httpserver
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"path/filepath"
"text/template"
"git.netflux.io/rob/elon-eats-my-tweets/config"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/gorilla/sessions"
"golang.org/x/oauth2"
)
const (
sessionName = "elon_session"
sessionKeyState = "state"
sessionKeyPkceVerifier = "pkce_verifier"
stateLen = 64
pkceVerifierLen = 64
)
type handler struct {
templates *template.Template
oauth2Config *oauth2.Config
sessionStore *sessions.CookieStore
}
func newHandler(cfg config.Config, templates *template.Template) *handler {
return &handler{
templates: templates,
sessionStore: sessions.NewCookieStore([]byte(cfg.SessionKey)),
oauth2Config: &oauth2.Config{
ClientID: cfg.Twitter.ClientID,
ClientSecret: cfg.Twitter.ClientSecret,
RedirectURL: cfg.Twitter.CallbackURL,
Scopes: []string{"tweet.read", "tweet.write", "users.read", "offline.access"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://twitter.com/i/oauth2/authorize",
TokenURL: "https://api.twitter.com/2/oauth2/token",
AuthStyle: oauth2.AuthStyleInHeader,
},
},
}
}
func (h *handler) getIndex(w http.ResponseWriter, r *http.Request) {
if err := h.templates.ExecuteTemplate(w, "index", nil); err != nil {
log.Printf("error rendering template: %v", err)
http.Error(w, "error rendering template", http.StatusInternalServerError)
return
}
}
func (h *handler) getLogin(w http.ResponseWriter, r *http.Request) {
state := randSeq(stateLen)
pkceVerifier := randSeq(pkceVerifierLen)
session, _ := h.sessionStore.Get(r, sessionName)
session.Values[sessionKeyState] = state
session.Values[sessionKeyPkceVerifier] = pkceVerifier
if err := session.Save(r, w); err != nil {
log.Printf("error saving session: %v", err)
http.Error(w, "error saving session", http.StatusBadRequest)
return
}
url := h.oauth2Config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("code_challenge", encodeSHA256(pkceVerifier)),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
func (h *handler) getCallback(w http.ResponseWriter, r *http.Request) {
session, err := h.sessionStore.Get(r, sessionName)
if err != nil {
log.Printf("error reading session: %v", err)
http.Error(w, "error reading session", http.StatusBadRequest)
return
}
state, ok := session.Values[sessionKeyState]
if !ok {
log.Println("empty state", err)
http.Error(w, "error reading session", http.StatusBadRequest)
return
}
if state != r.URL.Query().Get("state") {
http.Error(w, "error validating request", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "empty code", http.StatusBadRequest)
return
}
pkceVerifier, ok := session.Values[sessionKeyPkceVerifier]
if !ok {
log.Println("empty code challenge", err)
http.Error(w, "error reading session", http.StatusBadRequest)
return
}
token, err := h.oauth2Config.Exchange(context.Background(), code, oauth2.SetAuthURLParam("code_verifier", pkceVerifier.(string)))
if err != nil {
log.Printf("error exchanging code: %v", err)
http.Error(w, "error exchanging code", http.StatusForbidden)
return
}
client := h.oauth2Config.Client(context.Background(), token)
resp, err := client.Get("https://api.twitter.com/2/users/me")
if err != nil {
log.Printf("error fetching users/me: %v", err)
http.Error(w, "error fetching user", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// TODO: do something sensible
body, _ := io.ReadAll(resp.Body)
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err = w.Write([]byte(body)); err != nil {
log.Printf("error writing response: %v", err)
}
}
func Start(cfg config.Config) error {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
templates, err := template.ParseGlob(filepath.Join(cfg.PublicPath, "views", "*.html"))
if err != nil {
return fmt.Errorf("error loading templates: %v", err)
}
h := newHandler(cfg, templates)
r.Get("/", h.getIndex)
r.Get("/login", h.getLogin)
r.Get("/callback", h.getCallback)
return http.ListenAndServe(":8000", r)
}
// https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-go
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func randSeq(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func encodeSHA256(s string) string {
enc := sha256.Sum256([]byte(s))
return base64.RawURLEncoding.EncodeToString(enc[:])
}

31
httpserver/logger.go Normal file
View File

@ -0,0 +1,31 @@
package httpserver
import (
"net/http"
"time"
"github.com/go-chi/chi/middleware"
"go.uber.org/zap"
)
func loggerMiddleware(l *zap.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
t1 := time.Now()
defer func() {
l.Info("HTTP",
zap.String("proto", r.Proto),
zap.String("path", r.URL.Path),
zap.Duration("dur", time.Since(t1)),
zap.Int("status", ww.Status()),
zap.Int("size", ww.BytesWritten()),
zap.String("reqId", middleware.GetReqID(r.Context())))
}()
next.ServeHTTP(ww, r)
}
return http.HandlerFunc(fn)
}
}

1
httpserver/token.go Normal file
View File

@ -0,0 +1 @@
package httpserver

View File

@ -0,0 +1,33 @@
package httpserver
import (
"crypto/sha256"
"encoding/base64"
"math/rand"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
type TokenGenerator interface {
GenerateToken(n int) string
}
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
type RandomTokenGenerator struct{}
func (g RandomTokenGenerator) GenerateToken(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func encodeSHA256(s string) string {
enc := sha256.Sum256([]byte(s))
return base64.RawURLEncoding.EncodeToString(enc[:])
}

30
main.go
View File

@ -1,21 +1,39 @@
package main
import (
"html/template"
"log"
"math/rand"
"time"
"net/http"
"path/filepath"
"git.netflux.io/rob/elon-eats-my-tweets/config"
"git.netflux.io/rob/elon-eats-my-tweets/httpserver"
"github.com/gorilla/sessions"
"go.uber.org/zap"
)
func main() {
rand.Seed(time.Now().UnixNano())
c, err := config.NewFromEnv()
cfg, err := config.NewFromEnv()
if err != nil {
log.Fatal(err)
}
log.Fatal(httpserver.Start(c))
logger, err := zap.NewDevelopment()
if err != nil {
log.Fatal(err)
}
templates, err := template.ParseGlob(filepath.Join(cfg.PublicPath, "views", "*.html"))
if err != nil {
log.Fatalf("error loading templates: %v", err)
}
handler := httpserver.NewHandler(
cfg,
templates,
sessions.NewCookieStore([]byte(cfg.SessionKey)),
httpserver.RandomTokenGenerator{},
logger,
)
log.Fatal(http.ListenAndServe(cfg.ListenAddr, handler))
}