internal/server/api/tokens_test.go
Ref: Size: 8.6 KiB History
package api
import (
"encoding/json"
"testing"
"github.com/a73x/eitri/internal/server/api/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMeIdentity pins GET /api/v1/me: it returns the caller's tenant and the
// email bound to that tenant's row, over BOTH credential paths (PAT and
// session).
func TestMeIdentity(t *testing.T) {
ts, st, _, _, a := newServer(t)
t.Run("PAT returns the tenant and its bound email", func(t *testing.T) {
resp := do(t, "GET", ts.URL+"/api/v1/me", testPAT, nil)
require.Equal(t, 200, resp.StatusCode)
var me types.Me
require.NoError(t, json.NewDecoder(resp.Body).Decode(&me))
assert.Equal(t, testTenant, me.Tenant)
assert.Equal(t, testTenant+"@test.local", me.Email)
})
t.Run("session carries the same identity", func(t *testing.T) {
sess := sessionFor(t, st, testTenant)
resp := doCookie(t, "GET", ts.URL+"/api/v1/me", sess, nil)
require.Equal(t, 200, resp.StatusCode)
var me types.Me
require.NoError(t, json.NewDecoder(resp.Body).Decode(&me))
assert.Equal(t, testTenant, me.Tenant)
})
t.Run("bound tenant returns its email", func(t *testing.T) {
tn, err := st.CreateTenantForIdentity("https://idp", "sub-me", "alex@emery.xyz")
require.NoError(t, err)
pat, _, err := st.CreateAPIToken(tn.ID, "me", 0)
require.NoError(t, err)
resp := do(t, "GET", ts.URL+"/api/v1/me", pat, nil)
require.Equal(t, 200, resp.StatusCode)
var me types.Me
require.NoError(t, json.NewDecoder(resp.Body).Decode(&me))
assert.Equal(t, tn.ID, me.Tenant)
assert.Equal(t, "alex@emery.xyz", me.Email)
})
// The gate address is the plane's half of a connect name: without it a
// client holds a tenant and a VM name but nothing to hop through.
t.Run("a plane with no gate names none", func(t *testing.T) {
resp := do(t, "GET", ts.URL+"/api/v1/me", testPAT, nil)
require.Equal(t, 200, resp.StatusCode)
var me types.Me
require.NoError(t, json.NewDecoder(resp.Body).Decode(&me))
assert.Empty(t, me.SSHGate)
})
t.Run("a plane with a gate names it", func(t *testing.T) {
a.SetSSHGate("gate.eitri.sh:2222")
resp := do(t, "GET", ts.URL+"/api/v1/me", testPAT, nil)
require.Equal(t, 200, resp.StatusCode)
var me types.Me
require.NoError(t, json.NewDecoder(resp.Body).Decode(&me))
assert.Equal(t, "gate.eitri.sh:2222", me.SSHGate)
})
}
// TestAPITokenLifecycle exercises mint → list → revoke through the HTTP surface:
// the secret comes back once and is prefixed, list shows metadata (no secret),
// and revoke removes it from the usable set.
func TestAPITokenLifecycle(t *testing.T) {
ts, _, _, _, _ := newServer(t)
// Mint with a TTL: ExpiresAt is set.
resp := do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
types.CreateAPITokenRequest{Name: "worker", TTLSeconds: 3600})
require.Equal(t, 201, resp.StatusCode)
var minted types.CreateAPITokenResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&minted))
assert.Equal(t, "worker", minted.Name)
assert.NotEmpty(t, minted.ID)
assert.Contains(t, minted.Token, "eitri_pat_", "secret is prefixed for leak-grepping")
assert.NotEmpty(t, minted.ExpiresAt, "a TTL sets expires_at")
// The minted secret authenticates.
assert.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", minted.Token, nil).StatusCode)
// List shows it, metadata only (never the secret).
resp = do(t, "GET", ts.URL+"/api/v1/tokens", testPAT, nil)
require.Equal(t, 200, resp.StatusCode)
body := decodeJSONKeys(t, resp)
found := false
for _, row := range body {
if row["id"] == minted.ID {
found = true
assert.Equal(t, "worker", row["name"])
assert.NotContains(t, row, "token", "list never carries the secret")
assert.NotEmpty(t, row["created_at"])
}
}
assert.True(t, found, "the minted token appears in the list")
// Revoke it: 204, and it no longer authenticates.
resp = do(t, "DELETE", ts.URL+"/api/v1/tokens/"+minted.ID, testPAT, nil)
assert.Equal(t, 204, resp.StatusCode)
assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", minted.Token, nil).StatusCode)
// Re-revoking the same id is a 404 (indistinguishable from unknown).
resp = do(t, "DELETE", ts.URL+"/api/v1/tokens/"+minted.ID, testPAT, nil)
assert.Equal(t, 404, resp.StatusCode)
}
// TestCreateAPITokenNonExpiring: a zero TTL mints a non-expiring token and the
// response ExpiresAt is empty.
func TestCreateAPITokenNonExpiring(t *testing.T) {
ts, _, _, _, _ := newServer(t)
resp := do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
types.CreateAPITokenRequest{Name: "perm", TTLSeconds: 0})
require.Equal(t, 201, resp.StatusCode)
var minted types.CreateAPITokenResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&minted))
assert.Empty(t, minted.ExpiresAt, "ttl 0 ⇒ non-expiring ⇒ empty expires_at")
}
// TestCreateAPITokenValidation: an empty name and a negative TTL are both 400.
func TestCreateAPITokenValidation(t *testing.T) {
ts, _, _, _, _ := newServer(t)
assert.Equal(t, 400, do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
types.CreateAPITokenRequest{Name: "", TTLSeconds: 60}).StatusCode)
assert.Equal(t, 400, do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
types.CreateAPITokenRequest{Name: "bad", TTLSeconds: -1}).StatusCode)
}
// TestRevokeAPITokenForeignIs404: a token owned by another tenant cannot be
// revoked and answers 404 — the same as an unknown id, so revoke never leaks
// which token ids exist across the partition. It also stays usable afterward.
func TestRevokeAPITokenForeignIs404(t *testing.T) {
ts, st, _, _, _ := newServer(t)
beta, err := st.CreateTenantForIdentity("https://idp", "sub-beta", "beta@example.com")
require.NoError(t, err)
betaSecret, betaID, err := st.CreateAPIToken(beta.ID, "beta", 0)
require.NoError(t, err)
// The default-tenant caller tries to revoke beta's token id.
resp := do(t, "DELETE", ts.URL+"/api/v1/tokens/"+betaID, testPAT, nil)
assert.Equal(t, 404, resp.StatusCode)
// Beta's token is untouched.
assert.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", betaSecret, nil).StatusCode)
}
// TestListAPITokensTenantScoped: list returns only the caller's tokens, never
// another tenant's.
func TestListAPITokensTenantScoped(t *testing.T) {
ts, st, _, _, _ := newServer(t)
beta, err := st.CreateTenantForIdentity("https://idp", "sub-b", "b@example.com")
require.NoError(t, err)
_, betaID, err := st.CreateAPIToken(beta.ID, "beta-only", 0)
require.NoError(t, err)
resp := do(t, "GET", ts.URL+"/api/v1/tokens", testPAT, nil)
require.Equal(t, 200, resp.StatusCode)
for _, row := range decodeJSONKeys(t, resp) {
assert.NotEqual(t, betaID, row["id"], "default's list must not include beta's token")
}
}
// TestTokenEndpointsActAsTheCallersTenant runs list and revoke from the side
// that is not the seeded tenant. The tenant-scoped tests above only ever call
// AS default, so a handler that reads a hard-coded "default" instead of the
// calling principal's tenant satisfies every one of them — the caller's own
// tokens still come back, because the caller IS default. Asking as beta is the
// only question a hard-coded tenant answers wrongly.
func TestTokenEndpointsActAsTheCallersTenant(t *testing.T) {
ts, st, _, _, _ := newServer(t)
beta, err := st.CreateTenantForIdentity("https://idp", "sub-beta", "beta@example.com")
require.NoError(t, err)
betaSecret, betaID, err := st.CreateAPIToken(beta.ID, "beta-worker", 0)
require.NoError(t, err)
_, defaultID, err := st.CreateAPIToken(testTenant, "default-worker", 0)
require.NoError(t, err)
resp := do(t, "GET", ts.URL+"/api/v1/tokens", betaSecret, nil)
require.Equal(t, 200, resp.StatusCode)
var ids []string
for _, row := range decodeJSONKeys(t, resp) {
id, _ := row["id"].(string)
ids = append(ids, id)
}
assert.Contains(t, ids, betaID, "list must answer as the CALLER's tenant: beta asked and was not shown its own token")
assert.NotContains(t, ids, defaultID,
"list must answer as the CALLER's tenant: beta was shown "+testTenant+"'s token id, so the handler read a fixed tenant rather than the principal's")
resp = do(t, "DELETE", ts.URL+"/api/v1/tokens/"+defaultID, betaSecret, nil)
assert.Equal(t, 404, resp.StatusCode, "beta must not be able to revoke "+testTenant+"'s token")
// Beta revokes its own last: the call that answers 401 afterwards is the
// one that proves the DELETE landed on beta's row and not somewhere else.
resp = do(t, "DELETE", ts.URL+"/api/v1/tokens/"+betaID, betaSecret, nil)
assert.Equal(t, 204, resp.StatusCode, "beta must be able to revoke beta's own token")
assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", betaSecret, nil).StatusCode,
"the revoke must land on the CALLER's row: beta's secret still authenticates, so the DELETE scoped to a fixed tenant and deleted nothing")
}