Files
mcias/test/e2e/e2e_test.go
Kyle Isom e63d9863b6 Implement dashboard and audit log templates, add paginated audit log support
- Added `web/templates/{dashboard,audit,base,accounts,account_detail}.html` for a consistent UI.
- Implemented new audit log endpoint (`GET /v1/audit`) with filtering and pagination via `ListAuditEventsPaged`.
- Extended `AuditQueryParams`, added `AuditEventView` for joined actor/target usernames.
- Updated configuration (`goimports` preference), linting rules, and E2E tests.
- No logic changes to existing APIs.
2026-03-11 14:05:08 -07:00

585 lines
17 KiB
Go

// Package e2e contains end-to-end tests for the MCIAS server.
//
// These tests start a real httptest.Server (not TLS; mciassrv adds TLS at the
// listener level, but for e2e we use net/http/httptest which wraps any handler)
// and exercise complete user flows: login, token renewal, revocation, admin
// account management, TOTP enrolment, and system account token issuance.
//
// Security attack scenarios tested here:
// - alg confusion (HS256 token accepted by EdDSA server → must reject)
// - alg:none (crafted unsigned token → must reject)
// - revoked token reuse → must reject
// - expired token → must reject
// - non-admin calling admin endpoint → must return 403
package e2e
import (
"bytes"
"crypto/ed25519"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/server"
"git.wntrmute.dev/kyle/mcias/internal/token"
)
const e2eIssuer = "https://auth.e2e.test"
// testEnv holds all the state for one e2e test run.
type testEnv struct {
server *httptest.Server
srv *server.Server
db *db.DB
privKey ed25519.PrivateKey
pubKey ed25519.PublicKey
}
// newTestEnv spins up an httptest.Server backed by a fresh in-memory DB.
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate db: %v", err)
}
masterKey := make([]byte, 32)
if _, err := rand.Read(masterKey); err != nil {
t.Fatalf("generate master key: %v", err)
}
cfg := config.NewTestConfig(e2eIssuer)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := server.New(database, cfg, priv, pub, masterKey, logger)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(func() {
ts.Close()
_ = database.Close()
})
return &testEnv{
server: ts,
srv: srv,
db: database,
privKey: priv,
pubKey: pub,
}
}
// createAccount creates a human account directly in the DB.
func (e *testEnv) createAccount(t *testing.T, username string) *model.Account {
t.Helper()
hash, err := auth.HashPassword("testpass123", auth.DefaultArgonParams())
if err != nil {
t.Fatalf("hash: %v", err)
}
acct, err := e.db.CreateAccount(username, model.AccountTypeHuman, hash)
if err != nil {
t.Fatalf("create account %q: %v", username, err)
}
return acct
}
// createAdminAccount creates a human account with the admin role.
func (e *testEnv) createAdminAccount(t *testing.T, username string) (*model.Account, string) {
t.Helper()
acct := e.createAccount(t, username)
if err := e.db.GrantRole(acct.ID, "admin", nil); err != nil {
t.Fatalf("grant admin: %v", err)
}
// Issue and track an admin token.
tokenStr, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, []string{"admin"}, time.Hour)
if err != nil {
t.Fatalf("issue token: %v", err)
}
if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("track token: %v", err)
}
return acct, tokenStr
}
// do performs an HTTP request against the test server.
func (e *testEnv) do(t *testing.T, method, path string, body interface{}, bearerToken string) *http.Response {
t.Helper()
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
r = bytes.NewReader(b)
}
req, err := http.NewRequest(method, e.server.URL+path, r)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
resp, err := e.server.Client().Do(req)
if err != nil {
t.Fatalf("do request %s %s: %v", method, path, err)
}
return resp
}
// decodeJSON decodes the response body into v and closes the body.
func decodeJSON(t *testing.T, resp *http.Response, v interface{}) {
t.Helper()
defer func() { _ = resp.Body.Close() }()
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
t.Fatalf("decode JSON: %v", err)
}
}
// mustStatus fails the test if resp.StatusCode != want.
func mustStatus(t *testing.T, resp *http.Response, want int) {
t.Helper()
if resp.StatusCode != want {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
t.Fatalf("status = %d, want %d; body: %s", resp.StatusCode, want, body)
}
}
// ---- E2E Tests ----
// TestE2ELoginLogoutFlow verifies the complete login → validate → logout → invalidate cycle.
func TestE2ELoginLogoutFlow(t *testing.T) {
e := newTestEnv(t)
e.createAccount(t, "alice")
// Login.
resp := e.do(t, "POST", "/v1/auth/login", map[string]string{
"username": "alice",
"password": "testpass123",
}, "")
mustStatus(t, resp, http.StatusOK)
var loginResp struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
decodeJSON(t, resp, &loginResp)
if loginResp.Token == "" {
t.Fatal("empty token in login response")
}
// Validate — should be valid.
resp2 := e.do(t, "POST", "/v1/token/validate", nil, loginResp.Token)
mustStatus(t, resp2, http.StatusOK)
var vr struct {
Valid bool `json:"valid"`
}
decodeJSON(t, resp2, &vr)
if !vr.Valid {
t.Fatal("expected valid=true after login")
}
// Logout.
resp3 := e.do(t, "POST", "/v1/auth/logout", nil, loginResp.Token)
mustStatus(t, resp3, http.StatusNoContent)
_ = resp3.Body.Close()
// Validate — should now be invalid (revoked).
resp4 := e.do(t, "POST", "/v1/token/validate", nil, loginResp.Token)
mustStatus(t, resp4, http.StatusOK)
var vr2 struct {
Valid bool `json:"valid"`
}
decodeJSON(t, resp4, &vr2)
if vr2.Valid {
t.Fatal("expected valid=false after logout")
}
}
// TestE2ETokenRenewal verifies that renewal returns a new token and revokes the old one.
func TestE2ETokenRenewal(t *testing.T) {
e := newTestEnv(t)
e.createAccount(t, "bob")
// Login.
resp := e.do(t, "POST", "/v1/auth/login", map[string]string{
"username": "bob",
"password": "testpass123",
}, "")
mustStatus(t, resp, http.StatusOK)
var lr struct {
Token string `json:"token"`
}
decodeJSON(t, resp, &lr)
oldToken := lr.Token
// Renew.
resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken)
mustStatus(t, resp2, http.StatusOK)
var nr struct {
Token string `json:"token"`
}
decodeJSON(t, resp2, &nr)
newToken := nr.Token
if newToken == "" || newToken == oldToken {
t.Fatal("renewal must return a distinct non-empty token")
}
// Old token should be invalid.
resp3 := e.do(t, "POST", "/v1/token/validate", nil, oldToken)
mustStatus(t, resp3, http.StatusOK)
var vr struct {
Valid bool `json:"valid"`
}
decodeJSON(t, resp3, &vr)
if vr.Valid {
t.Fatal("old token should be invalid after renewal")
}
// New token should be valid.
resp4 := e.do(t, "POST", "/v1/token/validate", nil, newToken)
mustStatus(t, resp4, http.StatusOK)
var vr2 struct {
Valid bool `json:"valid"`
}
decodeJSON(t, resp4, &vr2)
if !vr2.Valid {
t.Fatal("new token should be valid after renewal")
}
}
// TestE2EAdminAccountManagement verifies full admin account CRUD.
func TestE2EAdminAccountManagement(t *testing.T) {
e := newTestEnv(t)
_, adminToken := e.createAdminAccount(t, "admin")
// Create account.
resp := e.do(t, "POST", "/v1/accounts", map[string]string{
"username": "carol",
"password": "carolpass123",
"account_type": "human",
}, adminToken)
mustStatus(t, resp, http.StatusCreated)
var acctResp struct {
ID string `json:"id"`
Username string `json:"username"`
Status string `json:"status"`
}
decodeJSON(t, resp, &acctResp)
if acctResp.Username != "carol" {
t.Errorf("username = %q, want carol", acctResp.Username)
}
carolUUID := acctResp.ID
// Get account.
resp2 := e.do(t, "GET", "/v1/accounts/"+carolUUID, nil, adminToken)
mustStatus(t, resp2, http.StatusOK)
_ = resp2.Body.Close()
// Set roles.
resp3 := e.do(t, "PUT", "/v1/accounts/"+carolUUID+"/roles", map[string][]string{
"roles": {"reader"},
}, adminToken)
mustStatus(t, resp3, http.StatusNoContent)
_ = resp3.Body.Close()
// Get roles.
resp4 := e.do(t, "GET", "/v1/accounts/"+carolUUID+"/roles", nil, adminToken)
mustStatus(t, resp4, http.StatusOK)
var rolesResp struct {
Roles []string `json:"roles"`
}
decodeJSON(t, resp4, &rolesResp)
if len(rolesResp.Roles) != 1 || rolesResp.Roles[0] != "reader" {
t.Errorf("roles = %v, want [reader]", rolesResp.Roles)
}
// Delete account.
resp5 := e.do(t, "DELETE", "/v1/accounts/"+carolUUID, nil, adminToken)
mustStatus(t, resp5, http.StatusNoContent)
_ = resp5.Body.Close()
}
// TestE2ELoginCredentialsNeverInResponse verifies that no credential material
// appears in any response body across all endpoints.
func TestE2ELoginCredentialsNeverInResponse(t *testing.T) {
e := newTestEnv(t)
e.createAccount(t, "dave")
_, adminToken := e.createAdminAccount(t, "admin-dave")
credentialPatterns := []string{
"argon2id",
"password_hash",
"PasswordHash",
"totp_secret",
"TOTPSecret",
"signing_key",
}
endpoints := []struct {
method string
path string
body interface{}
token string
}{
{"POST", "/v1/auth/login", map[string]string{"username": "dave", "password": "testpass123"}, ""},
{"GET", "/v1/accounts", nil, adminToken},
{"GET", "/v1/keys/public", nil, ""},
{"GET", "/v1/health", nil, ""},
}
for _, ep := range endpoints {
resp := e.do(t, ep.method, ep.path, ep.body, ep.token)
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
bodyStr := string(body)
for _, pattern := range credentialPatterns {
if strings.Contains(bodyStr, pattern) {
t.Errorf("%s %s: response contains credential pattern %q", ep.method, ep.path, pattern)
}
}
}
}
// TestE2EUnauthorizedAccess verifies that unauthenticated and insufficient-role
// requests are properly rejected.
func TestE2EUnauthorizedAccess(t *testing.T) {
e := newTestEnv(t)
acct := e.createAccount(t, "eve")
// Issue a non-admin token for eve.
tokenStr, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, []string{"reader"}, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
// No token on admin endpoint → 401.
resp := e.do(t, "GET", "/v1/accounts", nil, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("no token: status = %d, want 401", resp.StatusCode)
}
_ = resp.Body.Close()
// Non-admin token on admin endpoint → 403.
resp2 := e.do(t, "GET", "/v1/accounts", nil, tokenStr)
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("non-admin: status = %d, want 403", resp2.StatusCode)
}
_ = resp2.Body.Close()
}
// TestE2EAlgConfusionAttack verifies that a token signed with HMAC-SHA256
// using the public key as the secret is rejected. This is the classic alg
// confusion attack against JWT libraries that don't validate the alg header.
//
// Security: The server's ValidateToken always checks alg == "EdDSA" before
// attempting signature verification. HS256 tokens must be rejected.
func TestE2EAlgConfusionAttack(t *testing.T) {
e := newTestEnv(t)
acct := e.createAccount(t, "frank")
_ = acct
// Craft an HS256 JWT using the server's public key as the HMAC secret.
// If the server doesn't check alg, it might accept this.
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(
`{"iss":%q,"sub":%q,"roles":["admin"],"jti":"attack","iat":%d,"exp":%d}`,
e2eIssuer, acct.UUID,
time.Now().Unix(),
time.Now().Add(time.Hour).Unix(),
)))
sigInput := header + "." + payload
mac := hmac.New(sha256.New, e.pubKey)
mac.Write([]byte(sigInput))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
craftedToken := sigInput + "." + sig
resp := e.do(t, "GET", "/v1/accounts", nil, craftedToken)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("alg confusion attack: status = %d, want 401", resp.StatusCode)
}
_ = resp.Body.Close()
}
// TestE2EAlgNoneAttack verifies that a token with alg:none is rejected.
//
// Security: The server's ValidateToken explicitly rejects alg:none before
// any processing. A crafted unsigned token must not grant access.
func TestE2EAlgNoneAttack(t *testing.T) {
e := newTestEnv(t)
acct := e.createAccount(t, "grace")
_ = acct
// Craft an alg:none JWT (no signature).
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(
`{"iss":%q,"sub":%q,"roles":["admin"],"jti":"none-attack","iat":%d,"exp":%d}`,
e2eIssuer, acct.UUID,
time.Now().Unix(),
time.Now().Add(time.Hour).Unix(),
)))
craftedToken := header + "." + payload + "."
resp := e.do(t, "GET", "/v1/accounts", nil, craftedToken)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("alg:none attack: status = %d, want 401", resp.StatusCode)
}
_ = resp.Body.Close()
}
// TestE2ERevokedTokenRejected verifies that a revoked token cannot be reused
// to access protected endpoints.
func TestE2ERevokedTokenRejected(t *testing.T) {
e := newTestEnv(t)
_, adminToken := e.createAdminAccount(t, "admin-revoke")
// Admin can list accounts.
resp := e.do(t, "GET", "/v1/accounts", nil, adminToken)
mustStatus(t, resp, http.StatusOK)
_ = resp.Body.Close()
// Logout revokes the admin token.
resp2 := e.do(t, "POST", "/v1/auth/logout", nil, adminToken)
mustStatus(t, resp2, http.StatusNoContent)
_ = resp2.Body.Close()
// Revoked token should no longer work.
resp3 := e.do(t, "GET", "/v1/accounts", nil, adminToken)
if resp3.StatusCode != http.StatusUnauthorized {
t.Errorf("revoked token: status = %d, want 401", resp3.StatusCode)
}
_ = resp3.Body.Close()
}
// TestE2ESystemAccountTokenIssuance verifies the system account token flow:
// create system account → admin issues token → token is valid.
func TestE2ESystemAccountTokenIssuance(t *testing.T) {
e := newTestEnv(t)
_, adminToken := e.createAdminAccount(t, "admin-sys")
// Create a system account.
resp := e.do(t, "POST", "/v1/accounts", map[string]string{
"username": "my-service",
"account_type": "system",
}, adminToken)
mustStatus(t, resp, http.StatusCreated)
var sysAcct struct {
ID string `json:"id"`
}
decodeJSON(t, resp, &sysAcct)
// Issue a service token.
resp2 := e.do(t, "POST", "/v1/token/issue", map[string]string{
"account_id": sysAcct.ID,
}, adminToken)
mustStatus(t, resp2, http.StatusOK)
var tokenResp struct {
Token string `json:"token"`
}
decodeJSON(t, resp2, &tokenResp)
if tokenResp.Token == "" {
t.Fatal("empty service token")
}
// The issued token should be valid.
resp3 := e.do(t, "POST", "/v1/token/validate", nil, tokenResp.Token)
mustStatus(t, resp3, http.StatusOK)
var vr struct {
Subject string `json:"sub"`
Valid bool `json:"valid"`
}
decodeJSON(t, resp3, &vr)
if !vr.Valid {
t.Fatal("issued service token should be valid")
}
if vr.Subject != sysAcct.ID {
t.Errorf("subject = %q, want %q", vr.Subject, sysAcct.ID)
}
}
// TestE2EWrongPassword verifies that wrong passwords are rejected and the
// response is indistinguishable from unknown-user responses (generic 401).
func TestE2EWrongPassword(t *testing.T) {
e := newTestEnv(t)
e.createAccount(t, "heidi")
// Wrong password.
resp := e.do(t, "POST", "/v1/auth/login", map[string]string{
"username": "heidi",
"password": "wrongpassword",
}, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("wrong password: status = %d, want 401", resp.StatusCode)
}
// Check that the error is generic, not leaking existence.
var errBody map[string]string
decodeJSON(t, resp, &errBody)
if strings.Contains(errBody["error"], "heidi") {
t.Error("error message leaks username")
}
}
// TestE2EUnknownUserSameResponseAsWrongPassword verifies that unknown users
// and wrong passwords return identical status codes and error codes to prevent
// user enumeration.
func TestE2EUnknownUserSameResponseAsWrongPassword(t *testing.T) {
e := newTestEnv(t)
e.createAccount(t, "ivan")
// Wrong password for known user.
resp1 := e.do(t, "POST", "/v1/auth/login", map[string]string{
"username": "ivan",
"password": "wrong",
}, "")
var err1 map[string]string
decodeJSON(t, resp1, &err1)
// Unknown user.
resp2 := e.do(t, "POST", "/v1/auth/login", map[string]string{
"username": "nobody-exists",
"password": "anything",
}, "")
var err2 map[string]string
decodeJSON(t, resp2, &err2)
// Both should return 401 with the same error code.
if resp1.StatusCode != http.StatusUnauthorized || resp2.StatusCode != http.StatusUnauthorized {
t.Errorf("status mismatch: known-wrong=%d, unknown=%d, both want 401",
resp1.StatusCode, resp2.StatusCode)
}
if err1["code"] != err2["code"] {
t.Errorf("error codes differ: known-wrong=%q, unknown=%q; must be identical",
err1["code"], err2["code"])
}
}