- 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
105 lines
4.6 KiB
Go
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())
|
|
}
|
|
}
|