a73x

internal/server/store/identity_test.go

Ref:   Size: 4.5 KiB   History

package store

import (
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestTenantByIdentity(t *testing.T) {
	s := newStore(t)

	// Not found before any binding.
	_, ok, err := s.TenantByIdentity("https://idp", "sub-1")
	require.NoError(t, err)
	assert.False(t, ok)

	created, err := s.CreateTenantForIdentity("https://idp", "sub-1", "alex@a.com")
	require.NoError(t, err)
	assert.Equal(t, "alex", created.ID)
	assert.Equal(t, "https://idp", created.OIDCIssuer)
	assert.Equal(t, "sub-1", created.OIDCSubject)
	assert.Equal(t, "alex@a.com", created.Email)
	assert.False(t, created.CreatedAt.IsZero())

	// Found after create, same row.
	got, ok, err := s.TenantByIdentity("https://idp", "sub-1")
	require.NoError(t, err)
	require.True(t, ok)
	assert.Equal(t, created.ID, got.ID)
	assert.Equal(t, "sub-1", got.OIDCSubject)
}

func TestTenantByID(t *testing.T) {
	s := newStore(t)

	// Unknown handle: not found, no error.
	_, ok, err := s.TenantByID("nobody")
	require.NoError(t, err)
	assert.False(t, ok)

	created, err := s.CreateTenantForIdentity("https://idp", "sub-1", "alex@a.com")
	require.NoError(t, err)

	got, ok, err := s.TenantByID(created.ID)
	require.NoError(t, err)
	require.True(t, ok)
	assert.Equal(t, created.ID, got.ID)
	assert.Equal(t, "alex@a.com", got.Email)
	assert.Equal(t, "sub-1", got.OIDCSubject)

	// The harness-provisioned tenant is readable and carries its binding.
	def, ok, err := s.TenantByID(testTenant)
	require.NoError(t, err)
	require.True(t, ok)
	assert.Equal(t, testTenant, def.ID)
	assert.Equal(t, testTenant+"@test.local", def.Email)
}

func TestCreateTenantForIdentityHandleCollision(t *testing.T) {
	s := newStore(t)

	a, err := s.CreateTenantForIdentity("https://idp", "sub-a", "alex@a.com")
	require.NoError(t, err)
	assert.Equal(t, "alex", a.ID)

	// Same local part, different identity → numeric suffix.
	b, err := s.CreateTenantForIdentity("https://idp", "sub-b", "alex@b.com")
	require.NoError(t, err)
	assert.Equal(t, "alex-2", b.ID)

	c, err := s.CreateTenantForIdentity("https://idp", "sub-c", "alex@c.com")
	require.NoError(t, err)
	assert.Equal(t, "alex-3", c.ID)
}

func TestCreateTenantForIdentityDotFree(t *testing.T) {
	s := newStore(t)

	// Handles appear in <tenant>.<vm>; dots in the local part flatten to '-'.
	got, err := s.CreateTenantForIdentity("https://idp", "sub-1", "first.last@x.com")
	require.NoError(t, err)
	assert.Equal(t, "first-last", got.ID)
	assert.NotContains(t, got.ID, ".", "tenant handle must be dot-free")
}

func TestCreateTenantForIdentityReservedSystem(t *testing.T) {
	s := newStore(t)

	// SystemTenant is reserved (the audit scope for tenant-less events); JIT
	// never assigns it — a principal holding it could read system audit rows.
	got, err := s.CreateTenantForIdentity("https://idp", "sub-1", "system@x.com")
	require.NoError(t, err)
	assert.Equal(t, "system-2", got.ID)
}

func TestCreateTenantForIdentityDuplicateRejected(t *testing.T) {
	s := newStore(t)

	_, err := s.CreateTenantForIdentity("https://idp", "sub-1", "alex@a.com")
	require.NoError(t, err)

	// The same identity must not mint a second tenant.
	_, err = s.CreateTenantForIdentity("https://idp", "sub-1", "alex@a.com")
	require.Error(t, err)
}

func TestUnboundTenantsCoexist(t *testing.T) {
	s := newStore(t)

	// The identity index is partial (WHERE oidc_subject != ''): unbound rows —
	// which exist only in databases predating the OIDC binding — must coexist
	// without tripping it, or such a database fails on open.
	_, err := s.db.Exec(
		`INSERT INTO tenants (id, name, created_at) VALUES ('spare', 'spare', '2026-07-28T00:00:00Z')`)
	require.NoError(t, err)
	_, err = s.db.Exec(
		`INSERT INTO tenants (id, name, created_at) VALUES ('spare2', 'spare2', '2026-07-28T00:00:00Z')`)
	require.NoError(t, err, "a second unbound tenant must not trip the identity index")
}

func TestHandleFromEmail(t *testing.T) {
	cases := map[string]string{
		"alex@a.com":         "alex",
		"first.last@x.com":   "first-last",
		"UPPER@x.com":        "upper",
		"a+b@x.com":          "a-b",
		"weird__name@x.com":  "weird--name",
		"-lead-trail-@x.com": "lead-trail",
		"@x.com":             "user",
		"...@x.com":          "user",
	}
	for in, want := range cases {
		got := handleFromEmail(in)
		assert.Equal(t, want, got, "handleFromEmail(%q)", in)
		assert.NotContains(t, got, ".", "handle must be dot-free")
		assert.False(t, strings.HasPrefix(got, "-"), "no leading dash")
		assert.False(t, strings.HasSuffix(got, "-"), "no trailing dash")
	}
}