Files
Felis/internal/api/audit_test.go

178 lines
6.9 KiB
Go

package api
import (
"errors"
"net/http"
"strings"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
"felis.lolicon.best/internal/metrics"
)
// Audit attribution: a row names the acting account by id and never by an
// address the caller merely asserted; refused sign-ins, throttling and logouts
// leave rows; a failed write is counted instead of vanishing.
func TestAuditActorIgnoresUnverifiedEmail(t *testing.T) {
cases := []struct {
name string
p *Principal
want string
}{
{"verified session email", &Principal{UserID: "u1", Username: "alice", Email: "[email protected]", EmailVerified: true, ViaSession: true}, "[email protected]"},
{"unverified session email", &Principal{UserID: "u2", Username: "mallory", Email: "[email protected]", ViaSession: true}, "mallory"},
{"access jwt email", &Principal{UserID: "sub", Email: "[email protected]"}, "[email protected]"},
{"no email", &Principal{UserID: "u3", Username: "bob", ViaSession: true}, "bob"},
{"id only", &Principal{UserID: "u4", ViaSession: true}, "u4"},
{"nobody", nil, anonymousActor},
}
for _, c := range cases {
if got := auditActor(c.p); got != c.want {
t.Errorf("%s: auditActor = %q, want %q", c.name, got, c.want)
}
}
}
// A player who sets their address to the owner's still signs every row as
// themselves, by username and by id.
func TestAuditCannotBeSignedWithAnotherPersonsEmail(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["owner"] = &StaffUser{ID: "u1", Username: "owner", Email: "[email protected]", Role: "owner", EmailVerified: true}
repo.staff["mallory"] = &StaffUser{ID: "u2", Username: "mallory", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.sessions[hashCookie("tok")] = &fakeSession{userID: "u2", expiresAt: frozenNow.Add(time.Hour), reauthAt: frozenNow}
api := newTestAPI(repo, newFakeCluster())
api.External = SessionAuth{Repo: repo, RootDomain: testRoot, Now: api.now}
api.ClientIPHeader = "CF-Connecting-IP"
eh := api.ExternalHandler()
hdr := map[string]string{
"Content-Type": "application/json", "Cookie": sessionCookieName + "=tok",
"CF-Connecting-IP": "203.0.113.5", "User-Agent": "probe/1.0",
}
for _, email := range []string{"[email protected]", "[email protected]"} {
if w := do(eh, "POST", "/api/v1/account/email", `{"email":"`+email+`"}`, hdr); w.Code != http.StatusOK {
t.Fatalf("set email = %d (%s)", w.Code, w.Body.String())
}
}
// The first write ran while the address was still verified; the second
// ran with the owner's address set and unverified.
last := repo.audits[len(repo.audits)-1]
if last.Actor != "mallory" || last.ActorUserID != "u2" {
t.Fatalf("audit after spoofing = actor %q user %q, want mallory/u2", last.Actor, last.ActorUserID)
}
if last.ClientIP != "203.0.113.5" || last.UserAgent != "probe/1.0" || last.RequestID == "" {
t.Fatalf("audit request detail = %+v", last)
}
}
func TestSignInFailuresAreAuditedAndCounted(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
noAccount := metrics.AuthFailuresTotal.WithLabelValues("login_email", "no_account")
badCode := metrics.AuthFailuresTotal.WithLabelValues("login_email", "bad_code")
n0, b0 := testutil.ToFloat64(noAccount), testutil.ToFloat64(badCode)
do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, jsonHeader)
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start = %d", w.Code)
}
wrong := "000000"
if mailer.code == wrong {
wrong = "111111"
}
do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"`+wrong+`"}`, jsonHeader)
if got := testutil.ToFloat64(noAccount) - n0; got != 1 {
t.Errorf("no_account failures counted %v, want 1", got)
}
if got := testutil.ToFloat64(badCode) - b0; got != 1 {
t.Errorf("bad_code failures counted %v, want 1", got)
}
var failed []AuditEntry
for _, e := range repo.audits {
if e.Action == "auth.login_email.failed" {
failed = append(failed, e)
}
}
if len(failed) != 2 {
t.Fatalf("failure audits = %+v, want 2", failed)
}
if failed[0].Actor != anonymousActor || failed[0].ActorUserID != "" || !strings.Contains(string(failed[0].Payload), "no_account") {
t.Errorf("unknown-address failure = %+v", failed[0])
}
if failed[1].Actor != "player" || failed[1].ActorUserID != "u1" || !strings.Contains(string(failed[1].Payload), "bad_code") {
t.Errorf("wrong-code failure = %+v", failed[1])
}
}
func TestThrottleAuditsOncePerEpisode(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
api.AuthDoorLimit = RateLimit{Burst: 1, PerMinute: 1}
clock := time.Unix(1_700_000_000, 0)
api.Now = func() time.Time { return clock }
eh := api.ExternalHandler()
options := func() int {
return do(eh, "POST", "/api/v1/auth/options", `{"email":"[email protected]"}`, jsonHeader).Code
}
throttled := func() int {
n := 0
for _, e := range repo.audits {
if e.Action == "auth.rate_limited" {
n++
}
}
return n
}
options()
for i := 0; i < 5; i++ {
if c := options(); c != http.StatusTooManyRequests {
t.Fatalf("call %d = %d, want 429", i+2, c)
}
}
if n := throttled(); n != 1 {
t.Fatalf("5 refusals left %d audit rows, want 1", n)
}
clock = clock.Add(time.Minute)
options()
options()
if n := throttled(); n != 2 {
t.Fatalf("a second episode left %d rows in total, want 2", n)
}
}
func TestAuditWriteFailureIsCountedNotFatal(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
repo.failAudit = errors.New("db down")
before := testutil.ToFloat64(metrics.AuditWriteFailuresTotal)
if w := do(api.ExternalHandler(), "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start with the audit store down = %d, want 202", w.Code)
}
if got := testutil.ToFloat64(metrics.AuditWriteFailuresTotal) - before; got != 1 {
t.Fatalf("audit write failures counted %v, want 1", got)
}
}
func TestLogoutAuditsTheLiveSession(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
repo.sessions[hashCookie("tok")] = &fakeSession{userID: "u1", expiresAt: time.Unix(1_700_000_000, 0).Add(time.Hour)}
eh := api.ExternalHandler()
cookie := map[string]string{"Content-Type": "application/json", "Cookie": sessionCookieName + "=tok"}
do(eh, "POST", "/api/v1/auth/logout", "", cookie)
do(eh, "POST", "/api/v1/auth/logout", "", cookie) // already revoked: no second row
if len(repo.audits) != 1 || repo.audits[0].Action != "auth.logout" || repo.audits[0].ActorUserID != "u1" || repo.audits[0].Actor != "player" {
t.Fatalf("logout audits = %+v, want one auth.logout by player/u1", repo.audits)
}
}
func TestTruncateUTF8KeepsRunesWhole(t *testing.T) {
if got := truncateUTF8("ab\xffc", 10); got != "ab�c" {
t.Errorf("invalid byte = %q", got)
}
if got := truncateUTF8("猫猫", 4); got != "猫" {
t.Errorf("cut mid-rune = %q, want 猫", got)
}
}