- Add api_tokens table migration (007) - Add APIToken model with CRUD operations - Generate tokens with upaas_ prefix + 32 hex chars - Store SHA-256 hash of tokens (not plaintext) - Update APISessionAuth middleware to check Bearer tokens - Add POST/GET/DELETE /api/v1/tokens endpoints - Token creation returns plaintext once; list never exposes it - Expired and revoked tokens are rejected - Tests for creation, listing, deletion, bearer auth, revocation
294 lines
6.5 KiB
Go
294 lines
6.5 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// tokenRouter builds a chi router with token + app API routes.
|
|
func tokenRouter(tc *testContext) http.Handler {
|
|
r := chi.NewRouter()
|
|
|
|
r.Route("/api/v1", func(apiR chi.Router) {
|
|
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
|
|
|
|
apiR.Group(func(apiR chi.Router) {
|
|
apiR.Use(tc.middleware.APISessionAuth())
|
|
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
|
|
apiR.Post("/tokens", tc.handlers.HandleAPICreateToken())
|
|
apiR.Get("/tokens", tc.handlers.HandleAPIListTokens())
|
|
apiR.Delete(
|
|
"/tokens/{tokenID}",
|
|
tc.handlers.HandleAPIDeleteToken(),
|
|
)
|
|
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
|
|
})
|
|
})
|
|
|
|
return r
|
|
}
|
|
|
|
func TestAPICreateToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tc, cookies := setupAPITest(t)
|
|
r := tokenRouter(tc)
|
|
|
|
body := `{"name":"my-ci-token"}`
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/tokens",
|
|
strings.NewReader(body),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusCreated, rr.Code)
|
|
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
|
|
assert.Equal(t, "my-ci-token", resp["name"])
|
|
assert.Contains(t, resp["token"], "upaas_")
|
|
assert.NotEmpty(t, resp["id"])
|
|
}
|
|
|
|
func TestAPICreateTokenMissingName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tc, cookies := setupAPITest(t)
|
|
r := tokenRouter(tc)
|
|
|
|
body := `{"name":""}`
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/tokens",
|
|
strings.NewReader(body),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, rr.Code)
|
|
}
|
|
|
|
func TestAPIListTokens(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tc, cookies := setupAPITest(t)
|
|
r := tokenRouter(tc)
|
|
|
|
// Create two tokens.
|
|
for _, name := range []string{"token-a", "token-b"} {
|
|
body := `{"name":"` + name + `"}`
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/tokens",
|
|
strings.NewReader(body),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusCreated, rr.Code)
|
|
}
|
|
|
|
// List tokens.
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
var tokens []map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
|
|
assert.Len(t, tokens, 2)
|
|
|
|
// Plaintext token must NOT appear in list.
|
|
for _, tok := range tokens {
|
|
assert.Nil(t, tok["token"])
|
|
}
|
|
}
|
|
|
|
func TestAPIDeleteToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tc, cookies := setupAPITest(t)
|
|
r := tokenRouter(tc)
|
|
|
|
// Create a token.
|
|
body := `{"name":"delete-me"}`
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/tokens",
|
|
strings.NewReader(body),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusCreated, rr.Code)
|
|
|
|
var created map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
|
|
|
tokenID, ok := created["id"].(string)
|
|
require.True(t, ok)
|
|
|
|
// Delete it.
|
|
req = httptest.NewRequest(
|
|
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
// List should be empty.
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
|
|
var tokens []map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
|
|
assert.Empty(t, tokens)
|
|
}
|
|
|
|
func TestAPIBearerTokenAuth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tc, cookies := setupAPITest(t)
|
|
r := tokenRouter(tc)
|
|
|
|
// Create a token via session auth.
|
|
body := `{"name":"bearer-test"}`
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/tokens",
|
|
strings.NewReader(body),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusCreated, rr.Code)
|
|
|
|
var created map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
|
|
|
plaintext, ok := created["token"].(string)
|
|
require.True(t, ok)
|
|
|
|
// Use Bearer token to access an authenticated endpoint.
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
|
req.Header.Set("Authorization", "Bearer "+plaintext)
|
|
|
|
rr = httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusOK, rr.Code)
|
|
}
|
|
|
|
func TestAPIBearerTokenInvalid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tc := setupTestHandlers(t)
|
|
r := tokenRouter(tc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
|
req.Header.Set("Authorization", "Bearer upaas_invalidtoken1234567890ab")
|
|
|
|
rr := httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
}
|
|
|
|
func TestAPIBearerTokenRevoked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tc, cookies := setupAPITest(t)
|
|
r := tokenRouter(tc)
|
|
|
|
// Create then delete a token.
|
|
body := `{"name":"revoke-test"}`
|
|
req := httptest.NewRequest(
|
|
http.MethodPost, "/api/v1/tokens",
|
|
strings.NewReader(body),
|
|
)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr := httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusCreated, rr.Code)
|
|
|
|
var created map[string]any
|
|
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
|
|
|
|
plaintext, ok := created["token"].(string)
|
|
require.True(t, ok)
|
|
tokenID, ok := created["id"].(string)
|
|
require.True(t, ok)
|
|
|
|
// Delete (revoke) the token.
|
|
req = httptest.NewRequest(
|
|
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
require.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
// Try to use the revoked token.
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
|
|
req.Header.Set("Authorization", "Bearer "+plaintext)
|
|
|
|
rr = httptest.NewRecorder()
|
|
r.ServeHTTP(rr, req)
|
|
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
|
}
|