Files
sm-user-plugin/api_tokens_edge_test.go
Jakub Zych 7991b53979 test: extend the permission resolver table, the admin migration history and the API token refusals
- resolver: several groups, numbers stored as text, empty group sets, a code only at user level, booleans
- migrations: every id once, the history applied twice, the unique code after a down and an up
- personal API tokens: no application, no principal, invalid bodies, another user's token
2026-10-05 15:23:23 +02:00

105 lines
4.6 KiB
Go

package user
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// TestAPITokenManagementRefusals covers the refusals of the personal API
// token routes: no application, no principal, invalid bodies, and a token
// that is not the caller's.
func TestAPITokenManagementRefusals(t *testing.T) {
app, db := sessionApp(t, "0s")
if err := app.Config.Set("golem15.user.api_tokens.prefix", "usr_"); err != nil {
t.Fatal(err)
}
owner := insertUser(t, db, "token-owner@example.test", "correct-horse")
stranger := insertUser(t, db, "token-stranger@example.test", "correct-horse")
call := func(handler http.Handler, method, body, id string, userID uint) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, "/_user/api/v1/tokens", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if id != "" {
req.SetPathValue("id", id)
}
if userID != 0 {
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: userID}))
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
// Without an application or a principal nothing is served.
for name, handler := range map[string]http.Handler{
"store": controllers.APITokenStore(nil), "index": controllers.APITokenIndex(nil), "destroy": controllers.APITokenDestroy(nil),
} {
if rec := call(handler, http.MethodPost, `{"name":"x"}`, "1", owner.ID); rec.Code != http.StatusInternalServerError {
t.Fatalf("%s without an application = %d", name, rec.Code)
}
}
for name, handler := range map[string]http.Handler{
"store": controllers.APITokenStore(app), "index": controllers.APITokenIndex(app), "destroy": controllers.APITokenDestroy(app),
} {
if rec := call(handler, http.MethodPost, `{"name":"x"}`, "1", 0); rec.Code != http.StatusUnauthorized {
t.Fatalf("%s without a principal = %d %s", name, rec.Code, rec.Body.String())
}
}
// Invalid bodies are 422 on the field and mint nothing.
for body, field := range map[string]string{
`{}`: "name",
`{"name":" "}`: "name",
`{"name":"` + strings.Repeat("n", 256) + `"}`: "name",
`{"name":"a","scopes":"read"}`: "scopes",
`{"name":"a","scopes":["nope"]}`: "scopes",
`{"name":"a","scopes":[1]}`: "scopes",
`{"name":"a","expires_at":"tomorrow"}`: "expires_at",
} {
rec := call(controllers.APITokenStore(app), http.MethodPost, body, "", owner.ID)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+field+`"`) {
t.Fatalf("store %s = %d %s, want 422 on %s", body, rec.Code, rec.Body.String(), field)
}
}
var minted int64
if err := db.Model(&models.APIToken{}).Where("user_id = ?", owner.ID).Count(&minted).Error; err != nil || minted != 0 {
t.Fatalf("tokens after refused creates = %d err = %v", minted, err)
}
// A token with an expiry is minted; only its owner can revoke it.
created := call(controllers.APITokenStore(app), http.MethodPost, `{"name":"expiring","expires_at":"2099-01-02T03:04:05Z"}`, "", owner.ID)
if created.Code != http.StatusCreated {
t.Fatalf("create = %d %s", created.Code, created.Body.String())
}
var token models.APIToken
if err := db.Where("user_id = ?", owner.ID).First(&token).Error; err != nil || token.ExpiresAt == nil {
t.Fatalf("stored token = %+v err = %v", token, err)
}
id := strconv.FormatUint(uint64(token.ID), 10)
for _, bad := range []string{"", "0", "abc", "-1", "987654321"} {
if rec := call(controllers.APITokenDestroy(app), http.MethodDelete, "", bad, owner.ID); rec.Code != http.StatusNotFound {
t.Fatalf("destroy %q = %d %s", bad, rec.Code, rec.Body.String())
}
}
if rec := call(controllers.APITokenDestroy(app), http.MethodDelete, "", id, stranger.ID); rec.Code != http.StatusNotFound {
t.Fatalf("destroy by another user = %d %s", rec.Code, rec.Body.String())
}
if rec := call(controllers.APITokenIndex(app), http.MethodGet, "", "", stranger.ID); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "expiring") {
t.Fatalf("another user's list = %d %s", rec.Code, rec.Body.String())
}
if rec := call(controllers.APITokenDestroy(app), http.MethodDelete, "", id, owner.ID); rec.Code != http.StatusOK {
t.Fatalf("destroy = %d %s", rec.Code, rec.Body.String())
}
if rec := call(controllers.APITokenIndex(app), http.MethodGet, "", "", owner.ID); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "expiring") {
t.Fatalf("list after the revoke = %d %s", rec.Code, rec.Body.String())
}
}