Implement verified email registration and queued TLS delivery
This commit is contained in:
@@ -5,3 +5,10 @@ GOOGLE_CLIENT_SECRET=replace-me
|
||||
FOUNDRY_PROJECTS='{"notes":"http://localhost:3000/auth/callback","board":"http://localhost:3001/auth/callback"}'
|
||||
HOST=127.0.0.1
|
||||
PORT=8080
|
||||
|
||||
# Optional email verification through TLS SMTP.
|
||||
SMTP_HOST=smtpdm.aliyun.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=noreply@notice.entroia.com
|
||||
SMTP_PASSWORD=replace-me
|
||||
SMTP_FROM=noreply@notice.entroia.com
|
||||
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
@@ -46,7 +47,12 @@ func main() {
|
||||
}
|
||||
db := stdlib.OpenDB(*config)
|
||||
defer db.Close()
|
||||
handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret)
|
||||
email, err := auth.EmailConfigFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go email.Run(context.Background(), db)
|
||||
handler, err := auth.New(db, callbacks, origin.ResolveReference(&url.URL{Path: "/auth/google/callback"}).String(), id, secret, email)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ CREATE TABLE email_outbox (
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
available_at timestamptz NOT NULL DEFAULT now(),
|
||||
lease_until timestamptz,
|
||||
lease_id uuid,
|
||||
expires_at timestamptz NOT NULL,
|
||||
sent_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
+5
-1
@@ -44,6 +44,10 @@ docker network inspect "$network" >/dev/null
|
||||
release=$(mktemp -d "$foundry/release.XXXXXXXX")
|
||||
install -m 755 "$1" "$release/foundry"
|
||||
export HOST=0.0.0.0 PORT=8080 PUBLIC_ORIGIN="$origin"
|
||||
mail_env=()
|
||||
if [[ -s "$foundry/mail.env" ]]; then
|
||||
mail_env=(--env-file "$foundry/mail.env")
|
||||
fi
|
||||
codex_mounts=()
|
||||
codex_dir=$foundry/codex
|
||||
if [[ -d "$codex_dir" ]]; then
|
||||
@@ -64,7 +68,7 @@ if [[ -d "$codex_dir" ]]; then
|
||||
)
|
||||
fi
|
||||
|
||||
docker run -d --name "$candidate" "${codex_mounts[@]}" --network "$network" \
|
||||
docker run -d --name "$candidate" "${codex_mounts[@]}" "${mail_env[@]}" --network "$network" \
|
||||
--env-file "$foundry/database.env" -e HOST -e PORT -e PUBLIC_ORIGIN \
|
||||
-e GOOGLE_CLIENT_ID -e GOOGLE_CLIENT_SECRET -e FOUNDRY_PROJECTS -e FOUNDRY_ONBOARDING \
|
||||
--user 1000:1000 --cap-drop ALL --security-opt no-new-privileges \
|
||||
|
||||
+13
-3
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -26,10 +27,17 @@ type service struct {
|
||||
projects map[string]*url.URL
|
||||
redirectURI, clientID, secret string
|
||||
google *http.Client
|
||||
email *EmailConfig
|
||||
passwordSlots chan struct{}
|
||||
dummyPassword []byte
|
||||
}
|
||||
|
||||
func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string) (http.Handler, error) {
|
||||
s := &service{db: db, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret,
|
||||
func New(db *sql.DB, callbacks map[string]string, redirectURI, id, secret string, email *EmailConfig) (http.Handler, error) {
|
||||
dummy, err := bcrypt.GenerateFromPassword([]byte("foundry-invalid-login-placeholder"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &service{db: db, email: email, passwordSlots: make(chan struct{}, 4), dummyPassword: dummy, projects: make(map[string]*url.URL), redirectURI: redirectURI, clientID: id, secret: secret,
|
||||
google: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
for name, raw := range callbacks {
|
||||
u, err := url.Parse(raw)
|
||||
@@ -120,8 +128,10 @@ func (s *service) project(w http.ResponseWriter, r *http.Request) error {
|
||||
switch {
|
||||
case r.Method == http.MethodOptions:
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Foundry-Code, X-Foundry-Verifier")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Foundry-Code, X-Foundry-Verifier")
|
||||
reply(w, 204, nil)
|
||||
case r.Method == http.MethodPost && (action == "register" || action == "verify-email" || action == "login"):
|
||||
return s.emailAction(w, r, project, action)
|
||||
case r.Method == http.MethodPost && action == "exchange":
|
||||
return s.exchange(w, r, project)
|
||||
case r.Method == http.MethodGet && action == "me":
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func emailAddress(value string) (string, bool) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
address, err := mail.ParseAddress(value)
|
||||
return value, err == nil && address.Address == value && len(value) <= 254
|
||||
}
|
||||
func emailBody(w http.ResponseWriter, r *http.Request, input any) bool {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF {
|
||||
_ = reject(w, 400, "invalid_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *service) rateLimit(ctx context.Context, key string, limit int) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO auth_rate_limits(key_hash,attempts,expires_at) VALUES($1,1,now()+interval '15 minutes')
|
||||
ON CONFLICT(key_hash) DO UPDATE SET
|
||||
attempts=CASE WHEN auth_rate_limits.expires_at<=now() THEN 1 ELSE auth_rate_limits.attempts+1 END,
|
||||
expires_at=CASE WHEN auth_rate_limits.expires_at<=now() THEN now()+interval '15 minutes' ELSE auth_rate_limits.expires_at END
|
||||
RETURNING attempts
|
||||
`, digest(key)).Scan(&count)
|
||||
return count <= limit, err
|
||||
}
|
||||
|
||||
func (s *service) emailAction(w http.ResponseWriter, r *http.Request, project, action string) error {
|
||||
if action == "verify-email" {
|
||||
return s.verifyEmail(w, r, project)
|
||||
}
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !emailBody(w, r, &input) {
|
||||
return nil
|
||||
}
|
||||
email, valid := emailAddress(input.Email)
|
||||
if !valid || len(input.Password) < 12 || len(input.Password) > 72 {
|
||||
return reject(w, 400, "invalid_credentials_format")
|
||||
}
|
||||
limit := 10
|
||||
if action == "register" {
|
||||
if s.email == nil {
|
||||
return reject(w, 503, "email_not_configured")
|
||||
}
|
||||
limit = 3
|
||||
}
|
||||
allowed, err := s.rateLimit(r.Context(), action+":"+project+":"+email, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
w.Header().Set("Retry-After", "900")
|
||||
return reject(w, 429, "rate_limited")
|
||||
}
|
||||
select {
|
||||
case s.passwordSlots <- struct{}{}:
|
||||
defer func() { <-s.passwordSlots }()
|
||||
default:
|
||||
w.Header().Set("Retry-After", "2")
|
||||
return reject(w, 429, "auth_busy")
|
||||
}
|
||||
if action == "register" {
|
||||
return s.registerEmail(w, r, project, email, input.Password)
|
||||
}
|
||||
var account, hash string
|
||||
err = s.db.QueryRowContext(r.Context(), "SELECT account_id::text,password_hash FROM email_credentials WHERE project=$1 AND email=$2", project, email).Scan(&account, &hash)
|
||||
missing := errors.Is(err, sql.ErrNoRows)
|
||||
if err != nil && !missing {
|
||||
return err
|
||||
}
|
||||
passwordHash := []byte(hash)
|
||||
if missing {
|
||||
passwordHash = s.dummyPassword
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword(passwordHash, []byte(input.Password)) != nil || missing {
|
||||
return reject(w, 401, "invalid_credentials")
|
||||
}
|
||||
session := token()
|
||||
_, err = s.db.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 200, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) registerEmail(w http.ResponseWriter, r *http.Request, project, email, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
verification := token()
|
||||
result, err := tx.ExecContext(r.Context(), `
|
||||
INSERT INTO email_registrations(project,email,password_hash,token_hash,expires_at)
|
||||
SELECT $1,$2,$3,$4,now()+interval '30 minutes'
|
||||
WHERE NOT EXISTS(SELECT 1 FROM email_credentials WHERE project=$1 AND email=$2)
|
||||
ON CONFLICT(project,email) DO UPDATE SET password_hash=EXCLUDED.password_hash,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at
|
||||
`, project, email, string(hash), digest(verification))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
destination := *s.projects[project]
|
||||
destination.Fragment = "verify_email=" + verification
|
||||
body := "Confirm your email address for " + project + ".\r\n\r\n" + destination.String() + "\r\n\r\nThis link expires in 30 minutes. If you did not register, ignore this email."
|
||||
_, err = tx.ExecContext(r.Context(), `
|
||||
INSERT INTO email_outbox(project,recipient,subject,body,expires_at) VALUES($1,$2,'Verify your email', $3, now()+interval '30 minutes')
|
||||
`, project, email, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 202, payload{"status": "verification_requested"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) verifyEmail(w http.ResponseWriter, r *http.Request, project string) error {
|
||||
var input struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if !emailBody(w, r, &input) {
|
||||
return nil
|
||||
}
|
||||
if !tokenPattern.MatchString(input.Token) {
|
||||
return reject(w, 400, "invalid_verification")
|
||||
}
|
||||
allowed, err := s.rateLimit(r.Context(), "verify:"+project+":"+digest(input.Token), 10)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return reject(w, 429, "rate_limited")
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var email, hash string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
DELETE FROM email_registrations WHERE project=$1 AND token_hash=$2 AND expires_at>now() RETURNING email,password_hash
|
||||
`, project, digest(input.Token)).Scan(&email, &hash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return reject(w, 400, "invalid_verification")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var account string
|
||||
err = tx.QueryRowContext(r.Context(), `
|
||||
WITH identity AS(SELECT gen_random_uuid() AS id)
|
||||
INSERT INTO project_accounts(project,id,email,username)
|
||||
SELECT $1,id,$2,
|
||||
left(COALESCE(NULLIF(regexp_replace(split_part($2,'@',1),'[^a-z0-9]','','g'),''),'user'),7)
|
||||
|| '_' || replace(id::text,'-','') FROM identity RETURNING id::text
|
||||
`, project, email).Scan(&account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), "INSERT INTO email_credentials(project,email,account_id,password_hash) VALUES($1,$2,$3,$4)", project, email, account, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session := token()
|
||||
_, err = tx.ExecContext(r.Context(), "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '8 hours')", digest(session), project, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 201, payload{"access_token": session, "token_type": "Bearer", "expires_in": 28800})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EmailConfig struct {
|
||||
Host, Port, Username, Password, From string
|
||||
}
|
||||
|
||||
func EmailConfigFromEnv() (*EmailConfig, error) {
|
||||
c := &EmailConfig{Host: os.Getenv("SMTP_HOST"), Port: os.Getenv("SMTP_PORT"), Username: os.Getenv("SMTP_USERNAME"), Password: os.Getenv("SMTP_PASSWORD"), From: os.Getenv("SMTP_FROM")}
|
||||
if c.Host == "" && c.Port == "" && c.Username == "" && c.Password == "" && c.From == "" {
|
||||
return nil, nil
|
||||
}
|
||||
address, err := mail.ParseAddress(c.From)
|
||||
if c.Host == "" || c.Port != "465" || c.Username == "" || c.Password == "" || err != nil || address.Address != c.From || strings.ContainsAny(c.Host+c.Username+c.From, "\r\n") {
|
||||
return nil, errors.New("invalid SMTP configuration; TLS port 465 and a plain sender address are required")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *EmailConfig) send(recipient, subject, body string) error {
|
||||
if strings.ContainsAny(recipient+subject, "\r\n") {
|
||||
return errors.New("invalid mail headers")
|
||||
}
|
||||
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", net.JoinHostPort(c.Host, c.Port), &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
_ = connection.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
client, err := smtp.NewClient(connection, c.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if err = client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = client.Mail(c.From); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = client.Rcpt(recipient); err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", c.From, recipient, subject, body)
|
||||
if _, err = writer.Write([]byte(message)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func (c *EmailConfig) Run(ctx context.Context, db *sql.DB) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.deliver(ctx, db); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("email delivery failed (%T)", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *EmailConfig) deliver(ctx context.Context, db *sql.DB) error {
|
||||
var id, lease, recipient, subject, body string
|
||||
var attempts int
|
||||
err := db.QueryRowContext(ctx, `
|
||||
UPDATE email_outbox SET lease_id=gen_random_uuid(),lease_until=now()+interval '2 minutes',attempts=attempts+1
|
||||
WHERE id=(SELECT id FROM email_outbox WHERE sent_at IS NULL AND expires_at>now() AND attempts<10
|
||||
AND available_at<=now() AND (lease_until IS NULL OR lease_until<=now())
|
||||
ORDER BY available_at,id FOR UPDATE SKIP LOCKED LIMIT 1)
|
||||
RETURNING id::text,lease_id::text,recipient,subject,body,attempts
|
||||
`).Scan(&id, &lease, &recipient, &subject, &body, &attempts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sendErr := c.send(recipient, subject, body)
|
||||
if sendErr == nil {
|
||||
_, err = db.ExecContext(ctx, "UPDATE email_outbox SET sent_at=now(),body='',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease)
|
||||
return err
|
||||
}
|
||||
delay := 30 * (1 << min(attempts, 7))
|
||||
_, err = db.ExecContext(ctx, "UPDATE email_outbox SET available_at=now()+$3*interval '1 second',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease, delay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sendErr
|
||||
}
|
||||
@@ -7,10 +7,13 @@ import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
@@ -29,7 +32,7 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var raw [12]byte
|
||||
rand.Read(raw[:])
|
||||
project := "test-" + base64.RawURLEncoding.EncodeToString(raw[:])
|
||||
project := "test-" + hex.EncodeToString(raw[:])
|
||||
accounts := []string{}
|
||||
tokens := []string{}
|
||||
for range 3 {
|
||||
@@ -52,9 +55,15 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
defer func() {
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project)
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project)
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM email_outbox WHERE project=$1", project)
|
||||
}()
|
||||
callbacks := map[string]string{project: "https://app.example.com/auth/callback"}
|
||||
mux := http.NewServeMux()
|
||||
authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux.Handle("/", authHandler)
|
||||
Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{
|
||||
project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}},
|
||||
})
|
||||
@@ -110,6 +119,26 @@ func TestOnboardingAndSharing(t *testing.T) {
|
||||
contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201)
|
||||
call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200)
|
||||
call("GET", workspacePath, tokens[2], nil, 404)
|
||||
email := "fixture@example.invalid"
|
||||
password := "a-long-fixture-password"
|
||||
call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202)
|
||||
call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 401)
|
||||
var body string
|
||||
if err := db.QueryRowContext(ctx, "SELECT body FROM email_outbox WHERE project=$1 AND recipient=$2 ORDER BY created_at DESC LIMIT 1", project, email).Scan(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body)
|
||||
if len(match) != 2 {
|
||||
t.Fatal("missing email verification link")
|
||||
}
|
||||
verified := call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 201)
|
||||
call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 400)
|
||||
call("GET", base+"/profile", verified["access_token"].(string), nil, 200)
|
||||
call("POST", base+"/auth/login", "", response{"email": email, "password": "wrong-long-password"}, 401)
|
||||
loggedIn := call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 200)
|
||||
call("POST", base+"/auth/logout", loggedIn["access_token"].(string), nil, 204)
|
||||
call("GET", base+"/profile", loggedIn["access_token"].(string), nil, 401)
|
||||
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("material grant missing %v", err)
|
||||
|
||||
Reference in New Issue
Block a user