change cookie name
continuous-integration/drone/pr Build is passing Details

This commit is contained in:
Max Erenberg 2024-02-03 21:43:03 -05:00
parent f03f54a3d0
commit 31f5f950dd
18 changed files with 555 additions and 80 deletions

View File

@ -158,9 +158,12 @@ def reset_user_password(username: str):
@bp.route('/<username>', methods=['DELETE'])
@requires_admin_creds
@authz_restrict_to_syscom
@development_only
def delete_user(username: str):
# We use the admin creds for the integration tests for the web app, which
# uses the ceod/<host> key
txn = DeleteMemberTransaction(username)
return create_streaming_response(txn)

1
web/.gitignore vendored
View File

@ -1,2 +1,3 @@
/ceod-web
/app.sock
/test

View File

@ -35,6 +35,12 @@ You can change the `-u` (username) and `-f` (first name) arguments to simulate
a different user. See the .drone/data.ldif file in the parent directory to see
all mock users.
### Templates and static assets
In development, the templated views and static assets will be loaded from the
internal/views and internal/static folders, respectively. Unfortunately I think
that the Echo framework caches those files internally so you will need to
restart the app process if you modify them.
### Emails
By default, no emails will be sent; they will only be printed to stdout. To
send real emails, add these fields to the dev.json (replace `your_username`):
@ -48,6 +54,14 @@ send real emails, add these fields to the dev.json (replace `your_username`):
This will send all of the emails to your email address (the `To` header will
still be preserved, however).
## Tests
```sh
# On the host
CGO_ENABLED=0 go test -c -o test ./tests
# In the container
./test -test.v
```
## Deployment
Apache configuration on caffeine:
```

View File

@ -3,12 +3,16 @@ module git.csclub.uwaterloo.ca/public/pyceo/web
go 1.21.4
require (
github.com/PuerkitoBio/goquery v1.8.1
github.com/jcmturner/gokrb5/v8 v8.4.4
github.com/labstack/echo/v4 v4.11.4
github.com/labstack/gommon v0.4.2
github.com/stretchr/testify v1.8.4
)
require (
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
@ -18,6 +22,7 @@ require (
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.17.0 // indirect
@ -25,4 +30,5 @@ require (
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -1,3 +1,7 @@
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
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=
@ -56,6 +60,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@ -65,6 +70,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -78,6 +84,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
@ -88,6 +95,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -17,6 +17,8 @@ import (
"git.csclub.uwaterloo.ca/public/pyceo/web/internal"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
type TemplateManager struct {
@ -93,31 +95,42 @@ func newListener(sockPath string) net.Listener {
return l
}
func Start(cfg *config.Config) {
func useFCGI(cfg *config.Config) bool {
return !cfg.IsDev
}
func NewAPI(cfg *config.Config, ceodSrv model.CeodService, mailSrv service.MailService) *echo.Echo {
e := echo.New()
app := app.NewApp(cfg)
app := app.NewApp(cfg, mailSrv)
e.Logger.SetLevel(log.DEBUG)
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.RequestID())
e.Use(helmet(cfg.IsDev))
addStaticAssets(cfg.IsDev, e)
useFCGI := !cfg.IsDev
e.Use(appContextMiddleware(useFCGI, app))
e.Use(appContextMiddleware(useFCGI(cfg), app))
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "cookie:_csrf",
// The cookie name needs to be very unique because this app is
// being served from the root CSC domain
TokenLookup: "cookie:ceod-web-csrf",
CookieName: "ceod-web-csrf",
CookiePath: cfg.GetAppPath(),
CookieDomain: cfg.GetAppHostname(),
CookieSecure: true,
CookieSecure: !cfg.IsDev,
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
}))
e.HTTPErrorHandler = httpErrorHandler
e.Renderer = newTemplateManager(cfg.IsDev)
addRoutes(e)
return e
}
func Start(cfg *config.Config) {
e := NewAPI(cfg, service.NewCeodService(cfg), service.NewMailService(cfg))
listener := newListener(cfg.SocketPath)
e.Logger.Info("Listening on " + cfg.SocketPath)
if useFCGI {
if useFCGI(cfg) {
e.Logger.Fatal(fcgi.Serve(listener, e))
} else {
e.Logger.Fatal(http.Serve(listener, e))

View File

@ -33,7 +33,7 @@ func renderHTTPErrorPage(c echo.Context, code int, name string) {
if err := c.Render(code, name, data); err != nil {
c.Logger().Error(err)
// Fall back to plain text response
c.String(http.StatusInternalServerError, "Internal server error")
_ = c.String(http.StatusInternalServerError, "Internal server error")
}
}
@ -41,7 +41,7 @@ func renderAppErrorPage(c echo.Context, renderInfo *errorRenderInfo) {
if err := c.Render(http.StatusOK, "app_error", renderInfo); err != nil {
c.Logger().Error(err)
// Fall back to plain text response
c.String(http.StatusInternalServerError, "Internal server error")
_ = c.String(http.StatusInternalServerError, "Internal server error")
}
}

View File

@ -51,8 +51,8 @@ func helmet(isDev bool) echo.MiddlewareFunc {
func getReqInfoFromFCGI(r *http.Request) (*app.ReqInfo, error) {
env := fcgi.ProcessEnv(r)
u := new(app.ReqInfo)
u.Username, _ = env["MELLON_samaccountname"]
u.GivenName, _ = env["MELLON_givenname"]
u.Username = env["MELLON_samaccountname"]
u.GivenName = env["MELLON_givenname"]
if u.Username == "" {
return nil, errors.New("FCGI environment is missing username")
} else if u.GivenName == "" {
@ -63,11 +63,11 @@ func getReqInfoFromFCGI(r *http.Request) (*app.ReqInfo, error) {
func getReqInfoFromHTTPHeaders(r *http.Request) (*app.ReqInfo, error) {
// header names must be in canonical form (see http.CanonicalHeaderKey)
usernames, _ := r.Header["X-Csc-Adfs-Username"]
usernames := r.Header["X-Csc-Adfs-Username"]
if len(usernames) == 0 {
return nil, errors.New("Username is missing from HTTP headers")
}
givenNames, _ := r.Header["X-Csc-Adfs-Firstname"]
givenNames := r.Header["X-Csc-Adfs-Firstname"]
if len(givenNames) == 0 {
return nil, errors.New("Given name is missing from HTTP headers")
}

View File

@ -15,11 +15,11 @@ type App struct {
mail service.MailService
}
func NewApp(cfg *config.Config) *App {
func NewApp(cfg *config.Config, mailSrv service.MailService) *App {
return &App{
cfg: cfg,
ceod: service.NewCeodService(cfg),
mail: service.NewMailService(cfg),
mail: mailSrv,
}
}
@ -33,7 +33,7 @@ func (app *App) GetUser(ctx AppContext, username string) (*model.User, error) {
}
return nil, newAppError(ERROR_OTHER)
}
if cscUser.ShadowExpire != nil && *cscUser.ShadowExpire == 1 {
if cscUser.ShadowExpire == 1 {
return nil, newAppError(ERROR_MEMBERSHIP_EXPIRED)
}
return cscUser, nil

View File

@ -76,6 +76,8 @@ func NewConfig(cfgPath string) *Config {
cfg.appPath = parsedURL.Path
if cfg.appPath == "" {
cfg.appPath = "/"
} else if cfg.appPath[len(cfg.appPath)-1] != '/' {
cfg.appPath += "/"
}
if !cfg.IsDev && cfg.MTA == "" {
panic(fmt.Errorf(`"mta" is missing from %s`, cfgPath))

View File

@ -1,8 +1,11 @@
package service
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
krb5client "github.com/jcmturner/gokrb5/v8/client"
@ -11,7 +14,6 @@ import (
"github.com/jcmturner/gokrb5/v8/spnego"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
@ -50,49 +52,144 @@ type PwresetResponse struct {
Password string `json:"password"`
}
func (c *Ceod) request(ctx logging.ContextWithLogger, method, urlPath string, respBody any) error {
func (c *Ceod) getResp(ctx model.CeodRequestContext, method, urlPath string, body any) (*http.Response, error) {
ctx.Log().Debugf("%s %s", method, urlPath)
r, err := http.NewRequest(method, c.apiBaseURL+urlPath, nil)
var bodyReader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("Could not marshal body: %w", err)
}
bodyReader = bytes.NewBuffer(buf)
}
r, err := http.NewRequest(method, c.apiBaseURL+urlPath, bodyReader)
if err != nil {
return fmt.Errorf("Could not build HTTP request: %w", err)
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
}
if bodyReader != nil {
r.Header.Set("Content-Type", "application/json")
}
resp, err := c.spnegoClient.Do(r)
if err != nil {
return fmt.Errorf("SPNEGO request failed: %w", err)
return nil, fmt.Errorf("SPNEGO request failed: %w", err)
}
return resp, nil
}
func getRespError(ctx model.CeodRequestContext, resp *http.Response) error {
if resp.StatusCode/100 == 2 {
return nil
}
rawBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf(
"HTTP status=%d, and failed to read body: %w",
resp.StatusCode, err,
)
}
var errBody ErrorResponse
err = json.Unmarshal(rawBody, &errBody)
var message string
if err != nil {
message = string(rawBody)
} else {
message = errBody.Error
}
ctx.Log().Infof("ceod response: status=%d message=%s", resp.StatusCode, message)
return &model.CeodError{HttpStatus: resp.StatusCode, Message: message}
}
func (c *Ceod) request(ctx model.CeodRequestContext, method, urlPath string, respBody any) error {
resp, err := c.getResp(ctx, method, urlPath, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
var errBody ErrorResponse
err = json.NewDecoder(resp.Body).Decode(&errBody)
var message string
if err != nil {
message = "[invalid JSON response]"
} else {
message = errBody.Error
}
ctx.Log().Infof("ceod response: status=%d message=%s", resp.StatusCode, message)
return &model.CeodError{HttpStatus: resp.StatusCode, Message: message}
err = getRespError(ctx, resp)
if err != nil {
return err
}
err = json.NewDecoder(resp.Body).Decode(&respBody)
err = json.NewDecoder(resp.Body).Decode(respBody)
if err != nil {
return fmt.Errorf("Failed to decode JSON response: %w", err)
}
return nil
}
func (c *Ceod) GetUser(ctx logging.ContextWithLogger, username string) (*model.User, error) {
func parseTransactionMessage[T any](b []byte) (model.ITransactionMessage, error) {
var txn model.TransactionMessage
if err := json.Unmarshal(b, &txn); err != nil {
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
}
var itxn model.ITransactionMessage
switch txn.Status {
case "in progress":
itxn = new(model.TransactionInProgress)
case "completed":
itxn = new(model.TransactionCompleted[T])
case "aborted":
itxn = new(model.TransactionAborted)
default:
return nil, fmt.Errorf("Unrecognized transaction status '%s'", txn.Status)
}
if err := json.Unmarshal(b, itxn); err != nil {
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
}
return itxn, nil
}
func requestTransaction[T any](
c *Ceod, ctx model.CeodTransactionRequestContext,
method, urlPath string, body any,
) (<-chan model.ITransactionMessage, error) {
resp, err := c.getResp(ctx, method, urlPath, body)
if err != nil {
return nil, err
}
err = getRespError(ctx, resp)
if err != nil {
resp.Body.Close()
return nil, err
}
txnChan := make(chan model.ITransactionMessage)
go func() {
defer resp.Body.Close()
defer close(txnChan)
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
msg, err := parseTransactionMessage[T](scanner.Bytes())
if err != nil {
ctx.Log().Error(err)
return
}
select {
case txnChan <- msg:
continue
case <-ctx.CancelChan():
ctx.Log().Warn("Cancel chan closed, returning")
return
}
}
if err := scanner.Err(); err != nil {
ctx.Log().Error(err)
}
}()
return txnChan, nil
}
func (c *Ceod) GetUser(ctx model.CeodRequestContext, username string) (*model.User, error) {
user := &model.User{}
err := c.request(ctx, "GET", "/api/members/"+username, user)
return user, err
}
func (c *Ceod) GetUWUser(ctx logging.ContextWithLogger, username string) (*model.UWUser, error) {
func (c *Ceod) GetUWUser(ctx model.CeodRequestContext, username string) (*model.UWUser, error) {
uwUser := &model.UWUser{}
err := c.request(ctx, "GET", "/api/uwldap/"+username, uwUser)
return uwUser, err
}
func (c *Ceod) Pwreset(ctx logging.ContextWithLogger, username string) (string, error) {
func (c *Ceod) Pwreset(ctx model.CeodRequestContext, username string) (string, error) {
resp := &PwresetResponse{}
err := c.request(ctx, "POST", "/api/members/"+username+"/pwreset", resp)
if err != nil {
@ -100,3 +197,17 @@ func (c *Ceod) Pwreset(ctx logging.ContextWithLogger, username string) (string,
}
return resp.Password, nil
}
func (c *Ceod) AddUser(
ctx model.CeodTransactionRequestContext,
req *model.AddUserRequest,
) (<-chan model.ITransactionMessage, error) {
return requestTransaction[model.UserWithPassword](c, ctx, "POST", "/api/members", req)
}
func (c *Ceod) DeleteUser(
ctx model.CeodTransactionRequestContext,
username string,
) (<-chan model.ITransactionMessage, error) {
return requestTransaction[string](c, ctx, "DELETE", "/api/members/"+username, nil)
}

View File

@ -21,17 +21,17 @@ type MailService interface {
) error
}
type mailBase struct {
cfg *config.Config
type MailBackend interface {
Send(addr string, from string, to []string, msg []byte) error
}
type Mail struct {
mailBase
type mail struct {
cfg *config.Config
backend MailBackend
}
type MockMail struct {
mailBase
}
type prodMailBackend struct{}
type devMailBackend struct{}
//go:embed pwreset_email.txt
var emailTemplateFiles embed.FS
@ -56,18 +56,25 @@ func init() {
}
}
func NewMailService(cfg *config.Config) MailService {
if cfg.IsDev && cfg.ForcedEmailRecipient == "" {
return &MockMail{mailBase{cfg: cfg}}
}
return &Mail{mailBase{cfg: cfg}}
func NewMailServiceWithBackend(cfg *config.Config, backend MailBackend) MailService {
return &mail{cfg, backend}
}
func (m *mailBase) senderAddress() string {
func NewMailService(cfg *config.Config) MailService {
var backend MailBackend
if cfg.IsDev && cfg.ForcedEmailRecipient == "" {
backend = devMailBackend{}
} else {
backend = prodMailBackend{}
}
return NewMailServiceWithBackend(cfg, backend)
}
func (m *mail) senderAddress() string {
return "ceod+web@" + m.cfg.CSCDomain
}
func (m *mailBase) render(
func (m *mail) render(
recipientName, recipientAddress, tmplName string,
data map[string]any,
) ([]byte, error) {
@ -88,7 +95,7 @@ func (m *mailBase) render(
return buf.Bytes(), nil
}
func (m *Mail) Send(
func (m *mail) Send(
ctx logging.ContextWithLogger,
recipientName, recipientAddress, tmplName string,
data map[string]any,
@ -103,25 +110,20 @@ func (m *Mail) Send(
if m.cfg.ForcedEmailRecipient != "" {
realRecipientAddress = m.cfg.ForcedEmailRecipient
}
return m.backend.Send(m.cfg.MTA, m.senderAddress(), []string{realRecipientAddress}, msg)
}
func (m devMailBackend) Send(addr string, from string, to []string, msg []byte) error {
fmt.Printf("Would have sent this email:\n%s", string(msg))
return nil
}
func (m prodMailBackend) Send(addr string, from string, to []string, msg []byte) error {
return smtp.SendMail(
m.cfg.MTA,
addr,
nil, // auth
m.senderAddress(),
[]string{realRecipientAddress},
from,
to,
msg,
)
}
func (m *MockMail) Send(
ctx logging.ContextWithLogger,
recipientName, recipientAddress, tmplName string,
data map[string]any,
) error {
msg, err := m.render(recipientName, recipientAddress, tmplName, data)
if err != nil {
ctx.Log().Error(err)
return err
}
fmt.Printf("Would have sent this email:\n%s", msg)
return nil
}

View File

@ -8,4 +8,7 @@
our emails there. If you still have not received the confirmation email after
20 minutes, please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>.
</p>
<p>
<a href="..">Click here to return home</a>
</p>
{{ end }}

View File

@ -13,10 +13,36 @@ func (e *CeodError) Error() string {
return e.Message
}
type CeodRequestContext logging.ContextWithLogger
type CeodTransactionRequestContext interface {
logging.ContextWithLogger
CancelChan() <-chan struct{}
}
type CeodService interface {
GetUser(ctx logging.ContextWithLogger, username string) (*User, error)
GetUWUser(ctx logging.ContextWithLogger, username string) (*UWUser, error)
Pwreset(ctx logging.ContextWithLogger, username string) (string, error)
GetUser(ctx CeodRequestContext, username string) (*User, error)
GetUWUser(ctx CeodRequestContext, username string) (*UWUser, error)
Pwreset(ctx CeodRequestContext, username string) (string, error)
// On success, last message will be TransactionCompleted[UserWithPassword]
AddUser(ctx CeodTransactionRequestContext, req *AddUserRequest) (<-chan ITransactionMessage, error)
// On success, last message will be string "OK"
DeleteUser(ctx CeodTransactionRequestContext, username string) (<-chan ITransactionMessage, error)
}
type AddUserRequest struct {
// Full name
Cn string `json:"cn"`
// Last name
Sn string `json:"sn"`
// First name
GivenName string `json:"given_name"`
// Username
Uid string `json:"uid"`
Program string `json:"program"`
Terms int `json:"terms,omitempty"`
NonMemberTerms int `json:"non_member_terms,omitempty"`
ForwardingAddresses []string `json:"forwarding_addresses"`
}
type User struct {
@ -35,19 +61,19 @@ type User struct {
Groups []string `json:"groups"`
Program string `json:"program"`
// Terms will be absent for club reps
Terms []string `json:"terms"`
Terms []string `json:"terms,omitempty"`
// NonMemberTerms will be absent for general members
NonMemberTerms []string `json:"non_member_terms"`
NonMemberTerms []string `json:"non_member_terms,omitempty"`
MailLocalAddresses []string `json:"mail_local_addresses"`
// ForwardingAddresses will be absent if the client does not have
// sufficient permissions. It will be empty if the client's
// ~/.forward is missing or empty.
ForwardingAddresses []string `json:"forwarding_addresses"`
ForwardingAddresses []string `json:"forwarding_addresses,omitempty"`
IsClub bool `json:"is_club"`
IsClubRep bool `json:"is_club_rep"`
// ShadowExpire will be 1 if the user's account is expired, and
// absent otherwise.
ShadowExpire *int `json:"shadow_expire"`
ShadowExpire int `json:"shadow_expire,omitempty"`
}
type UWUser struct {
@ -55,9 +81,35 @@ type UWUser struct {
Uid string `json:"uid"`
MailLocalAddresses []string `json:"mail_local_addresses"`
// The following fields might be absent for alumni
Cn *string `json:"cn"`
GivenName *string `json:"given_name"`
Sn *string `json:"sn"`
Cn string `json:"cn,omitempty"`
GivenName string `json:"given_name,omitempty"`
Sn string `json:"sn,omitempty"`
// This field will always be absent for alumni
Program *string `json:"program"`
Program string `json:"program,omitempty"`
}
type ITransactionMessage interface{}
type TransactionMessage struct {
Status string `json:"status"`
}
type TransactionInProgress struct {
TransactionMessage // "in progress"
Operation string `json:"operation"`
}
type TransactionAborted struct {
TransactionMessage // "aborted"
Error string `json:"error"`
}
type TransactionCompleted[T any] struct {
TransactionMessage // "completed"
Result T `json:"result"`
}
type UserWithPassword struct {
User
Password string `json:"password"`
}

View File

@ -46,7 +46,10 @@ func (p *Proxy) ServeHTTP(wr http.ResponseWriter, req *http.Request) {
wr.Header()[k] = v
}
wr.WriteHeader(resp.StatusCode)
io.Copy(wr, resp.Body)
_, err = io.Copy(wr, resp.Body)
if err != nil {
panic(err)
}
}
func main() {

202
web/tests/common.go Normal file
View File

@ -0,0 +1,202 @@
package tests
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"sync"
"github.com/PuerkitoBio/goquery"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/api"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
// Make sure app_url is using "127.0.0.1", not "localhost", because that is
// what httptest.Server uses, and the cookie domain needs to match exactly
var Cfg = config.NewConfig("tests/test.json")
var Ceod = service.NewCeodService(Cfg)
var TestMail = TestMailBackend{}
var TestServer = httptest.NewServer(api.NewAPI(
Cfg, Ceod, service.NewMailServiceWithBackend(Cfg, &TestMail),
))
type MockEmail struct {
MTA string
From string
To []string
Msg []byte
}
type TestMailBackend struct {
SentEmails []MockEmail
mutex sync.Mutex
}
func (m *TestMailBackend) Send(addr string, from string, to []string, msg []byte) error {
msg = bytes.ReplaceAll(msg, []byte("\r\n"), []byte("\n"))
m.mutex.Lock()
m.SentEmails = append(m.SentEmails, MockEmail{addr, from, to, msg})
m.mutex.Unlock()
return nil
}
func (m *TestMailBackend) Reset() {
m.SentEmails = nil
}
var simpleUsernameCounter int
var simpleUsernameMutex sync.Mutex
func getNextSimpleUsername() string {
simpleUsernameMutex.Lock()
simpleUsernameCounter += 1
i := simpleUsernameCounter
simpleUsernameMutex.Unlock()
return fmt.Sprintf("ceodweb%d", i)
}
type TestLogger struct{}
var SharedTestLogger TestLogger
func (t *TestLogger) Debug(i ...interface{}) {}
func (t *TestLogger) Debugf(format string, args ...interface{}) {}
func (t *TestLogger) Info(i ...interface{}) {}
func (t *TestLogger) Infof(format string, args ...interface{}) {}
func (t *TestLogger) Warn(i ...interface{}) {
log.Println(i...)
}
func (t *TestLogger) Warnf(format string, args ...interface{}) {
log.Printf(format+"\n", args...)
}
func (t *TestLogger) Error(i ...interface{}) {
log.Println(i...)
}
func (t *TestLogger) Errorf(format string, args ...interface{}) {
log.Printf(format+"\n", args...)
}
type TestCeodTransactionRequestContext struct {
logger *TestLogger
cancelChan <-chan struct{}
}
func (t *TestCeodTransactionRequestContext) Log() logging.Logger {
return t.logger
}
func (t *TestCeodTransactionRequestContext) CancelChan() <-chan struct{} {
return t.cancelChan
}
func NewCeodTransactionRequestContext(cancelChan <-chan struct{}) model.CeodTransactionRequestContext {
return &TestCeodTransactionRequestContext{logger: &SharedTestLogger, cancelChan: cancelChan}
}
func handleTxn[T any](txnChan <-chan model.ITransactionMessage, err error) *T {
if err != nil {
panic(err)
}
for msg := range txnChan {
if _, ok := msg.(*model.TransactionInProgress); ok {
// ignore
} else if abortedMsg, ok := msg.(*model.TransactionAborted); ok {
panic(abortedMsg.Error)
} else if completedMsg, ok := msg.(*model.TransactionCompleted[T]); ok {
return &completedMsg.Result
} else {
panic(fmt.Errorf("Unrecognized message type: %+v", msg))
}
}
panic("Did not receive completed transaction message")
}
func AddSimpleUser() *model.UserWithPassword {
username := getNextSimpleUsername()
cancelChan := make(chan struct{})
defer close(cancelChan)
ctx := NewCeodTransactionRequestContext(cancelChan)
req := model.AddUserRequest{
Uid: username,
Cn: "John Doe",
GivenName: "John",
Sn: "Doe",
Program: "MAT/Mathematics Computer Science",
Terms: 1,
ForwardingAddresses: []string{username + "@" + Cfg.UWDomain},
}
return handleTxn[model.UserWithPassword](Ceod.AddUser(ctx, &req))
}
func DeleteUser(username string) {
cancelChan := make(chan struct{})
defer close(cancelChan)
ctx := NewCeodTransactionRequestContext(cancelChan)
result := *handleTxn[string](Ceod.DeleteUser(ctx, username))
if result != "OK" {
panic(fmt.Errorf("Result was not 'OK': '%s'", result))
}
}
type TestClient struct {
User *model.UserWithPassword
client http.Client
}
func NewTestClientFromUser(user *model.UserWithPassword) *TestClient {
return &TestClient{User: user, client: http.Client{}}
}
func NewTestClient() *TestClient {
jar, err := cookiejar.New(nil)
if err != nil {
panic(err)
}
return &TestClient{
User: AddSimpleUser(),
client: http.Client{Jar: jar},
}
}
// Destroy must be called if the client was created with NewTestClient()
func (c *TestClient) Destroy() {
DeleteUser(c.User.Uid)
}
func (c *TestClient) request(method, urlPath string, formData url.Values) (*goquery.Document, error) {
var bodyReader io.Reader
if formData != nil {
bodyReader = strings.NewReader(formData.Encode())
}
r, err := http.NewRequest(method, TestServer.URL+urlPath, bodyReader)
if err != nil {
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
}
if bodyReader != nil {
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
r.Header.Set("X-CSC-ADFS-Username", c.User.Uid)
r.Header.Set("X-CSC-ADFS-FirstName", c.User.GivenName)
resp, err := c.client.Do(r)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return goquery.NewDocumentFromReader(resp.Body)
}
func (c *TestClient) Get(urlPath string) (*goquery.Document, error) {
return c.request("GET", urlPath, nil)
}
func (c *TestClient) Post(urlPath string, formData url.Values) (*goquery.Document, error) {
return c.request("POST", urlPath, formData)
}

48
web/tests/pwreset_test.go Normal file
View File

@ -0,0 +1,48 @@
package tests
import (
"fmt"
"net/url"
"regexp"
"testing"
"github.com/stretchr/testify/require"
)
func TestPwreset(t *testing.T) {
client := NewTestClient()
defer client.Destroy()
doc, err := client.Get("/pwreset")
require.Nil(t, err)
inputs := doc.Find("form input")
csrfInput := inputs.Filter(`[name="_csrf"]`)
require.Equal(t, 1, csrfInput.Length())
csrfToken, ok := csrfInput.Attr("value")
require.True(t, ok)
submitInput := inputs.Filter(`[type="submit"]`)
require.Equal(t, 1, submitInput.Length())
submitInputValue, ok := submitInput.Attr("value")
require.True(t, ok)
require.Equal(t, "Reset my password", submitInputValue)
defer TestMail.Reset()
doc, err = client.Post("/pwreset", url.Values{"_csrf": {csrfToken}})
require.Nil(t, err)
para := doc.Find("p").First()
uwEmail := client.User.ForwardingAddresses[0]
require.Contains(
t,
para.Text(),
fmt.Sprintf("A new temporary password was sent to %s.", uwEmail),
)
require.Len(t, TestMail.SentEmails, 1)
emailMsg := &TestMail.SentEmails[0]
require.Equal(t, "ceod+web@csclub.internal", emailMsg.From)
require.Equal(t, []string{uwEmail}, emailMsg.To)
matched, err := regexp.Match("(?m)^Subject: Computer Science Club Password Reset$", emailMsg.Msg)
require.Nil(t, err)
require.True(t, matched)
emailBodyStr := string(emailMsg.Msg)
require.Contains(t, emailBodyStr, "Your new temporary CSC password is:\n\n")
}

7
web/tests/test.json Normal file
View File

@ -0,0 +1,7 @@
{
"app_url": "http://127.0.0.1",
"socket_path": "app.sock",
"csc_domain": "csclub.internal",
"uw_domain": "uwaterloo.internal",
"dev": true
}