Files
sm-user-plugin/api_tokens_test.go
2026-10-04 22:30:20 +02:00

115 lines
4.8 KiB
Go

package user
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
func TestSharedAPITokenAuthenticatesWithCurrentUserPermissions(t *testing.T) {
app, db := sessionApp(t, "0s")
if err := app.Config.Set("golem15.user.api_tokens.prefix", "tst_"); err != nil {
t.Fatal(err)
}
user := insertUser(t, db, "token-shared@example.test", "correct-horse")
var groupID uint
if err := db.Raw(`INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
VALUES ('API operators', 'api-operators', '{"jz.bm.access_materials":1,"jz.bm.access_courses":0}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)", user.ID, groupID).Error; err != nil {
t.Fatal(err)
}
secret, token, err := classes.MintAPIToken("tst_", user.ID, "shared", []string{"read", "write"}, nil, nil)
if err != nil {
t.Fatal(err)
}
if strings.Contains(token.TokenHash, secret) || !strings.HasPrefix(secret, "tst_") {
t.Fatalf("secret/hash = %q/%q", secret, token.TokenHash)
}
if err := db.Create(token).Error; err != nil {
t.Fatal(err)
}
guard := &usermiddleware.APITokenGuard{App: app, Prefix: "tst_", ClientIP: func(*http.Request) string { return "203.0.113.7" }}
req := httptest.NewRequest(http.MethodGet, "/api/v1/example", nil)
req.Header.Set("Authorization", "Bearer "+secret)
principal, credential, err := guard.AuthenticateCredential(req)
if err != nil {
t.Fatal(err)
}
if principal.ID != user.ID || !principal.PermissionGrants["jz.bm.access_materials"] || principal.PermissionGrants["jz.bm.access_courses"] {
t.Fatalf("principal = %#v", principal)
}
got, ok := credential.(*models.APIToken)
if !ok || got.ID != token.ID || got.LastUsedAt == nil || got.LastUsedIP == nil || *got.LastUsedIP != "203.0.113.7" {
t.Fatalf("credential = %#v", credential)
}
called := false
final := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })
ctx := bouncer.WithCredential(bouncer.WithUser(context.Background(), principal), got)
scoped := usermiddleware.Scope("write")(usermiddleware.Permission("jz.bm.access_materials")(final))
scoped.ServeHTTP(httptest.NewRecorder(), req.WithContext(ctx))
if !called {
t.Fatal("shared token did not pass its scope and current user permission")
}
denied := httptest.NewRecorder()
usermiddleware.Permission("jz.bm.access_courses")(final).ServeHTTP(denied, req.WithContext(ctx))
if denied.Code != http.StatusForbidden {
t.Fatalf("permission denial = %d %s", denied.Code, denied.Body.String())
}
}
func TestMintAPITokenPreservesScopeOrderWhileDeduplicating(t *testing.T) {
_, token, err := classes.MintAPIToken("usr_", 1, "ordered", []string{"write", "ai", "write", "read"}, nil, nil)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(token.Scopes.Get(), ","); got != "write,ai,read" {
t.Fatalf("scopes = %q, want write,ai,read", got)
}
}
func TestAPITokenManagementShowsSecretOnlyOnCreate(t *testing.T) {
app, db := sessionApp(t, "0s")
if err := app.Config.Set("golem15.user.api_tokens.prefix", "usr_"); err != nil {
t.Fatal(err)
}
user := insertUser(t, db, "token-manage@example.test", "correct-horse")
principal := &bouncer.Principal{ID: user.ID}
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/tokens", strings.NewReader(`{"name":"automation","scopes":["read"]}`))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
created := httptest.NewRecorder()
controllers.APITokenStore(app).ServeHTTP(created, req)
if created.Code != http.StatusCreated {
t.Fatalf("create = %d %s", created.Code, created.Body.String())
}
var body struct {
Token string `json:"token"`
Meta map[string]any `json:"meta"`
}
if err := json.Unmarshal(created.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(body.Token, "usr_") || strings.Contains(created.Body.String(), classes.HashAPIToken(body.Token)) {
t.Fatalf("create body = %s", created.Body.String())
}
listReq := httptest.NewRequest(http.MethodGet, "/_user/api/v1/tokens", nil)
listReq = listReq.WithContext(bouncer.WithUser(listReq.Context(), principal))
listed := httptest.NewRecorder()
controllers.APITokenIndex(app).ServeHTTP(listed, listReq)
if listed.Code != http.StatusOK || strings.Contains(listed.Body.String(), body.Token) || strings.Contains(listed.Body.String(), classes.HashAPIToken(body.Token)) {
t.Fatalf("list = %d %s", listed.Code, listed.Body.String())
}
}