The kill switch had no operator surface. streamrevoke.Store.List() existed and was called from nowhere, so an admin could only terminate one session by id or revoke a user's streams as a side effect of editing their account — with no way to see what was revoked, choose a TTL, or undo a mistake. Because expiry is deliberately monotonic, a wrong 24h kill was irreversible. - GET/POST/DELETE /api/v1/admin/streams/revocations, admin-only, additive. - Explicit wire-to-internal kind mapping: the wire accepts "session" (and "sess"), the store key is "sess". Passing the wire string straight into a Key would create a revocation IsRevoked never consults. - Validation: non-empty bounded session ids; canonical positive user ids (strconv.Itoa round-trip, so "01" is rejected — the cache key is the canonical form); ttl_seconds bounded to 30d so the duration cannot overflow; bounded reason and request body. DELETE of an absent key is idempotent. - Store.Unrevoke, guarded by a bounded in-memory tombstone: the tombstone is installed and the local entry dropped BEFORE the slow durable/Redis deletes, so a concurrent poll reconcile cannot re-apply the row it just read. A newer Revoke on the same key clears the tombstone, so an unrevoke never suppresses a later legitimate kill. Tombstones age out with the kill they replaced. - maintain() takes the same operation lock as Revoke/Unrevoke around its durable block, closing the window where a poll tick could re-Upsert a row Unrevoke had just deleted — invisible until a restart resurrected the kill. - Durable self-heal now compares expiry, not mere presence, so a failed Revoke mirror leaves a stale shorter row that the next tick repairs. - A failed unrevoke publish fails safe: other processes keep the kill until it expires. Propagation failures surface as warnings rather than weakening the local result. Unrevoking an over-cap victim is legal but the async enforcer will re-revoke it on its next pass while the user is still over cap; that is documented at the endpoint. Part of the stream monitoring & kill-switch epic.
125 lines
4.5 KiB
Go
125 lines
4.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
|
"github.com/Silo-Server/silo-server/internal/streamrevoke"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func streamRevocationTestRouter(store *streamrevoke.Store) http.Handler {
|
|
h := NewAdminStreamRevocationHandler(store)
|
|
r := chi.NewRouter()
|
|
r.Get("/revocations", h.HandleList)
|
|
r.Post("/revocations", h.HandleCreate)
|
|
r.Delete("/revocations/{kind}/{id}", h.HandleDelete)
|
|
return r
|
|
}
|
|
|
|
func TestAdminStreamRevocationRequiresAdminMiddleware(t *testing.T) {
|
|
const adminRole = "admin"
|
|
protected := apimw.RequireActingAdmin(nil)(streamRevocationTestRouter(streamrevoke.New(streamrevoke.Options{})))
|
|
for _, tc := range []struct {
|
|
name string
|
|
claims *auth.Claims
|
|
status int
|
|
}{
|
|
{name: "unauthenticated", status: http.StatusUnauthorized},
|
|
{name: "non admin", claims: &auth.Claims{UserID: 1, Role: string(scopeUser)}, status: http.StatusForbidden},
|
|
{name: adminRole, claims: &auth.Claims{UserID: 1, Role: adminRole}, status: http.StatusOK},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/revocations", nil)
|
|
if tc.claims != nil {
|
|
req = req.WithContext(apimw.SetClaims(req.Context(), tc.claims))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
protected.ServeHTTP(rec, req)
|
|
if rec.Code != tc.status {
|
|
t.Fatalf("status=%d, want %d", rec.Code, tc.status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminStreamRevocationSessionMappingAndListDeleteRoundTrip(t *testing.T) {
|
|
store := streamrevoke.New(streamrevoke.Options{})
|
|
router := streamRevocationTestRouter(store)
|
|
req := httptest.NewRequest(http.MethodPost, "/revocations", bytes.NewBufferString(`{"kind":"session","id":" s1 ","reason":" test "}`))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var created map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created["kind"] != "sess" || created["id"] != "s1" {
|
|
t.Fatalf("created=%v, want internal sess kind and trimmed id", created)
|
|
}
|
|
|
|
listReq := httptest.NewRequest(http.MethodGet, "/revocations", nil)
|
|
listRec := httptest.NewRecorder()
|
|
router.ServeHTTP(listRec, listReq)
|
|
var listed struct {
|
|
Revocations []streamrevoke.Revocation `json:"revocations"`
|
|
}
|
|
if err := json.Unmarshal(listRec.Body.Bytes(), &listed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(listed.Revocations) != 1 {
|
|
t.Fatalf("list=%+v", listed.Revocations)
|
|
}
|
|
deletePath := "/revocations/" + string(listed.Revocations[0].Kind) + "/" + listed.Revocations[0].ID
|
|
deleteReq := httptest.NewRequest(http.MethodDelete, deletePath, nil)
|
|
deleteRec := httptest.NewRecorder()
|
|
router.ServeHTTP(deleteRec, deleteReq)
|
|
if deleteRec.Code != http.StatusNoContent || len(store.List()) != 0 {
|
|
t.Fatalf("delete status=%d remaining=%v", deleteRec.Code, store.List())
|
|
}
|
|
}
|
|
|
|
func TestAdminStreamRevocationValidation(t *testing.T) {
|
|
router := streamRevocationTestRouter(streamrevoke.New(streamrevoke.Options{}))
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{name: "noncanonical user", body: `{"kind":"user","id":"01"}`},
|
|
{name: "zero user", body: `{"kind":"user","id":"0"}`},
|
|
{name: "unknown kind", body: `{"kind":"stream","id":"x"}`},
|
|
{name: "missing session id", body: `{"kind":"session","id":" "}`},
|
|
{name: "zero ttl", body: `{"kind":"sess","id":"x","ttl_seconds":0}`},
|
|
{name: "negative ttl", body: `{"kind":"sess","id":"x","ttl_seconds":-1}`},
|
|
{name: "ttl above max", body: `{"kind":"sess","id":"x","ttl_seconds":2592001}`},
|
|
{name: "ttl integer overflow", body: `{"kind":"sess","id":"x","ttl_seconds":9223372036854775808}`},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/revocations", bytes.NewBufferString(tt.body))
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAdminStreamRevocationDeleteAbsentIsIdempotent(t *testing.T) {
|
|
router := streamRevocationTestRouter(streamrevoke.New(streamrevoke.Options{}))
|
|
req := httptest.NewRequest(http.MethodDelete, "/revocations/sess/missing", nil)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|