178 lines
6.9 KiB
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: time.Unix(1_700_000_000, 0).Add(time.Hour)}
|
|
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)
|
|
}
|
|
}
|