a73x

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")
}