bcc6b45515
Implements #23 per ADR-0002. - Discord authorization code grant (identify + guilds.members.read), form-encoded token exchange - Guild membership gate via the single-guild endpoint; optional DISCORD_REQUIRED_ROLE (empty default) - Owner Discord ID is the only identity allowed to sign in - Sessions are DB rows with opaque random ids; cookie carries only the id; expiry enforced; delete = revoke - HMAC session signing, derived key, and WEB_PASSWORD removed; no replacement signing secret - Login rate limiting preserved on the callback - Full flow tested through the real router against a local Discord stub (DISCORD_API_BASE) - Env: DISCORD_CLIENT_ID/_CLIENT_SECRET/_GUILD_ID/_REQUIRED_ROLE/_API_BASE/_REDIRECT_URI; docs updated go test ./... passes. Reviewed-on: #31 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
56 lines
1.4 KiB
Go
56 lines
1.4 KiB
Go
package web
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestOAuthStateSingleUse(t *testing.T) {
|
|
s := newOAuthStates()
|
|
s.put("st", time.Now().Add(time.Minute))
|
|
if !s.take("st") {
|
|
t.Fatal("take of a fresh state = false, want true")
|
|
}
|
|
if s.take("st") {
|
|
t.Fatal("take of a consumed state = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestOAuthStateUnknownOrExpired(t *testing.T) {
|
|
s := newOAuthStates()
|
|
if s.take("never-seen") {
|
|
t.Fatal("take of an unknown state = true, want false")
|
|
}
|
|
s.put("stale", time.Now().Add(-time.Minute))
|
|
if s.take("stale") {
|
|
t.Fatal("take of an expired state = true, want false")
|
|
}
|
|
}
|
|
|
|
// The map is capped: a flood of starts evicts older states instead of growing,
|
|
// and consumed states must not change that.
|
|
func TestOAuthStateEviction(t *testing.T) {
|
|
s := newOAuthStates()
|
|
key := func(i, salt int) string {
|
|
return string(rune('a'+i%26)) + string(rune('0'+i/26+salt*16))
|
|
}
|
|
now := time.Now().Add(time.Hour)
|
|
for i := 0; i < maxStates*2; i++ {
|
|
s.put(key(i, 0), now)
|
|
}
|
|
if got := len(s.expiry); got != maxStates {
|
|
t.Fatalf("states after a flood = %d, want %d", got, maxStates)
|
|
}
|
|
|
|
// Consume everything, then flood again: the map stays bounded.
|
|
for state := range s.expiry {
|
|
s.take(state)
|
|
}
|
|
for i := 0; i < maxStates; i++ {
|
|
s.put(key(i, 1), now)
|
|
}
|
|
if got := len(s.expiry); got != maxStates {
|
|
t.Fatalf("states after consume+flood = %d, want %d", got, maxStates)
|
|
}
|
|
}
|