diff --git a/.github/workflows/go-ci-cd.yaml b/.github/workflows/go-ci-cd.yaml new file mode 100644 index 0000000..3d5bb6f --- /dev/null +++ b/.github/workflows/go-ci-cd.yaml @@ -0,0 +1,63 @@ +name: Go Tests + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + lint: + name: Vet + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Run Go Vet + run: go vet ./... + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" # Specify your desired Go version + + - name: Install dependencies + run: | + go mod tidy + go mod download + + - name: Run tests + run: go test -v ./... + + build: + name: Build + runs-on: ubuntu-latest + needs: [test, lint] # Ensure tests and linting pass before building + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Install dependencies + run: | + go mod tidy + go mod download + + - name: Build application + run: go build -v ./... diff --git a/.gitignore b/.gitignore index 9ddf587..dcafc93 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ count.txt .env .nitro .tanstack +.vite \ No newline at end of file diff --git a/cmd/sumnotes/main.go b/cmd/sumnotes/main.go index 5fcf9c5..e302de3 100644 --- a/cmd/sumnotes/main.go +++ b/cmd/sumnotes/main.go @@ -19,7 +19,9 @@ func main() { } defer db.Close() - srv, err := server.New(cfg, db) + userStore := database.NewUserStore(db) + + srv, err := server.New(cfg, userStore) if err != nil { log.Fatalf("Failed to create server: %v", err) } diff --git a/go.mod b/go.mod index c8e7565..7066bfd 100644 --- a/go.mod +++ b/go.mod @@ -4,23 +4,24 @@ go 1.24.4 require ( github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a + github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/markbates/goth v1.81.0 + github.com/stretchr/testify v1.10.0 google.golang.org/api v0.241.0 ) require ( github.com/PuerkitoBio/goquery v1.9.2 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect - github.com/gin-contrib/cors v1.7.6 // indirect - github.com/mfridman/interpolate v0.0.2 // indirect - github.com/sethvargo/go-retry v0.3.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sync v0.15.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect ) require ( @@ -31,7 +32,6 @@ require ( github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect @@ -55,7 +55,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pressly/goose/v3 v3.24.3 github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect @@ -66,7 +65,7 @@ require ( golang.org/x/arch v0.18.0 // indirect golang.org/x/crypto v0.39.0 // indirect golang.org/x/net v0.41.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.30.0 golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.26.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect diff --git a/go.sum b/go.sum index 43e785b..a43f11b 100644 --- a/go.sum +++ b/go.sum @@ -12,33 +12,23 @@ github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsVi github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a h1:dIdcLbck6W67B5JFMewU5Dba1yKZA3MsT67i4No/zh0= github.com/antonlindstrom/pgstore v0.0.0-20220421113606-e3a6e3fed12a/go.mod h1:Sdr/tmSOLEnncCuXS5TwZRxuk7deH1WXVY8cve3eVBM= -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= @@ -56,12 +46,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -92,8 +78,6 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= @@ -112,32 +96,27 @@ github.com/markbates/goth v1.81.0 h1:XVcCkeGWokynPV7MXvgb8pd2s3r7DS40P7931w6kdnE github.com/markbates/goth v1.81.0/go.mod h1:+6z31QyUms84EHmuBY7iuqYSxyoN3njIgg9iCF/lR1k= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= -github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= -github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= -github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -145,17 +124,14 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= @@ -171,11 +147,6 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -258,9 +229,9 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/auth/authenticator.go b/internal/auth/authenticator.go new file mode 100644 index 0000000..48e6975 --- /dev/null +++ b/internal/auth/authenticator.go @@ -0,0 +1,12 @@ +package auth + +import ( + "net/http" + + "github.com/markbates/goth" +) + +// Authenticator describes an object that can complete user authentication. +type Authenticator interface { + CompleteUserAuth(w http.ResponseWriter, r *http.Request) (goth.User, error) +} diff --git a/internal/auth/gothic.go b/internal/auth/gothic.go new file mode 100644 index 0000000..a41a81a --- /dev/null +++ b/internal/auth/gothic.go @@ -0,0 +1,21 @@ +package auth + +import ( + "net/http" + + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" +) + +// GothicAuthenticator is the real implementation of the Authenticator interface. +type GothicAuthenticator struct{} + +// NewGothicAuthenticator creates a new GothicAuthenticator. +func NewGothicAuthenticator() *GothicAuthenticator { + return &GothicAuthenticator{} +} + +// CompleteUserAuth wraps the call to gothic.CompleteUserAuth. +func (a *GothicAuthenticator) CompleteUserAuth(w http.ResponseWriter, r *http.Request) (goth.User, error) { + return gothic.CompleteUserAuth(w, r) +} diff --git a/internal/auth/session.go b/internal/auth/session.go index 4f34efb..78c6128 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -28,6 +28,6 @@ func NewStore(dbURL string, keyPairs ...[]byte) (*pgstore.PGStore, error) { return store, nil } -func GetSession(store *pgstore.PGStore, r *http.Request) (*sessions.Session, error) { +func GetSession(store sessions.Store, r *http.Request) (*sessions.Session, error) { return store.Get(r, SessionName) } diff --git a/internal/auth/token.go b/internal/auth/token.go index 8d9133f..e7a52a5 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -1,24 +1,27 @@ package auth import ( - "database/sql" + "errors" + "fmt" "main/internal/database" "main/internal/model" "github.com/markbates/goth" ) -func RefreshToken(u *model.User, db *sql.DB) error { - p, err := goth.GetProvider("google") +var ( + ErrRefreshFailed = errors.New("failed to refresh token with provider") +) + +func RefreshToken(u *model.User, db database.UserStore, p goth.Provider) error { + newToken, err := p.RefreshToken(u.RefreshToken) if err != nil { - return err + return ErrRefreshFailed } - n, err := p.RefreshToken(u.RefreshToken) + err = db.UpdateUserTokens(u.ID, newToken.AccessToken, newToken.RefreshToken, newToken.Expiry) if err != nil { - return err + return fmt.Errorf("failed to update user tokens in database: %w", err) } - - return database.UpdateUserTokens(db, u.ID, n.AccessToken, n.RefreshToken, n.Expiry) - + return nil } diff --git a/internal/database/user.go b/internal/database/user.go index 201e91e..202fbb7 100644 --- a/internal/database/user.go +++ b/internal/database/user.go @@ -8,7 +8,25 @@ import ( "github.com/google/uuid" ) -func FindUserByEmail(db *sql.DB, email string) (*model.User, error) { +// UserStorer defines the interface for user database operations. +type UserStore interface { + FindUserByEmail(email string) (*model.User, error) + FindUserByID(id string) (*model.User, error) + CreateUser(user *model.User) (*model.User, error) + UpdateUserTokens(userID, accessToken, refreshToken string, tokenExpiry time.Time) error +} + +// DB holds the database connection pool. +type DB struct { + *sql.DB +} + +// NewUserStore creates a new DB instance. +func NewUserStore(db *sql.DB) *DB { + return &DB{db} +} + +func (db *DB) FindUserByEmail(email string) (*model.User, error) { user := &model.User{} var accessToken, refreshToken sql.NullString var tokenExpiry sql.NullTime @@ -28,7 +46,7 @@ func FindUserByEmail(db *sql.DB, email string) (*model.User, error) { return user, nil } -func FindUserByID(db *sql.DB, id string) (*model.User, error) { +func (db *DB) FindUserByID(id string) (*model.User, error) { user := &model.User{} var accessToken, refreshToken sql.NullString var tokenExpiry sql.NullTime @@ -48,7 +66,7 @@ func FindUserByID(db *sql.DB, id string) (*model.User, error) { return user, nil } -func CreateUser(db *sql.DB, user *model.User) (*model.User, error) { +func (db *DB) CreateUser(user *model.User) (*model.User, error) { user.ID = uuid.New().String() user.CreatedAt = time.Now() user.UpdatedAt = time.Now() @@ -61,7 +79,7 @@ func CreateUser(db *sql.DB, user *model.User) (*model.User, error) { return user, nil } -func UpdateUserTokens(db *sql.DB, userID, accessToken, refreshToken string, tokenExpiry time.Time) error { +func (db *DB) UpdateUserTokens(userID, accessToken, refreshToken string, tokenExpiry time.Time) error { _, err := db.Exec("UPDATE users SET access_token = $1, refresh_token = $2, token_expiry = $3, updated_at = $4 WHERE id = $5", accessToken, refreshToken, tokenExpiry, time.Now(), userID) return err diff --git a/internal/handler/http.go b/internal/handler/http.go index b098704..feb7171 100644 --- a/internal/handler/http.go +++ b/internal/handler/http.go @@ -2,10 +2,7 @@ package handler import ( "context" - "database/sql" "encoding/base64" - "fmt" - "html/template" "main/internal/auth" "main/internal/config" "main/internal/database" @@ -14,8 +11,9 @@ import ( "time" md "github.com/JohannesKaufmann/html-to-markdown" - "github.com/antonlindstrom/pgstore" "github.com/gin-gonic/gin" + "github.com/gorilla/sessions" + "github.com/markbates/goth" "github.com/markbates/goth/gothic" "golang.org/x/oauth2" "google.golang.org/api/gmail/v1" @@ -23,26 +21,22 @@ import ( ) type Handler struct { - db *sql.DB - store *pgstore.PGStore + db database.UserStore + store sessions.Store cfg *config.Config + p goth.Provider + auth auth.Authenticator } -func New(db *sql.DB, store *pgstore.PGStore, cfg *config.Config) *Handler { - return &Handler{db, store, cfg} +func New(db database.UserStore, store sessions.Store, cfg *config.Config, p goth.Provider, auth auth.Authenticator) *Handler { + + return &Handler{db, store, cfg, p, auth} } func (h *Handler) Home(c *gin.Context) { - tmpl, err := template.ParseFiles("templates/index.html") - if err != nil { - c.AbortWithStatus(http.StatusInternalServerError) - return - } - err = tmpl.Execute(c.Writer, gin.H{}) - if err != nil { - c.AbortWithStatus(http.StatusInternalServerError) - return - } + c.JSON(http.StatusOK, struct{ Message string }{ + Message: "sumnotes golang backend", + }) } func (h *Handler) SignInWithProvider(c *gin.Context) { @@ -61,21 +55,20 @@ func (h *Handler) CallbackHandler(c *gin.Context) { q.Del("scope") c.Request.URL.RawQuery = q.Encode() - gothUser, err := gothic.CompleteUserAuth(c.Writer, c.Request) + gothUser, err := h.auth.CompleteUserAuth(c.Writer, c.Request) if err != nil { - fmt.Println("Error: ", err) c.AbortWithError(http.StatusInternalServerError, err) return } - dbUser, err := database.FindUserByEmail(h.db, gothUser.Email) + dbUser, err := h.db.FindUserByEmail(gothUser.Email) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return } if dbUser == nil { - dbUser, err = database.CreateUser(h.db, &model.User{ + dbUser, err = h.db.CreateUser(&model.User{ Email: gothUser.Email, Name: gothUser.Name, AvatarURL: gothUser.AvatarURL, @@ -86,7 +79,7 @@ func (h *Handler) CallbackHandler(c *gin.Context) { } } - err = database.UpdateUserTokens(h.db, dbUser.ID, gothUser.AccessToken, gothUser.RefreshToken, gothUser.ExpiresAt) + err = h.db.UpdateUserTokens(dbUser.ID, gothUser.AccessToken, gothUser.RefreshToken, gothUser.ExpiresAt) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return @@ -104,7 +97,7 @@ func (h *Handler) CallbackHandler(c *gin.Context) { return } - c.Redirect(http.StatusTemporaryRedirect, "/api/success") + c.Redirect(http.StatusTemporaryRedirect, h.cfg.FrontendURL) } func (h *Handler) Success(c *gin.Context) { @@ -124,24 +117,31 @@ func (h *Handler) Refresh(c *gin.Context) { return } - user, err := database.FindUserByID(h.db, userID) - + user, err := h.db.FindUserByID(userID) if err != nil { - c.AbortWithError(http.StatusNotFound, err) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + if user == nil { + c.AbortWithStatus(http.StatusNotFound) return } - err = auth.RefreshToken(user, h.db) + err = auth.RefreshToken(user, h.db, h.p) if err != nil { + // When refresh fails, the user is no longer authenticated. + // We should clear the session and return 401 Unauthorized. session.Options.MaxAge = -1 if err := session.Save(c.Request, c.Writer); err != nil { - panic("Failed to remove session for user: " + userID) + // If we can't even save the session, something is very wrong. + c.AbortWithStatus(http.StatusInternalServerError) + return } - c.AbortWithStatus(http.StatusNotFound) + c.AbortWithStatus(http.StatusUnauthorized) return } - c.Status(http.StatusOK) + c.JSON(http.StatusOK, user) } func (h *Handler) Me(c *gin.Context) { @@ -153,12 +153,11 @@ func (h *Handler) Me(c *gin.Context) { userID, ok := session.Values["user_id"].(string) if !ok || userID == "" { - c.Redirect(http.StatusTemporaryRedirect, "/") - c.Abort() + c.AbortWithStatus(http.StatusNotFound) return } - user, err := database.FindUserByID(h.db, userID) + user, err := h.db.FindUserByID(userID) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return @@ -198,7 +197,7 @@ func (h *Handler) Summaries(c *gin.Context) { return } - user, err := database.FindUserByID(h.db, userID) + user, err := h.db.FindUserByID(userID) if err != nil { c.AbortWithError(http.StatusInternalServerError, err) return @@ -250,5 +249,5 @@ func (h *Handler) Summaries(c *gin.Context) { return } - c.Data(http.StatusOK, "text/markdown; charset=utf-8", fmt.Appendf(nil, "%s", markdown)) + c.Data(http.StatusOK, "text/markdown; charset=utf-8", []byte(markdown)) } diff --git a/internal/handler/http_test.go b/internal/handler/http_test.go new file mode 100644 index 0000000..cc7c848 --- /dev/null +++ b/internal/handler/http_test.go @@ -0,0 +1,745 @@ +// internal/handler/http_test.go + +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/sessions" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "golang.org/x/oauth2" + + "main/internal/auth" + "main/internal/config" + "main/internal/database" + "main/internal/model" +) + +// MockDB is a mock implementation of the UserStorer interface. +type MockDB struct { + mock.Mock +} + +// Ensure MockDB satisfies the UserStorer interface. +var _ database.UserStore = (*MockDB)(nil) + +// MockStore is a mock implementation of the sessions.Store interface. +type MockStore struct { + mock.Mock +} + +type MockProvider struct { + mock.Mock +} + +type MockGothSession struct { + mock.Mock +} + +func (m *MockGothSession) GetAuthURL() (string, error) { + args := m.Called() + return args.String(0), args.Error(1) +} + +func (m *MockGothSession) Marshal() string { + return "" +} + +func (m *MockGothSession) Authorize(provider goth.Provider, params goth.Params) (string, error) { + return "", nil +} + +func (m *MockProvider) Name() string { return "mock" } + +func (m *MockProvider) SetName(name string) {} + +func (m *MockProvider) Debug(debug bool) {} + +func (m *MockProvider) BeginAuth(state string) (goth.Session, error) { + args := m.Called(state) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(goth.Session), args.Error(1) +} +func (m *MockProvider) UnmarshalSession(session string) (goth.Session, error) { return nil, nil } +func (m *MockProvider) FetchUser(session goth.Session) (goth.User, error) { return goth.User{}, nil } +func (m *MockProvider) RefreshTokenAvailable() bool { return true } + +func (m *MockProvider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + args := m.Called(refreshToken) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*oauth2.Token), args.Error(1) +} + +func (m *MockDB) FindUserByEmail(email string) (*model.User, error) { + args := m.Called(email) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.User), args.Error(1) +} + +func (m *MockDB) FindUserByID(id string) (*model.User, error) { + args := m.Called(id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.User), args.Error(1) +} + +func (m *MockDB) CreateUser(user *model.User) (*model.User, error) { + args := m.Called(user) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*model.User), args.Error(1) +} + +func (m *MockDB) UpdateUserTokens(userID, accessToken, refreshToken string, tokenExpiry time.Time) error { + args := m.Called(userID, accessToken, refreshToken, tokenExpiry) + return args.Error(0) +} + +func (m *MockStore) Get(r *http.Request, name string) (*sessions.Session, error) { + args := m.Called(r, name) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*sessions.Session), args.Error(1) +} + +func (m *MockStore) New(r *http.Request, name string) (*sessions.Session, error) { + args := m.Called(r, name) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*sessions.Session), args.Error(1) +} + +func (m *MockStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error { + args := m.Called(r, w, s) + return args.Error(0) +} + +type MockAuth struct { + mock.Mock +} + +func (m *MockAuth) CompleteUserAuth(w http.ResponseWriter, r *http.Request) (goth.User, error) { + args := m.Called(r, w) + // Add a nil check for robustness. + if args.Get(0) == nil { + // Return an empty goth.User struct if the mock returns nil. + return goth.User{}, args.Error(1) + } + // Correct the type assertion to match the return type. + return args.Get(0).(goth.User), args.Error(1) +} + +func setupBaseTest() (*httptest.ResponseRecorder, *gin.Engine, *MockDB, *MockStore, *MockProvider, *MockAuth) { + gin.SetMode(gin.TestMode) + + mockDB := new(MockDB) + mockStore := new(MockStore) + mockProvider := new(MockProvider) + mockAuthenticator := new(MockAuth) + + w := httptest.NewRecorder() + router := gin.Default() + + return w, router, mockDB, mockStore, mockProvider, mockAuthenticator +} + +func TestNew(t *testing.T) { + t.Run("New Handler", func(t *testing.T) { + _, _, mockDB, mockStore, mockProvider, mockAuthenticator := setupBaseTest() + + cfg := &config.Config{ + FrontendURL: "example.com", + } + h := New(mockDB, mockStore, cfg, mockProvider, mockAuthenticator) + + assert.NotNil(t, h) + assert.Equal(t, mockDB, h.db) + assert.Equal(t, mockProvider, h.p) + assert.Equal(t, mockStore, h.store) + assert.Equal(t, cfg, h.cfg) + }) +} + +func TestSignInWithProvider(t *testing.T) { + t.Run("Sign in with a provider", func(t *testing.T) { + gin.SetMode(gin.TestMode) + + w, router, mockDB, mockStore, mockProvider, mockAuthenticator := setupBaseTest() + + // Tell gothic to use our mock store + gothic.Store = mockStore + + // Set up the handler with the mock provider + h := New(mockDB, mockStore, &config.Config{}, mockProvider, mockAuthenticator) + router.GET("/auth/:provider", h.SignInWithProvider) + + // Mock the BeginAuth call to return a mock session + mockSession := new(MockGothSession) + mockProvider.On("BeginAuth", mock.Anything).Return(mockSession, nil) + + // Mock the GetAuthURL call to return a fake auth URL + expectedAuthURL := "http://example.com/auth" + mockSession.On("GetAuthURL").Return(expectedAuthURL, nil) + + // Add the mock provider to goth + goth.UseProviders(mockProvider) + + // Mock the session store calls from gothic + session := sessions.NewSession(mockStore, "_gothic_session") + mockStore.On("New", mock.Anything, "_gothic_session").Return(session, nil) + mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // Perform the request + req, _ := http.NewRequest(http.MethodGet, "/auth/mock", nil) + router.ServeHTTP(w, req) + + // Assertions + assert.Equal(t, http.StatusTemporaryRedirect, w.Code) + assert.Equal(t, expectedAuthURL, w.Header().Get("Location")) + + mockStore.AssertExpectations(t) + mockProvider.AssertExpectations(t) + }) +} + +func setupCallBackTest() (*httptest.ResponseRecorder, *gin.Engine, *MockDB, *MockStore, *MockProvider, *MockAuth) { + w, router, mockDB, mockStore, mockProvider, mockAuthenticator := setupBaseTest() + + h := &Handler{ + db: mockDB, + store: mockStore, + p: mockProvider, + auth: mockAuthenticator, + cfg: &config.Config{ + FrontendURL: "http://example.com", + }, + } + + router.GET("/auth/google/callback", h.CallbackHandler) + + return w, router, mockDB, mockStore, mockProvider, mockAuthenticator +} + +func TestCallBackHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + + fixedTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + + testCases := []struct { + name string + setupMocks func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider, mockAuthenticator *MockAuth) + expectedStatus int + expectedBody *model.User + }{ + { + name: "Callback Failed User Auth", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider, mockAuthenticator *MockAuth) { + session := sessions.NewSession(mockStore, "sumnotes_session") + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + + mockAuthenticator.On("CompleteUserAuth", mock.Anything, mock.Anything).Return(nil, errors.New("Error")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Callback Failed DB User Search", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider, mockAuthenticator *MockAuth) { + session := sessions.NewSession(mockStore, "sumnotes_session") + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + + mockAuthenticator.On("CompleteUserAuth", mock.Anything, mock.Anything).Return(goth.User{ + Email: "abc@abc.com", + }, nil) + + mockDB.On("FindUserByEmail", "abc@abc.com").Return(nil, errors.New("Error")) + + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Callback Failed DB User nil and create user error", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider, mockAuthenticator *MockAuth) { + session := sessions.NewSession(mockStore, "sumnotes_session") + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + + mockAuthenticator.On("CompleteUserAuth", mock.Anything, mock.Anything).Return(goth.User{ + Email: "abc@abc.com", + }, nil) + + mockDB.On("FindUserByEmail", "abc@abc.com").Return(nil, nil) + mockDB.On("CreateUser", mock.Anything).Return(nil, errors.New("Error")) + + // mockDB.On("UpdateUserTokens", "abc@abc.com").Return(nil, errors.New("Error")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Callback Failed Update Token", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider, mockAuthenticator *MockAuth) { + session := sessions.NewSession(mockStore, "sumnotes_session") + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + + mockAuthenticator.On("CompleteUserAuth", mock.Anything, mock.Anything).Return(goth.User{ + Email: "abc@abc.com", + AccessToken: "abc", + RefreshToken: "def", + ExpiresAt: fixedTime, + }, nil) + + mockDB.On("FindUserByEmail", "abc@abc.com").Return(nil, nil) + mockDB.On("CreateUser", mock.Anything).Return(&model.User{ + ID: "1", + }, nil) + mockDB.On("UpdateUserTokens", "1", "abc", "def", fixedTime).Return(errors.New("Error")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Callback Failed Get Session", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider, mockAuthenticator *MockAuth) { + + mockAuthenticator.On("CompleteUserAuth", mock.Anything, mock.Anything).Return(goth.User{ + Email: "abc@abc.com", + AccessToken: "abc", + RefreshToken: "def", + ExpiresAt: fixedTime, + }, nil) + + mockDB.On("FindUserByEmail", "abc@abc.com").Return(nil, nil) + mockDB.On("CreateUser", mock.Anything).Return(&model.User{ + ID: "1", + }, nil) + mockDB.On("UpdateUserTokens", "1", "abc", "def", fixedTime).Return(nil) + + // session := sessions.NewSession(mockStore, "sumnotes_session") + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(nil, errors.New("Error")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Callback Failed Error Session and Error Save", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider, mockAuthenticator *MockAuth) { + + mockAuthenticator.On("CompleteUserAuth", mock.Anything, mock.Anything).Return(goth.User{ + Email: "abc@abc.com", + AccessToken: "abc", + RefreshToken: "def", + ExpiresAt: fixedTime, + }, nil) + + mockDB.On("FindUserByEmail", "abc@abc.com").Return(nil, nil) + mockDB.On("CreateUser", mock.Anything).Return(&model.User{ + ID: "1", + }, nil) + mockDB.On("UpdateUserTokens", "1", "abc", "def", fixedTime).Return(nil) + mockDB.On("UpdateUserTokens", "1", "abc", "def", fixedTime).Return(nil) + + session := sessions.NewSession(mockStore, "sumnotes_session") + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(errors.New("session save error")) + + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Callback Success", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider, mockAuthenticator *MockAuth) { + + mockAuthenticator.On("CompleteUserAuth", mock.Anything, mock.Anything).Return(goth.User{ + Email: "abc@abc.com", + AccessToken: "abc", + RefreshToken: "def", + ExpiresAt: fixedTime, + }, nil) + + mockDB.On("FindUserByEmail", "abc@abc.com").Return(nil, nil) + mockDB.On("CreateUser", mock.Anything).Return(&model.User{ + ID: "1", + }, nil) + mockDB.On("UpdateUserTokens", "1", "abc", "def", fixedTime).Return(nil) + mockDB.On("UpdateUserTokens", "1", "abc", "def", fixedTime).Return(nil) + + session := sessions.NewSession(mockStore, "sumnotes_session") + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + }, + expectedStatus: http.StatusTemporaryRedirect, + expectedBody: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + w, router, mockDB, mockStore, mockProvider, mockAuthenticator := setupCallBackTest() + + tc.setupMocks(mockDB, mockStore, mockProvider, mockAuthenticator) + + req, _ := http.NewRequest(http.MethodGet, "/auth/google/callback?provider=google", nil) + router.ServeHTTP(w, req) + + if tc.expectedStatus == http.StatusTemporaryRedirect { + assert.Equal(t, "http://example.com", w.Result().Header.Get("Location")) + + } + + assert.Equal(t, tc.expectedStatus, w.Code) + }) + } +} + +func setupMeTest() (*httptest.ResponseRecorder, *gin.Engine, *MockDB, *MockStore, *MockProvider) { + w, router, mockDB, mockStore, mockProvider, _ := setupBaseTest() + + h := &Handler{ + db: mockDB, + store: mockStore, + p: mockProvider, + } + + router.GET("/me", h.Me) + + return w, router, mockDB, mockStore, mockProvider +} + +func TestHandler_Me(t *testing.T) { + gin.SetMode(gin.TestMode) + + expectedUser := &model.User{ + ID: "user-123", + Name: "Test User", + Email: "test@example.com", + AvatarURL: "http://example.com/avatar.png", + CreatedAt: time.Now(), + } + + testCases := []struct { + name string + setupMocks func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) + expectedStatus int + expectedBody *model.User + }{ + { + name: "Get Me Success", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + mockDB.On("FindUserByID", "user-123").Return(expectedUser, nil) + }, + expectedStatus: http.StatusOK, + expectedBody: expectedUser, + }, + { + name: "Get Me Session Error", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(nil, errors.New("Failed to Get User Session")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Get Me Session Empty User", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + }, + expectedStatus: http.StatusNotFound, + expectedBody: nil, + }, + { + name: "Get Me DB Find Error", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + mockDB.On("FindUserByID", "user-123").Return(nil, errors.New("DB Find User Error")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Get Me DB No User", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + mockDB.On("FindUserByID", "user-123").Return(nil, nil) + }, + expectedStatus: http.StatusNotFound, + expectedBody: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + w, router, mockDB, mockStore, mockProvider := setupMeTest() + + tc.setupMocks(mockDB, mockStore, mockProvider) + + // Perform the request + req, _ := http.NewRequest(http.MethodGet, "/me", nil) + router.ServeHTTP(w, req) + + // Assertions + assert.Equal(t, tc.expectedStatus, w.Code) + + if tc.expectedBody != nil { + var responseBody model.User + err := json.Unmarshal(w.Body.Bytes(), &responseBody) + assert.NoError(t, err) + + // We only compare the fields that are expected to be returned. + // The tokens and expiry are updated in the background. + assert.Equal(t, tc.expectedBody.ID, responseBody.ID) + assert.Equal(t, tc.expectedBody.Name, responseBody.Name) + assert.Equal(t, tc.expectedBody.Email, responseBody.Email) + assert.Equal(t, tc.expectedBody.AvatarURL, responseBody.AvatarURL) + } + + // Verify that all mock expectations were met + mockDB.AssertExpectations(t) + mockStore.AssertExpectations(t) + mockProvider.AssertExpectations(t) + }) + } + +} + +func TestHandler_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("Get Me Success", func(t *testing.T) { + expectedRedirect := "http://test.com" + + _, _, mockDB, mockStore, mockProvider, mockAuthenticator := setupBaseTest() + + cfg := &config.Config{ + FrontendURL: expectedRedirect, + } + h := New(mockDB, mockStore, cfg, mockProvider, mockAuthenticator) + + // Setup router + router := gin.Default() + router.GET("/success", h.Success) + + // Perform request + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/success", nil) + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusPermanentRedirect, w.Code) + assert.Equal(t, expectedRedirect, w.Result().Header.Get("Location")) + }) + +} + +func setupRefreshTest() (*httptest.ResponseRecorder, *gin.Engine, *MockDB, *MockStore, *MockProvider) { + w, router, mockDB, mockStore, mockProvider, mockAuthenticator := setupBaseTest() + + cfg := &config.Config{} + h := New(mockDB, mockStore, cfg, mockProvider, mockAuthenticator) + + router.GET("/refresh", h.Refresh) + + return w, router, mockDB, mockStore, mockProvider +} + +func TestHandler_Refresh(t *testing.T) { + gin.SetMode(gin.TestMode) + + fixedTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + + expectedUser := &model.User{ + ID: "user-123", + Name: "Test User", + Email: "test@example.com", + AvatarURL: "http://example.com/avatar.png", + RefreshToken: "old-refresh-token", + CreatedAt: fixedTime, + } + + // Define the new token details + newToken := &oauth2.Token{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + Expiry: fixedTime.Add(1 * time.Hour), + } + + testCases := []struct { + name string + setupMocks func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) + expectedStatus int + expectedBody *model.User + }{ + { + name: "Get Refresh Success", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + mockDB.On("FindUserByID", "user-123").Return(expectedUser, nil) + mockProvider.On("RefreshToken", expectedUser.RefreshToken).Return(newToken, nil) + mockDB.On("UpdateUserTokens", "user-123", newToken.AccessToken, newToken.RefreshToken, newToken.Expiry).Return(nil) + }, + expectedStatus: http.StatusOK, + expectedBody: expectedUser, + }, + { + name: "Get Refresh Session Failure", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(nil, errors.New("Failed to get user session")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Get Refresh No User Session", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + }, + expectedStatus: http.StatusNotFound, + expectedBody: nil, + }, + { + name: "Get Refresh DB User Error", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + + mockDB.On("FindUserByID", "user-123").Return(nil, errors.New("Failed to get user in DB")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + { + name: "Get Refresh DB No User", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + + mockDB.On("FindUserByID", "user-123").Return(nil, nil) + }, + expectedStatus: http.StatusNotFound, + expectedBody: nil, + }, + { + name: "Refresh fails and session is cleared", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + mockDB.On("FindUserByID", "user-123").Return(expectedUser, nil) + mockProvider.On("RefreshToken", expectedUser.RefreshToken).Return(nil, auth.ErrRefreshFailed) + + // Expect Save to be called to clear the session + mockStore.On("Save", mock.Anything, mock.Anything, mock.MatchedBy(func(s *sessions.Session) bool { + return s.Options.MaxAge == -1 + })).Return(nil) + }, + expectedStatus: http.StatusUnauthorized, + expectedBody: nil, + }, + { + name: "Refresh fails and session save fails", + setupMocks: func(mockDB *MockDB, mockStore *MockStore, mockProvider *MockProvider) { + session := sessions.NewSession(mockStore, "sumnotes_session") + session.Values["user_id"] = "user-123" + + mockStore.On("Get", mock.Anything, "sumnotes_session").Return(session, nil) + mockDB.On("FindUserByID", "user-123").Return(expectedUser, nil) + mockProvider.On("RefreshToken", expectedUser.RefreshToken).Return(nil, auth.ErrRefreshFailed) + + // Expect Save to be called and fail + mockStore.On("Save", mock.Anything, mock.Anything, mock.Anything).Return(errors.New("session save error")) + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + w, router, mockDB, mockStore, mockProvider := setupRefreshTest() + + tc.setupMocks(mockDB, mockStore, mockProvider) + + // Perform the request + req, _ := http.NewRequest(http.MethodGet, "/refresh", nil) + router.ServeHTTP(w, req) + + // Assertions + assert.Equal(t, tc.expectedStatus, w.Code) + + if tc.expectedBody != nil { + var responseBody model.User + err := json.Unmarshal(w.Body.Bytes(), &responseBody) + assert.NoError(t, err) + + // We only compare the fields that are expected to be returned. + // The tokens and expiry are updated in the background. + assert.Equal(t, tc.expectedBody.ID, responseBody.ID) + assert.Equal(t, tc.expectedBody.Name, responseBody.Name) + assert.Equal(t, tc.expectedBody.Email, responseBody.Email) + assert.Equal(t, tc.expectedBody.AvatarURL, responseBody.AvatarURL) + } + + // Verify that all mock expectations were met + mockDB.AssertExpectations(t) + mockStore.AssertExpectations(t) + mockProvider.AssertExpectations(t) + }) + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index baaec6d..12d307b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,18 +1,34 @@ package middleware import ( - "database/sql" "main/internal/auth" "main/internal/database" + "main/internal/model" "net/http" "time" - "github.com/antonlindstrom/pgstore" "github.com/gin-gonic/gin" + "github.com/gorilla/sessions" ) +var userCtxKey = "authedUser" + +func SetUser(c *gin.Context, user *model.User) { + c.Set(userCtxKey, user) +} + +func GetUser(c *gin.Context) (*model.User, bool) { + u, exists := c.Get(userCtxKey) + if !exists { + return nil, false + } + + user, ok := u.(*model.User) + return user, ok +} + // Auth is a middleware to protect routes that require authentication. -func Auth(store *pgstore.PGStore, db *sql.DB) gin.HandlerFunc { +func Auth(store sessions.Store, db database.UserStore) gin.HandlerFunc { return func(c *gin.Context) { session, err := auth.GetSession(store, c.Request) if err != nil { @@ -27,7 +43,7 @@ func Auth(store *pgstore.PGStore, db *sql.DB) gin.HandlerFunc { return } - u, err := database.FindUserByID(db, userID) + u, err := db.FindUserByID(userID) if err != nil { c.AbortWithStatus(http.StatusNotFound) return @@ -38,6 +54,8 @@ func Auth(store *pgstore.PGStore, db *sql.DB) gin.HandlerFunc { return } + SetUser(c, u) + c.Next() } } diff --git a/internal/server/server.go b/internal/server/server.go index 1bb4afd..05b0dbb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,27 +1,27 @@ package server import ( - "database/sql" "main/internal/auth" "main/internal/config" + "main/internal/database" "main/internal/handler" "main/internal/middleware" "time" - "github.com/antonlindstrom/pgstore" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/gorilla/sessions" "github.com/markbates/goth" "github.com/markbates/goth/providers/google" ) type Server struct { *gin.Engine - db *sql.DB - store *pgstore.PGStore + db database.UserStore + store sessions.Store } -func New(cfg *config.Config, db *sql.DB) (*Server, error) { +func New(cfg *config.Config, db database.UserStore) (*Server, error) { r := gin.Default() store, err := auth.NewStore(cfg.DatabaseURL, []byte(cfg.SessionSecret)) @@ -37,6 +37,8 @@ func New(cfg *config.Config, db *sql.DB) (*Server, error) { goth.UseProviders(gp) + auth := auth.NewGothicAuthenticator() + r.LoadHTMLGlob("templates/*") r.Use(cors.New(cors.Config{ @@ -48,7 +50,7 @@ func New(cfg *config.Config, db *sql.DB) (*Server, error) { MaxAge: 12 * time.Hour, })) - h := handler.New(db, store, cfg) + h := handler.New(db, store, cfg, gp, auth) api := r.Group("/api") api.GET("/", h.Home) api.GET("/auth/:provider", h.SignInWithProvider)