Replace fmt/errors/strings/encoding/json/os/os/exec/path/filepath with
core primitives; rename abbreviated variables; add Ugly test variants to
all test files; rename integration tests to TestFilename_Function_{Good,Bad,Ugly}.
Co-Authored-By: Virgil <virgil@lethean.io>
376 lines
11 KiB
Go
376 lines
11 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package api_test
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"testing"
|
|
|
|
"dappco.re/go/core"
|
|
|
|
api "dappco.re/go/core/api"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// testAuthRoutes provides endpoints for integration testing.
|
|
type testAuthRoutes struct{}
|
|
|
|
func (r *testAuthRoutes) Name() string { return "authtest" }
|
|
func (r *testAuthRoutes) BasePath() string { return "/v1" }
|
|
|
|
func (r *testAuthRoutes) RegisterRoutes(rg *gin.RouterGroup) {
|
|
rg.GET("/public", func(c *gin.Context) {
|
|
c.JSON(200, api.OK("public"))
|
|
})
|
|
rg.GET("/whoami", api.RequireAuth(), func(c *gin.Context) {
|
|
user := api.GetUser(c)
|
|
c.JSON(200, api.OK(user))
|
|
})
|
|
rg.GET("/admin", api.RequireGroup("admins"), func(c *gin.Context) {
|
|
user := api.GetUser(c)
|
|
c.JSON(200, api.OK(user))
|
|
})
|
|
}
|
|
|
|
// getClientCredentialsToken fetches a token from Authentik using
|
|
// the client_credentials grant.
|
|
func getClientCredentialsToken(t *testing.T, issuer, clientID, clientSecret string) (accessToken, idToken string) {
|
|
t.Helper()
|
|
|
|
// Discover token endpoint.
|
|
discoveryURL := core.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
|
resp, err := http.Get(discoveryURL) //nolint:noctx
|
|
if err != nil {
|
|
t.Fatalf("OIDC discovery failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
discoveryBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read discovery body: %v", err)
|
|
}
|
|
|
|
var oidcConfig struct {
|
|
TokenEndpoint string `json:"token_endpoint"`
|
|
}
|
|
if result := core.JSONUnmarshal(discoveryBody, &oidcConfig); !result.OK {
|
|
t.Fatalf("decode discovery: %v", result.Value)
|
|
}
|
|
|
|
// Request token.
|
|
formData := url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"client_id": {clientID},
|
|
"client_secret": {clientSecret},
|
|
"scope": {"openid email profile entitlements"},
|
|
}
|
|
tokenResp, err := http.PostForm(oidcConfig.TokenEndpoint, formData) //nolint:noctx
|
|
if err != nil {
|
|
t.Fatalf("token request failed: %v", err)
|
|
}
|
|
defer tokenResp.Body.Close()
|
|
|
|
tokenBody, err := io.ReadAll(tokenResp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read token body: %v", err)
|
|
}
|
|
|
|
var tokenResult struct {
|
|
AccessToken string `json:"access_token"`
|
|
IDToken string `json:"id_token"`
|
|
Error string `json:"error"`
|
|
ErrorDesc string `json:"error_description"`
|
|
}
|
|
if result := core.JSONUnmarshal(tokenBody, &tokenResult); !result.OK {
|
|
t.Fatalf("decode token response: %v", result.Value)
|
|
}
|
|
if tokenResult.Error != "" {
|
|
t.Fatalf("token error: %s — %s", tokenResult.Error, tokenResult.ErrorDesc)
|
|
}
|
|
|
|
return tokenResult.AccessToken, tokenResult.IDToken
|
|
}
|
|
|
|
func TestAuthentikIntegration_Good_LiveTokenFlow(t *testing.T) {
|
|
// Skip unless explicitly enabled — requires live Authentik at auth.lthn.io.
|
|
if core.Env("AUTHENTIK_INTEGRATION") != "1" {
|
|
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
|
|
}
|
|
|
|
issuer := envOrDefault("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
|
clientID := envOrDefault("AUTHENTIK_CLIENT_ID", "core-api")
|
|
clientSecret := core.Env("AUTHENTIK_CLIENT_SECRET")
|
|
if clientSecret == "" {
|
|
t.Fatal("AUTHENTIK_CLIENT_SECRET is required")
|
|
}
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
// Fetch a real token from Authentik.
|
|
t.Run("TokenAcquisition", func(t *testing.T) {
|
|
access, id := getClientCredentialsToken(t, issuer, clientID, clientSecret)
|
|
if access == "" {
|
|
t.Fatal("empty access_token")
|
|
}
|
|
if id == "" {
|
|
t.Fatal("empty id_token")
|
|
}
|
|
t.Logf("access_token length: %d", len(access))
|
|
t.Logf("id_token length: %d", len(id))
|
|
})
|
|
|
|
// Build the engine with real Authentik config.
|
|
engine, err := api.New(
|
|
api.WithAuthentik(api.AuthentikConfig{
|
|
Issuer: issuer,
|
|
ClientID: clientID,
|
|
TrustedProxy: true,
|
|
}),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("engine: %v", err)
|
|
}
|
|
engine.Register(&testAuthRoutes{})
|
|
testServer := httptest.NewServer(engine.Handler())
|
|
defer testServer.Close()
|
|
|
|
accessToken, _ := getClientCredentialsToken(t, issuer, clientID, clientSecret)
|
|
|
|
t.Run("Health_NoAuth", func(t *testing.T) {
|
|
resp := getWithBearer(t, testServer.URL+"/health", "")
|
|
assertStatusCode(t, resp, 200)
|
|
body := readResponseBody(t, resp)
|
|
t.Logf("health: %s", body)
|
|
})
|
|
|
|
t.Run("Public_NoAuth", func(t *testing.T) {
|
|
resp := getWithBearer(t, testServer.URL+"/v1/public", "")
|
|
assertStatusCode(t, resp, 200)
|
|
body := readResponseBody(t, resp)
|
|
t.Logf("public: %s", body)
|
|
})
|
|
|
|
t.Run("Whoami_NoToken_401", func(t *testing.T) {
|
|
resp := getWithBearer(t, testServer.URL+"/v1/whoami", "")
|
|
assertStatusCode(t, resp, 401)
|
|
})
|
|
|
|
t.Run("Whoami_WithAccessToken", func(t *testing.T) {
|
|
resp := getWithBearer(t, testServer.URL+"/v1/whoami", accessToken)
|
|
assertStatusCode(t, resp, 200)
|
|
body := readResponseBody(t, resp)
|
|
t.Logf("whoami (access_token): %s", body)
|
|
|
|
// Parse response and verify user fields.
|
|
var envelope struct {
|
|
Data api.AuthentikUser `json:"data"`
|
|
}
|
|
if result := core.JSONUnmarshalString(body, &envelope); !result.OK {
|
|
t.Fatalf("parse whoami: %v", result.Value)
|
|
}
|
|
if envelope.Data.UID == "" {
|
|
t.Error("expected non-empty UID")
|
|
}
|
|
if !core.Contains(envelope.Data.Username, "client_credentials") {
|
|
t.Logf("username: %s (service account)", envelope.Data.Username)
|
|
}
|
|
})
|
|
|
|
t.Run("Admin_ServiceAccount_403", func(t *testing.T) {
|
|
// Service account has no groups — should get 403.
|
|
resp := getWithBearer(t, testServer.URL+"/v1/admin", accessToken)
|
|
assertStatusCode(t, resp, 403)
|
|
})
|
|
|
|
t.Run("Whoami_ForwardAuthHeaders", func(t *testing.T) {
|
|
// Simulate what Traefik sends after forward auth.
|
|
req, _ := http.NewRequest("GET", testServer.URL+"/v1/whoami", nil)
|
|
req.Header.Set("X-authentik-username", "akadmin")
|
|
req.Header.Set("X-authentik-email", "mafiafire@proton.me")
|
|
req.Header.Set("X-authentik-name", "Admin User")
|
|
req.Header.Set("X-authentik-uid", "abc123")
|
|
req.Header.Set("X-authentik-groups", "authentik Admins|admins|developers")
|
|
req.Header.Set("X-authentik-entitlements", "")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
assertStatusCode(t, resp, 200)
|
|
|
|
body := readResponseBody(t, resp)
|
|
t.Logf("whoami (forward auth): %s", body)
|
|
|
|
var envelope struct {
|
|
Data api.AuthentikUser `json:"data"`
|
|
}
|
|
if result := core.JSONUnmarshalString(body, &envelope); !result.OK {
|
|
t.Fatalf("parse: %v", result.Value)
|
|
}
|
|
if envelope.Data.Username != "akadmin" {
|
|
t.Errorf("expected username akadmin, got %s", envelope.Data.Username)
|
|
}
|
|
if !envelope.Data.HasGroup("admins") {
|
|
t.Error("expected admins group")
|
|
}
|
|
})
|
|
|
|
t.Run("Admin_ForwardAuth_Admins_200", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", testServer.URL+"/v1/admin", nil)
|
|
req.Header.Set("X-authentik-username", "akadmin")
|
|
req.Header.Set("X-authentik-email", "mafiafire@proton.me")
|
|
req.Header.Set("X-authentik-name", "Admin User")
|
|
req.Header.Set("X-authentik-uid", "abc123")
|
|
req.Header.Set("X-authentik-groups", "authentik Admins|admins|developers")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
assertStatusCode(t, resp, 200)
|
|
t.Logf("admin (forward auth): %s", readResponseBody(t, resp))
|
|
})
|
|
|
|
t.Run("InvalidJWT_FailOpen", func(t *testing.T) {
|
|
// Invalid token on a public endpoint — should still work (permissive).
|
|
resp := getWithBearer(t, testServer.URL+"/v1/public", "not-a-real-token")
|
|
assertStatusCode(t, resp, 200)
|
|
})
|
|
|
|
t.Run("InvalidJWT_Protected_401", func(t *testing.T) {
|
|
// Invalid token on a protected endpoint — no user extracted, RequireAuth returns 401.
|
|
resp := getWithBearer(t, testServer.URL+"/v1/whoami", "not-a-real-token")
|
|
assertStatusCode(t, resp, 401)
|
|
})
|
|
}
|
|
|
|
func getWithBearer(t *testing.T, requestURL, bearerToken string) *http.Response {
|
|
t.Helper()
|
|
req, _ := http.NewRequest("GET", requestURL, nil)
|
|
if bearerToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+bearerToken)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", requestURL, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func readResponseBody(t *testing.T, resp *http.Response) string {
|
|
t.Helper()
|
|
responseBytes, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
return string(responseBytes)
|
|
}
|
|
|
|
func assertStatusCode(t *testing.T, resp *http.Response, want int) {
|
|
t.Helper()
|
|
if resp.StatusCode != want {
|
|
responseBytes, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("want status %d, got %d: %s", want, resp.StatusCode, string(responseBytes))
|
|
}
|
|
}
|
|
|
|
func envOrDefault(key, fallback string) string {
|
|
if value := core.Env(key); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// TestOIDCDiscovery_Good_EndpointReachable validates that the OIDC discovery endpoint is reachable.
|
|
func TestOIDCDiscovery_Good_EndpointReachable(t *testing.T) {
|
|
if core.Env("AUTHENTIK_INTEGRATION") != "1" {
|
|
t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests")
|
|
}
|
|
|
|
issuer := envOrDefault("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/")
|
|
discoveryURL := core.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
|
|
|
resp, err := http.Get(discoveryURL) //nolint:noctx
|
|
if err != nil {
|
|
t.Fatalf("discovery request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("discovery status: %d", resp.StatusCode)
|
|
}
|
|
|
|
discoveryBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read discovery body: %v", err)
|
|
}
|
|
|
|
var discoveryConfig map[string]any
|
|
if result := core.JSONUnmarshal(discoveryBody, &discoveryConfig); !result.OK {
|
|
t.Fatalf("decode: %v", result.Value)
|
|
}
|
|
|
|
// Verify essential fields.
|
|
for _, field := range []string{"issuer", "token_endpoint", "jwks_uri", "authorization_endpoint"} {
|
|
if discoveryConfig[field] == nil {
|
|
t.Errorf("missing field: %s", field)
|
|
}
|
|
}
|
|
|
|
if discoveryConfig["issuer"] != issuer {
|
|
t.Errorf("issuer mismatch: got %v, want %s", discoveryConfig["issuer"], issuer)
|
|
}
|
|
|
|
// Verify grant types include client_credentials.
|
|
grants, ok := discoveryConfig["grant_types_supported"].([]any)
|
|
if !ok {
|
|
t.Fatal("missing grant_types_supported")
|
|
}
|
|
clientCredentialsFound := false
|
|
for _, grantType := range grants {
|
|
if grantType == "client_credentials" {
|
|
clientCredentialsFound = true
|
|
break
|
|
}
|
|
}
|
|
if !clientCredentialsFound {
|
|
t.Error("client_credentials grant not supported")
|
|
}
|
|
|
|
t.Logf("OIDC discovery OK — issuer: %s", discoveryConfig["issuer"])
|
|
t.Logf("Token endpoint: %s", discoveryConfig["token_endpoint"])
|
|
t.Logf("JWKS URI: %s", discoveryConfig["jwks_uri"])
|
|
}
|
|
|
|
// TestOIDCDiscovery_Bad_SkipsWithoutEnvVar verifies the test skips without AUTHENTIK_INTEGRATION=1.
|
|
func TestOIDCDiscovery_Bad_SkipsWithoutEnvVar(t *testing.T) {
|
|
// This test always runs; it verifies no network call is made without the env var.
|
|
// Since we cannot unset env vars safely in parallel tests, we verify the skip logic
|
|
// by running this in an environment where AUTHENTIK_INTEGRATION is not "1".
|
|
if core.Env("AUTHENTIK_INTEGRATION") == "1" {
|
|
t.Skip("skipping skip-check test when integration env is set")
|
|
}
|
|
// No network call should happen — test passes if we reach here.
|
|
}
|
|
|
|
// TestOIDCDiscovery_Ugly_MalformedIssuerHandled verifies the discovery helper does not panic on bad issuer.
|
|
func TestOIDCDiscovery_Ugly_MalformedIssuerHandled(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("malformed issuer panicked: %v", r)
|
|
}
|
|
}()
|
|
|
|
// envOrDefault returns fallback on empty — verify it does not panic on empty key.
|
|
result := envOrDefault("", "fallback")
|
|
if result != "fallback" {
|
|
t.Errorf("expected fallback, got %q", result)
|
|
}
|
|
}
|