This repository has been archived on 2025-08-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
web-app-template/main_test.go
Tim Wundenberg 2b46a00a49
Some checks failed
Build Docker Image / Build-Docker-Image (push) Failing after 40s
feat(security): #328 delete old sessions change password [tbs]
2024-12-18 21:42:05 +01:00

427 lines
12 KiB
Go

package main
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"me-fit/service"
"me-fit/types"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"golang.org/x/net/html"
)
var (
httpClient = http.Client{
// Disable redirect following
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
port atomic.Int32
)
func TestIntegrationSecurityHeader(t *testing.T) {
t.Parallel()
t.Run("should keep caching for static content", func(t *testing.T) {
t.Parallel()
_, basePath, ctx := setupIntegrationTest(t)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/static/favicon.svg", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
cacheControl := resp.Header.Get("Cache-Control")
assert.Equal(t, "", cacheControl)
})
t.Run("should disable caching for dynamic content", func(t *testing.T) {
t.Parallel()
_, basePath, ctx := setupIntegrationTest(t)
req, err := http.NewRequestWithContext(ctx, "GET", basePath, nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
cacheControl := resp.Header.Get("Cache-Control")
assert.Equal(t, "no-cache, no-store, must-revalidate", cacheControl)
})
t.Run("should include security headers", func(t *testing.T) {
t.Parallel()
_, basePath, ctx := setupIntegrationTest(t)
req, err := http.NewRequestWithContext(ctx, "GET", basePath, nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
value := resp.Header.Get("X-Content-Type-Options")
assert.Equal(t, "nosniff", value)
value = resp.Header.Get("Access-Control-Allow-Origin")
assert.Equal(t, basePath, value)
value = resp.Header.Get("Access-Control-Allow-Methods")
assert.Equal(t, "GET, POST, DELETE", value)
value = resp.Header.Get("Content-Security-Policy")
assert.Equal(t, "default-src 'none'; "+
"script-src 'self' https://umami.me-fit.eu; "+
"connect-src 'self' https://umami.me-fit.eu; "+
"img-src 'self'; "+
"style-src 'self'; "+
"form-action 'self'; "+
"frame-ancestors 'none';", value)
value = resp.Header.Get("Cross-Origin-Resource-Policy")
assert.Equal(t, "same-origin", value)
value = resp.Header.Get("Cross-Origin-Opener-Policy")
assert.Equal(t, "same-origin", value)
value = resp.Header.Get("Cross-Origin-Embedder-Policy")
assert.Equal(t, "require-corp", value)
value = resp.Header.Get("Permissions-Policy")
assert.Equal(t, "geolocation=(), camera=(), microphone=(), interest-cohort=()", value)
value = resp.Header.Get("Referrer-Policy")
assert.Equal(t, "strict-origin-when-cross-origin", value)
value = resp.Header.Get("Strict-Transport-Security")
assert.Equal(t, "max-age=63072000; includeSubDomains; preload", value)
})
}
func TestIntegrationAuth(t *testing.T) {
t.Parallel()
t.Run("should return secure cookie on signin with generated csrf-token and session-id", func(t *testing.T) {
t.Parallel()
db, basePath, ctx := setupIntegrationTest(t)
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, uuid.New(), pass, []byte("salt"))
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/signin", nil)
assert.Nil(t, err)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
html, err := html.Parse(resp.Body)
assert.Nil(t, err)
csrfToken := findCsrfToken(html)
assert.NotEqual(t, "", csrfToken)
anonymousSession := findCookie(resp, "id")
assert.NotNil(t, anonymousSession)
formData := url.Values{
"email": {"mail@mail.de"},
"password": {"password"},
"csrf-token": {csrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/signin", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+anonymousSession.Value)
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
cookie := findCookie(resp, "id")
assert.NotNil(t, cookie)
assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite, "Cookie is not secure")
assert.True(t, cookie.HttpOnly, "Cookie is not secure")
assert.True(t, cookie.Secure, "Cookie is not secure")
})
t.Run("should change password and invalidate other sessions from user", func(t *testing.T) {
t.Parallel()
db, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
userIdOther := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
assert.Nil(t, err)
_, err = db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
assert.Nil(t, err)
_, err = db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES ("second", ?, datetime(), datetime("now", "+1 day"))`, userId)
assert.Nil(t, err)
_, err = db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES ("other", ?, datetime(), datetime("now", "+1 day"))`, userIdOther)
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/change-password", nil)
assert.Nil(t, err)
req.Header.Set("Cookie", "id="+sessionId)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
html, err := html.Parse(resp.Body)
assert.Nil(t, err)
csrfToken := findCsrfToken(html)
assert.NotEqual(t, "", csrfToken)
formData := url.Values{
"current-password": {"password"},
"new-password": {"MyNewSecurePassword1!"},
"csrf-token": {csrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+sessionId)
req.Header.Set("HX-Request", "true")
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var sessionIds []string
sessions, err := db.Query("SELECT session_id FROM session ORDER BY session_id")
assert.Nil(t, err)
for sessions.Next() {
var sessionId string
err = sessions.Scan(&sessionId)
assert.Nil(t, err)
sessionIds = append(sessionIds, sessionId)
}
assert.Equal(t, 2, len(sessionIds))
assert.Equal(t, "other", sessionIds[0])
assert.Equal(t, "session-id", sessionIds[1])
})
t.Run("should forget password and invalidate other sessions from user", func(t *testing.T) {
t.Parallel()
db, basePath, ctx := setupIntegrationTest(t)
userId := uuid.New()
pass := service.GetHashPassword("password", []byte("salt"))
_, err := db.Exec(`
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
sessionId := "session-id"
assert.Nil(t, err)
_, err = db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
assert.Nil(t, err)
_, err = db.Exec(`
INSERT INTO session (session_id, user_id, created_at, expires_at)
VALUES ("second", ?, datetime(), datetime("now", "+1 day"))`, userId)
assert.Nil(t, err)
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/change-password", nil)
assert.Nil(t, err)
req.Header.Set("Cookie", "id="+sessionId)
resp, err := httpClient.Do(req)
assert.Nil(t, err)
html, err := html.Parse(resp.Body)
assert.Nil(t, err)
csrfToken := findCsrfToken(html)
assert.NotEqual(t, "", csrfToken)
formData := url.Values{
"current-password": {"password"},
"new-password": {"MyNewSecurePassword1!"},
"csrf-token": {csrfToken},
}
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Cookie", "id="+sessionId)
req.Header.Set("HX-Request", "true")
resp, err = httpClient.Do(req)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var sessionIds []string
sessions, err := db.Query("SELECT session_id FROM session ORDER BY session_id")
assert.Nil(t, err)
for sessions.Next() {
var sessionId string
err = sessions.Scan(&sessionId)
assert.Nil(t, err)
sessionIds = append(sessionIds, sessionId)
}
assert.Equal(t, 2, len(sessionIds))
assert.Equal(t, "other", sessionIds[0])
assert.Equal(t, "session-id", sessionIds[1])
})
}
func findCookie(resp *http.Response, name string) *http.Cookie {
for _, cookie := range resp.Cookies() {
if cookie.Name == name {
return cookie
}
}
return nil
}
func setupIntegrationTest(t *testing.T) (db *sql.DB, basePath string, ctx context.Context) {
ctx, done := context.WithCancel(context.Background())
t.Cleanup(done)
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Could not open Database data.db: %v", err)
}
t.Cleanup(func() {
db.Close()
})
testPort := port.Add(1)
testPort += 1024
go run(ctx, db, getEnv(testPort))
basePath = "http://localhost:" + fmt.Sprint(testPort)
err = waitForReady(ctx, 5*time.Second, basePath, t)
assert.Nil(t, err)
return db, basePath, ctx
}
func getEnv(port int32) func(string) string {
return func(key string) string {
if key == "PORT" {
return fmt.Sprint(port)
} else if key == "SMTP_ENABLED" {
return "false"
} else if key == "PROMETHEUS_ENABLED" {
return "false"
} else if key == "BASE_URL" {
return "http://localhost:" + fmt.Sprint(port)
} else if key == "ENVIRONMENT" {
return "test"
} else {
return ""
}
}
}
// waitForReady calls the specified endpoint until it gets a 200
// response or until the context is cancelled or the timeout is
// reached.
func waitForReady(
ctx context.Context,
timeout time.Duration,
endpoint string,
t *testing.T,
) error {
client := http.Client{}
startTime := time.Now()
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
assert.Nil(t, err)
resp, err := client.Do(req)
if err == nil && resp.StatusCode == http.StatusOK {
resp.Body.Close()
return nil
} else if err == nil {
resp.Body.Close()
}
select {
case <-ctx.Done():
return ctx.Err()
default:
if time.Since(startTime) >= timeout {
t.Fatal("timeout reached while waiting for endpoint")
return types.ErrInternal
}
// wait a little while between checks
time.Sleep(250 * time.Millisecond)
}
}
}
func findCsrfToken(data *html.Node) string {
attr := getTokenAttribute(data)
if attr != nil {
return attr.Val
}
if data.FirstChild != nil {
if token := findCsrfToken(data.FirstChild); token != "" {
return token
}
}
if data.NextSibling != nil {
if token := findCsrfToken(data.NextSibling); token != "" {
return token
}
}
return ""
}
func getTokenAttribute(data *html.Node) *html.Attribute {
returnValue := false
for _, attr := range data.Attr {
if attr.Key == "name" && attr.Val == "csrf-token" {
returnValue = true
}
}
if !returnValue {
return nil
}
for _, attr := range data.Attr {
if attr.Key == "value" {
return &attr
}
}
return nil
}