@ -0,0 +1,37 @@ | |||
# This is an example goreleaser.yaml file with some sane defaults. | |||
# Make sure to check the documentation at http://goreleaser.com | |||
before: | |||
hooks: | |||
# You may remove this if you don't use go modules. | |||
- go mod download | |||
# you may remove this if you don't need go generate | |||
- go generate ./... | |||
builds: | |||
- env: | |||
- CGO_ENABLED=0 | |||
goos: | |||
- linux | |||
- windows | |||
- darwin | |||
goarch: | |||
- amd64 | |||
- arm64 | |||
- arm | |||
archives: | |||
- replacements: | |||
darwin: Darwin | |||
linux: Linux | |||
windows: Windows | |||
386: i386 | |||
amd64: x86_64 | |||
arm64: aarch64 | |||
checksum: | |||
name_template: 'checksums.txt' | |||
snapshot: | |||
name_template: "{{ .Tag }}-next" | |||
changelog: | |||
sort: asc | |||
filters: | |||
exclude: | |||
- '^docs:' | |||
- '^test:' |
@ -0,0 +1,2 @@ | |||
public/vendor | |||
vendor/ |
@ -1,5 +1,5 @@ | |||
// +build !dev | |||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.coolaj86.com/coolaj86/goserv/assets".Assets | |||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.example.com/example/goserv/assets".Assets | |||
package assets |
@ -1,5 +1,5 @@ | |||
// +build !dev | |||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.coolaj86.com/coolaj86/goserv/assets/configfs".Assets | |||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.example.com/example/goserv/assets/configfs".Assets | |||
package configfs |
@ -0,0 +1,3 @@ | |||
-- this is only used for the tests | |||
DROP TABLE IF EXISTS "authn"; | |||
DROP TABLE IF EXISTS "events"; |
@ -0,0 +1,6 @@ | |||
// See also | |||
// | |||
// internal/api: http://localhost:6060/pkg/git.example.com/example/project/internal/api | |||
// | |||
// internal/db: http://localhost:6060/pkg/git.example.com/example/project/internal/db | |||
package main |
@ -1,2 +1,22 @@ | |||
PORT="3000" | |||
#LISTEN=":3000" | |||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres | |||
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres_test | |||
TRUST_PROXY=false | |||
# Supports OIDC-compliant SSO issuers / providers | |||
# (Auth0, Google, etc) | |||
# (should provide .well-known/openid-configuration and .well-known/jwks.json) | |||
OIDC_WHITELIST=https://mock.pocketid.app | |||
# Public Key may be provided in addition to or in lieu of OIDC_WHITELIST | |||
# can be RSA or ECDSA, either a filename or JWK/JSON (or PEM, but good luck escaping the newlines) | |||
# | |||
# go install -mod=vendor git.rootprojects.org/root/keypairs/cmd/keypairs | |||
# keypairs gen -o priv.jwk.json --pub pub.jwk.json | |||
# | |||
#PUBLIC_KEY='{"crv":"P-256","kid":"kN4qj1w01Ry6ElG9I3qAVJOZFYLDklPFUdHaKozWtmc","kty":"EC","use":"sig","x":"SzzNgrOM_N0GwQWZPGFcdIKmfoQD6aXIzYm4gzGyPgQ","y":"erYeb884pk0BGMewDzEh_qYDB0aOFIxFjrXdqIzkmbw"}' | |||
PUBLIC_KEY=./pub.jwk.json | |||
#--demo is explicit |
@ -0,0 +1,5 @@ | |||
# Install `keypairs` | |||
go install -mod=vendor git.rootprojects.org/root/keypairs/cmd/keypairs | |||
# Generate a keypair | |||
keypairs gen -o key.jwk.json --pub pub.jwk.json |
@ -0,0 +1,4 @@ | |||
#!/bin/bash | |||
bash ./examples/build.sh | |||
bash ./examples/genkeys.sh | |||
bash ./examples/test.sh |
@ -0,0 +1,45 @@ | |||
#!/bin/bash | |||
export BASE_URL=http://localhost:7070 | |||
#export BASE_URL=https://example.com | |||
#CURL_OPTS="-sS" | |||
CURL_OPTS="" | |||
mkdir -p ./tmp/ | |||
# Sign an Admin token | |||
echo '{ "sub": "admin_ppid", "email": "me@example.com", "iss": "'"${BASE_URL}"'" }' > ./tmp/admin.claims.json | |||
keypairs sign --exp 1h ./key.jwk.json ./tmp/admin.claims.json > ./tmp/admin.jwt.txt 2> ./tmp/admin.jws.json | |||
export ADMIN_TOKEN=$(cat ./tmp/admin.jwt.txt) | |||
# verify the Admin token | |||
#keypairs verify ./pub.jwk.json ./admin.jwt.txt | |||
# Sign a User token | |||
echo '{ "sub": "random_ppid", "email": "me@example.com", "iss": "'"${BASE_URL}"'" }' > ./tmp/user.claims.json | |||
keypairs sign --exp 1h ./key.jwk.json ./tmp/user.claims.json > ./tmp/user.jwt.txt 2> ./tmp/user.jws.json | |||
export USER_TOKEN=$(cat ./tmp/user.jwt.txt) | |||
# verify the User token | |||
#keypairs verify ./pub.jwk.json ./user.jwt.txt | |||
EID=$(cat ./user.jws.json | grep sub | cut -d'"' -f 4) | |||
echo "" | |||
echo 'DELETE /api/public/reset (only works in --demo mode, deletes all data)' | |||
curl $CURL_OPTS -X DELETE "${BASE_URL}/api/public/reset" | |||
echo "" | |||
echo "" | |||
echo "Bootstrap with a new admin (only works once)" | |||
curl -f $CURL_OPTS -X POST "${BASE_URL}/api/public/setup" \ | |||
-H "Authorization: Bearer ${ADMIN_TOKEN}" | |||
echo "" | |||
echo "Create a new user" | |||
curl $CURL_OPTS -X POST "${BASE_URL}/api/users" \ | |||
-H "Authorization: Bearer ${USER_TOKEN}" \ | |||
-d '{ "display_name": "Jo Doe" }' | |||
echo "" |
@ -1,14 +1,17 @@ | |||
module git.coolaj86.com/coolaj86/goserv | |||
module git.example.com/example/goserv | |||
go 1.15 | |||
require ( | |||
git.rootprojects.org/root/go-gitver v1.1.3 // indirect | |||
git.rootprojects.org/root/go-gitver/v2 v2.0.1 | |||
git.rootprojects.org/root/keypairs v0.6.3 | |||
github.com/go-chi/chi v4.1.2+incompatible | |||
github.com/jmoiron/sqlx v1.2.0 | |||
github.com/joho/godotenv v1.3.0 | |||
github.com/lib/pq v1.8.0 | |||
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect | |||
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 | |||
golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346 // indirect | |||
golang.org/x/tools v0.0.0-20201001230009-b5b87423c93b | |||
google.golang.org/appengine v1.6.6 // indirect | |||
) |
@ -0,0 +1,414 @@ | |||
package api | |||
import ( | |||
"context" | |||
"crypto/rand" | |||
"database/sql" | |||
"encoding/json" | |||
"errors" | |||
"fmt" | |||
"io" | |||
"log" | |||
"net/http" | |||
"strconv" | |||
"strings" | |||
"time" | |||
"git.rootprojects.org/root/keypairs" | |||
"git.rootprojects.org/root/keypairs/keyfetch" | |||
"github.com/go-chi/chi" | |||
) | |||
// TrustProxy will respect X-Forwarded-* headers | |||
var TrustProxy bool | |||
// OIDCWhitelist is a list of allowed issuers | |||
var OIDCWhitelist string | |||
var issuers keyfetch.Whitelist | |||
// RandReader is a crypto/rand.Reader by default | |||
var RandReader io.Reader = rand.Reader | |||
var startedAt = time.Now() | |||
var defaultMaxBytes int64 = 1 << 20 | |||
var apiIsReady bool | |||
// Init will add the API routes to the given router | |||
func Init(pub keypairs.PublicKey, r chi.Router) http.Handler { | |||
// TODO more of this stuff should be options for the API | |||
{ | |||
// block-scoped so we don't keep temp vars around | |||
var err error | |||
list := strings.Fields(strings.ReplaceAll(strings.TrimSpace(OIDCWhitelist), ",", " ")) | |||
issuers, err = keyfetch.NewWhitelist(list) | |||
if nil != err { | |||
log.Fatal("error parsing oidc whitelist:", err) | |||
} | |||
} | |||
// OIDC Routes | |||
if nil != pub { | |||
fmt.Println("Public Key Thumbprint:", pub.Thumbprint()) | |||
fmt.Println("OIDC enabled at /.well-known/openid-configuration") | |||
r.Get("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { | |||
baseURL := getBaseURL(r) | |||
w.Header().Set("Content-Type", "application/json") | |||
w.Write([]byte(fmt.Sprintf( | |||
`{ "issuer": "%s", "jwks_uri": "%s/.well-known/jwks.json" }`+"\n", | |||
baseURL, baseURL, | |||
))) | |||
}) | |||
fmt.Println("JWKs enabled at /.well-known/jwks.json") | |||
r.Get("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) { | |||
w.Header().Set("Content-Type", "application/json") | |||
// non-standard: add expiry for when key should be fetched again | |||
// TODO expiry should also go in the HTTP caching headers | |||
exp := time.Now().Add(2 * time.Hour) | |||
b := pubToOIDC(pub, exp) | |||
// it's the little things | |||
w.Write(append(b, '\n')) | |||
}) | |||
} | |||
r.Route("/api", func(r chi.Router) { | |||
r.Use(limitResponseSize) | |||
r.Use(jsonAllTheThings) | |||
/* | |||
n, err := countAdmins() | |||
if nil != err { | |||
log.Fatal("could not connect to database on boot:", err) | |||
} | |||
apiIsReady = n > 0 | |||
*/ | |||
// Unauthenticated routes | |||
r.Route("/public", func(r chi.Router) { | |||
r.Post("/setup", publicSetup) | |||
r.Get("/ping", ping) | |||
}) | |||
// Require admin-level permission | |||
r.Route("/admin", func(r chi.Router) { | |||
r.Use(errorUnlessReady()) | |||
r.Use(mustAdmin()) | |||
r.Get("/ping", ping) | |||
}) | |||
// Any authenticated user | |||
r.Route("/user", func(r chi.Router) { | |||
r.Use(errorUnlessReady()) | |||
r.Use(canImpersonate()) | |||
r.Get("/ping", ping) | |||
// TODO get ALL of the user's data | |||
//r.Get("/", userComplete) | |||
}) | |||
}) | |||
return r | |||
} | |||
// Reset sets the API back to its initial state | |||
func Reset() { | |||
apiIsReady = false | |||
} | |||
// utils | |||
func getBaseURL(r *http.Request) string { | |||
var scheme string | |||
if nil != r.TLS || | |||
(TrustProxy && "https" == r.Header.Get("X-Forwarded-Proto")) { | |||
scheme = "https:" | |||
} else { | |||
scheme = "http:" | |||
} | |||
return fmt.Sprintf( | |||
"%s//%s", | |||
scheme, | |||
r.Host, | |||
) | |||
} | |||
func mustAuthn(r *http.Request) (*http.Request, error) { | |||
authzParts := strings.Split(r.Header.Get("Authorization"), " ") | |||
if 2 != len(authzParts) { | |||
return nil, fmt.Errorf("Bad Request: missing Auhorization header") | |||
} | |||
jwt := authzParts[1] | |||
// TODO should probably add an error to keypairs | |||
jws := keypairs.JWTToJWS(jwt) | |||
if nil == jws { | |||
return nil, fmt.Errorf("Bad Request: malformed Authorization header") | |||
} | |||
if err := jws.DecodeComponents(); nil != err { | |||
return nil, fmt.Errorf("Bad Request: malformed JWT") | |||
} | |||
kid, _ := jws.Header["kid"].(string) | |||
if "" == kid { | |||
return nil, fmt.Errorf("Bad Request: missing 'kid' identifier") | |||
} | |||
iss, _ := jws.Claims["iss"].(string) | |||
// TODO beware domain fronting, we should set domain statically | |||
// See https://pkg.go.dev/git.rootprojects.org/root/keypairs@v0.6.2/keyfetch | |||
// (Caddy does protect against Domain-Fronting by default: | |||
// https://github.com/caddyserver/caddy/issues/2500) | |||
if "" == iss || !issuers.IsTrustedIssuer(iss, r) { | |||
return nil, fmt.Errorf("Bad Request: 'iss' is not a trusted issuer") | |||
} | |||
pub, err := keyfetch.OIDCJWK(kid, iss) | |||
if nil != err { | |||
return nil, fmt.Errorf("Bad Request: 'kid' could not be matched to a known public key") | |||
} | |||
errs := keypairs.VerifyClaims(pub, jws) | |||
if nil != errs { | |||
strs := []string{} | |||
for _, err := range errs { | |||
strs = append(strs, err.Error()) | |||
} | |||
return nil, fmt.Errorf("invalid jwt:\n%s", strings.Join(strs, "\n\t")) | |||
} | |||
email, _ := jws.Claims["email"].(string) | |||
ppid, _ := jws.Claims["sub"].(string) | |||
if "" == email || "" == ppid { | |||
return nil, fmt.Errorf("valid signed token, but missing claim for either 'email' or 'sub'") | |||
} | |||
ctx := context.WithValue(r.Context(), userPPID, ppid) | |||
return r.WithContext(ctx), nil | |||
} | |||
func pubToOIDC(pub keypairs.PublicKey, exp time.Time) []byte { | |||
exps := strconv.FormatInt(exp.Unix(), 10) | |||
jsons := string(keypairs.MarshalJWKPublicKey(pub)) | |||
// this isn't as fragile as it looks, just adding some OIDC keys and such | |||
// make prettier | |||
jsons = strings.Replace(jsons, `{"`, `{ "`, 1) | |||
jsons = strings.Replace(jsons, `",`, `" ,`, -1) | |||
// nix trailing } | |||
jsons = jsons[0 : len(jsons)-1] | |||
// add on the OIDC stuff (exp is non-standard, but used by pocketid) | |||
jsons = `{ "keys": [ ` + | |||
jsons + fmt.Sprintf(`, "ext": true , "key_ops": ["verify"], "exp": %s }`, exps) + | |||
" ] }" | |||
return []byte(jsons) | |||
} | |||
// HTTPResponse gives a basic status message | |||
// TODO sanitize all error messages and define error codes | |||
type HTTPResponse struct { | |||
Error string `json:"error,omitempty"` | |||
Code string `json:"code,omitempty"` | |||
Success bool `json:"success"` | |||
Data interface{} `json:"result,omitempty"` | |||
} | |||
func ping(w http.ResponseWriter, r *http.Request) { | |||
ctx := r.Context() | |||
// as of yet, only known to be a user | |||
ppid, _ := ctx.Value(userPPID).(string) | |||
w.Write([]byte(fmt.Sprintf( | |||
`{ "success": true, "uptime": %.0f, "ppid": %q }`+"\n", | |||
time.Since(startedAt).Seconds(), ppid, | |||
))) | |||
} | |||
func noImpl(w http.ResponseWriter, r *http.Request) { | |||
w.Write([]byte( | |||
`{ "success": false, "error": "not implemented" }` + "\n", | |||
)) | |||
} | |||
func publicSetup(w http.ResponseWriter, r *http.Request) { | |||
if apiIsReady { | |||
// default is already 404, methinks | |||
http.Error(w, "Not Found", http.StatusNotFound) | |||
return | |||
} | |||
r, err := mustAuthn(r) | |||
if nil != err { | |||
userError(w, err) | |||
return | |||
} | |||
ctx := r.Context() | |||
ppid := ctx.Value(userPPID).(string) | |||
if "" == ppid { | |||
if nil != err { | |||
userError(w, err) | |||
return | |||
} | |||
} | |||
if err := adminBootstrap(ppid); nil != err { | |||
serverError("publicSetup.bootstrap", w, err) | |||
return | |||
} | |||
apiIsReady = true | |||
w.Write([]byte(fmt.Sprintf( | |||
`{ "success": true, "sub": %q }`+"\n", | |||
ppid, | |||
))) | |||
} | |||
func reply(w http.ResponseWriter, msg interface{}) { | |||
w.WriteHeader(http.StatusOK) | |||
b, _ := json.MarshalIndent(&HTTPResponse{ | |||
Success: true, | |||
Data: msg, | |||
}, "", " ") | |||
w.Write(append(b, '\n')) | |||
} | |||
func userError(w http.ResponseWriter, err error) { | |||
w.WriteHeader(http.StatusBadRequest) | |||
b, _ := json.Marshal(&HTTPResponse{ | |||
Error: err.Error(), | |||
}) | |||
w.Write(append(b, '\n')) | |||
} | |||
func dbError(hint string, w http.ResponseWriter, err error) { | |||
serverError(hint, w, err) | |||
} | |||
func serverError(hint string, w http.ResponseWriter, err error) { | |||
// TODO check constraint errors and such, as those are likely user errors | |||
if sql.ErrNoRows == err { | |||
userError(w, fmt.Errorf("E_NOT_FOUND: %s", err)) | |||
return | |||
} else if strings.Contains(err.Error(), "constraint") { | |||
userError(w, fmt.Errorf("E_DUPLICATE_NAME: %s", err)) | |||
return | |||
} | |||
log.Printf("[%s] error: %v", hint, err) | |||
w.WriteHeader(http.StatusInternalServerError) | |||
b, _ := json.Marshal(&HTTPResponse{ | |||
Error: err.Error(), | |||
}) | |||
w.Write(append(b, '\n')) | |||
} | |||
var errSetID = errors.New("you may not set the player's ID") | |||
// Middleware | |||
func errorUnlessReady() func(next http.Handler) http.Handler { | |||
return func(next http.Handler) http.Handler { | |||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
if !apiIsReady { | |||
http.Error(w, "Not Found", http.StatusNotFound) | |||
return | |||
} | |||
next.ServeHTTP(w, r) | |||
}) | |||
} | |||
} | |||
type adminCtx string | |||
const ( | |||
adminPPID adminCtx = "ppid" | |||
) | |||
type userCtx string | |||
const ( | |||
userPPID userCtx = "ppid" | |||
) | |||
func canImpersonate() func(next http.Handler) http.Handler { | |||
return actAsAdmin(false) | |||
} | |||
func mustAdmin() func(next http.Handler) http.Handler { | |||
return actAsAdmin(true) | |||
} | |||
func countAdmins() (int, error) { | |||
return 0, errors.New("not implemented") | |||
} | |||
func adminBootstrap(ppid string) error { | |||
return errors.New("not implemented") | |||
} | |||
func actAsAdmin(must bool) func(next http.Handler) http.Handler { | |||
return func(next http.Handler) http.Handler { | |||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
var err error | |||
r, err = mustAuthn(r) | |||
if nil != err { | |||
userError(w, err) | |||
return | |||
} | |||
ctx := r.Context() | |||
// as of yet, only known to be a user | |||
ppid, _ := ctx.Value(userPPID).(string) | |||
//ok, err := isAdmin(ppid) | |||
ok, err := false, errors.New("not implemented") | |||
if nil != err { | |||
serverError("actAsAdmin", w, err) | |||
return | |||
} | |||
if !ok { | |||
if must { | |||
userError(w, errors.New("you're not an admin")) | |||
return | |||
} | |||
next.ServeHTTP(w, r) | |||
return | |||
} | |||
// we now know this is an admin, adjust accordingly | |||
ctx = context.WithValue(r.Context(), adminPPID, ppid) | |||
// also, an admin can impersonate | |||
//uemail := r.URL.Query().Get("user_email") | |||
uppid := r.URL.Query().Get("manager_id") | |||
if "" == uppid { | |||
uppid = ppid | |||
} | |||
ctx = context.WithValue(ctx, userPPID, uppid) | |||
next.ServeHTTP(w, r.WithContext(ctx)) | |||
}) | |||
} | |||
} | |||
func jsonAllTheThings(next http.Handler) http.Handler { | |||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
// just setting a default, other handlers can change this | |||
w.Header().Set("Content-Type", "application/json") | |||
next.ServeHTTP(w, r) | |||
}) | |||
} | |||
func limitResponseSize(next http.Handler) http.Handler { | |||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
r.Body = http.MaxBytesReader(w, r.Body, defaultMaxBytes) | |||
next.ServeHTTP(w, r) | |||
}) | |||
} |
@ -0,0 +1,164 @@ | |||
package api | |||
import ( | |||
"bytes" | |||
"encoding/json" | |||
"errors" | |||
"fmt" | |||
"io/ioutil" | |||
"log" | |||
mathrand "math/rand" | |||
"net/http" | |||
"net/http/httptest" | |||
"net/url" | |||
"os" | |||
"strings" | |||
"testing" | |||
"git.example.com/example/goserv/internal/db" | |||
"git.rootprojects.org/root/keypairs" | |||
"git.rootprojects.org/root/keypairs/keyfetch" | |||
"github.com/go-chi/chi" | |||
) | |||
var srv *httptest.Server | |||
var testKey keypairs.PrivateKey | |||
var testPub keypairs.PublicKey | |||
var testWhitelist keyfetch.Whitelist | |||
func init() { | |||
// In tests it's nice to get the same "random" values, every time | |||
RandReader = testReader{} | |||
mathrand.Seed(0) | |||
} | |||
func TestMain(m *testing.M) { | |||
connStr := needsTestDB(m) | |||
if strings.Contains(connStr, "@localhost/") || strings.Contains(connStr, "@localhost:") { | |||
connStr += "?sslmode=disable" | |||
} else { | |||
connStr += "?sslmode=required" | |||
} | |||
if err := db.Init(connStr); nil != err { | |||
log.Fatal("db connection error", err) | |||
return | |||
} | |||
if err := db.DropAllTables(db.PleaseDoubleCheckTheDatabaseURLDontDropProd(connStr)); nil != err { | |||
log.Fatal(err) | |||
} | |||
if err := db.Init(connStr); nil != err { | |||
log.Fatal("db connection error", err) | |||
return | |||
} | |||
var err error | |||
testKey = keypairs.NewDefaultPrivateKey() | |||
testPub = keypairs.NewPublicKey(testKey.Public()) | |||
r := chi.NewRouter() | |||
srv = httptest.NewServer(Init(testPub, r)) | |||
testWhitelist, err = keyfetch.NewWhitelist(nil, []string{srv.URL}) | |||
if nil != err { | |||
log.Fatal("bad whitelist", err) | |||
return | |||
} | |||
os.Exit(m.Run()) | |||
} | |||
// public APIs | |||
func Test_Public_Ping(t *testing.T) { | |||
if err := testPing("public"); nil != err { | |||
t.Fatal(err) | |||
} | |||
} | |||
// test types | |||
type testReader struct{} | |||
func (testReader) Read(p []byte) (n int, err error) { | |||
return mathrand.Read(p) | |||
} | |||
func testPing(which string) error { | |||
urlstr := fmt.Sprintf("/api/%s/ping", which) | |||
res, err := testReq("GET", urlstr, "", nil, 200) | |||
if nil != err { | |||
return err | |||
} | |||
data := map[string]interface{}{} | |||
if err := json.NewDecoder(res.Body).Decode(&data); nil != err { | |||
return err | |||
} | |||
if success, ok := data["success"].(bool); !ok || !success { | |||
log.Printf("Bad Response\n\tURL:%s\n\tBody:\n%#v", urlstr, data) | |||
return errors.New("bad response: missing success") | |||
} | |||
if ppid, _ := data["ppid"].(string); "" != ppid { | |||
return fmt.Errorf("the effective user ID isn't what it should be: %q != %q", ppid, "") | |||
} | |||
return nil | |||
} | |||
func testReq(method, pathname string, jwt string, payload []byte, expectedStatus int) (*http.Response, error) { | |||
client := srv.Client() | |||
urlstr, _ := url.Parse(srv.URL + pathname) | |||
if "" == method { | |||
method = "GET" | |||
} | |||
req := &http.Request{ | |||
Method: method, | |||
URL: urlstr, | |||
Body: ioutil.NopCloser(bytes.NewReader(payload)), | |||
Header: http.Header{}, | |||
} | |||
if len(jwt) > 0 { | |||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) | |||
} | |||
res, err := client.Do(req) | |||
if nil != err { | |||
return nil, err | |||
} | |||
if expectedStatus > 0 { | |||
if expectedStatus != res.StatusCode { | |||
data, _ := ioutil.ReadAll(res.Body) | |||
log.Printf("Bad Response: %d\n\tURL:%s\n\tBody:\n%s", res.StatusCode, urlstr, string(data)) | |||
return nil, fmt.Errorf("bad status code: %d", res.StatusCode) | |||
} | |||
} | |||
return res, nil | |||
} | |||
func needsTestDB(m *testing.M) string { | |||
connStr := os.Getenv("TEST_DATABASE_URL") | |||
if "" == connStr { | |||
log.Fatal(`no connection string defined | |||
You must set TEST_DATABASE_URL to run db tests. | |||
You may find this helpful: | |||
psql 'postgres://postgres:postgres@localhost:5432/postgres' | |||
DROP DATABASE IF EXISTS postgres_test; | |||
CREATE DATABASE postgres_test; | |||
\q | |||
Then your test database URL will be | |||
export TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres_test | |||
`) | |||
} | |||
return connStr | |||
} |
@ -0,0 +1,37 @@ | |||
package api | |||
import ( | |||
"encoding/base64" | |||
"time" | |||
"git.example.com/example/goserv/internal/db" | |||
) | |||
func newID() string { | |||
// Postgres returns IDs on inserts but, | |||
// for portability and ease of association, | |||
// we'll create our own. | |||
b := make([]byte, 16) | |||
_, _ = RandReader.Read(b) | |||
id := base64.RawURLEncoding.EncodeToString(b) | |||
return id | |||
} | |||
// NotDeleted supplements a WHERE clause | |||
const NotDeleted = ` | |||
( "deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00+00' OR "deleted_at" = '1970-01-01 00:00:00+00' ) | |||
` | |||
func logEvent(action, table, recordID, by string, at time.Time) (string, error) { | |||
id := newID() | |||
if _, err := db.DB.Exec(` | |||
INSERT INTO "events" ("id", "action", "table", "record", "by", "at") | |||
VALUES ($1, $2, $3, $4, $5, $6)`, | |||
id, action, table, recordID, by, at, | |||
); nil != err { | |||
return "", err | |||
} | |||
return id, nil | |||
} |
@ -0,0 +1,66 @@ | |||
package db | |||
import ( | |||
"errors" | |||
"fmt" | |||
"os" | |||
"strings" | |||
"testing" | |||
) | |||
func TestMain(m *testing.M) { | |||
if err := testConnectAndInit(); nil != err { | |||
fmt.Fprintf(os.Stderr, err.Error()) | |||
os.Exit(1) | |||
return | |||
} | |||
os.Exit(m.Run()) | |||
} | |||
func needsTestDB() (string, error) { | |||
connStr := os.Getenv("TEST_DATABASE_URL") | |||
if "" == connStr { | |||
return "", errors.New(`no connection string defined | |||
You must set TEST_DATABASE_URL to run db tests. | |||
You may find this helpful: | |||
psql 'postgres://postgres:postgres@localhost:5432/postgres' | |||
DROP DATABASE IF EXISTS postgres_test; | |||
CREATE DATABASE postgres_test; | |||
\q | |||
Then your test database URL will be | |||
export TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres_test`) | |||
} | |||
return connStr, nil | |||
} | |||
func testConnectAndInit() error { | |||
connStr, err := needsTestDB() | |||
if nil != err { | |||
return err | |||
} | |||
if strings.Contains(connStr, "@localhost/") || strings.Contains(connStr, "@localhost:") { | |||
connStr += "?sslmode=disable" | |||
} else { | |||
connStr += "?sslmode=required" | |||
} | |||
if err := Init(connStr); nil != err { | |||
return fmt.Errorf("db connection error: %w", err) | |||
} | |||
return nil | |||
} | |||
func TestDropAll(t *testing.T) { | |||
connStr := os.Getenv("TEST_DATABASE_URL") | |||
if err := DropAllTables(PleaseDoubleCheckTheDatabaseURLDontDropProd(connStr)); nil != err { | |||
t.Fatal(err) | |||
} | |||
} |
@ -1,82 +1,76 @@ | |||
<!DOCTYPE htmtl> | |||
<html> | |||
<head> | |||
<meta charset="UTF-8" /> | |||
<title>Example</title> | |||
<link | |||
rel="stylesheet" | |||
href="./vendor/css/bootswatch.com/4/materia/bootstrap.min.css" | |||
/> | |||
</head> | |||
<body> | |||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> | |||
<a class="navbar-brand" href="#">Example</a> | |||
<button | |||
class="navbar-toggler" | |||
type="button" | |||
data-toggle="collapse" | |||
data-target="#navbarColor01" | |||
aria-controls="navbarColor01" | |||
aria-expanded="false" | |||
aria-label="Toggle navigation" | |||
> | |||
<span class="navbar-toggler-icon"></span> | |||
</button> | |||
<head> | |||
<meta charset="UTF-8" /> | |||
<title>Example</title> | |||
<link | |||
rel="stylesheet" | |||
href="./vendor/css/bootswatch.com/4/materia/bootstrap.min.css" | |||
/> | |||
</head> | |||
<body> | |||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> | |||
<a class="navbar-brand" href="#">Example</a> | |||
<button | |||
class="navbar-toggler" | |||
type="button" | |||
data-toggle="collapse" | |||
data-target="#navbarColor01" | |||
aria-controls="navbarColor01" | |||
aria-expanded="false" | |||
aria-label="Toggle navigation" | |||
> | |||
<span class="navbar-toggler-icon"></span> | |||
</button |