headscale/integration/oidc_groups_test.go
Ryan Malloy 32ea1c1c84 oidc groups: fix post-merge compile and migration issues
Bugs found by running the real test suite after merging upstream:

- types/types_clone.go, types/types_view.go: extend the regeneration
  guard struct literals to include the new Groups field, and add a
  UserView.Groups() accessor. Generated files normally rebuilt via
  cloner / viewer; touched by hand here pending make generate.
- db/db.go: the migration adding the groups column ran after
  202505141324, which calls ListUsers() through the User struct that
  now includes Groups. Move the column-add to 202505141323 so the
  schema is in place before any migration loads users. Register the
  new ID in the FK-disabled migration list.
- db/db.go: 202507021200 recreates all tables from inline SQL during
  the SQLite schema migration; add groups to both the CREATE TABLE
  users statement and the INSERT INTO users ... SELECT FROM users_old
  so the column survives the recreation. Also fix a copy-paste bug
  in the Rollback closure that referenced tx instead of db.
- db/schema.sql: add the groups column to the canonical schema so
  squibble.Validate accepts databases produced by the new migration
  chain. Verified against all 7 historical sqlite dumps in
  hscontrol/db/testdata/sqlite.
- types/users_test.go: the casby-oidc-claim case now exercises group
  storage; update the want to include the JSON-encoded groups column.
- integration/oidc_groups_test.go: replace the aspirational draft
  (which referenced assertNoErr, scenario.usernames, hsic.WithTLS and
  other symbols that do not exist) with a focused test that follows
  the auth_oidc_test.go pattern. Verifies the groups column directly
  via sqlite3 inside the headscale container since the gRPC User
  message does not expose Groups.
2026-05-21 21:14:08 -06:00

137 lines
4.6 KiB
Go

package integration
import (
"encoding/json"
"sort"
"strings"
"testing"
"github.com/juanfont/headscale/integration/hsic"
"github.com/oauth2-proxy/mockoidc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestOIDCGroupsPersisted verifies that the `groups` claim from an OIDC
// provider is persisted into the users.groups column after the user logs in.
//
// The implementation under test:
// - User.Groups column (TEXT, JSON-encoded []string) added by migration
// 202505141323 in hscontrol/db/db.go.
// - User.SetGroups / User.GetGroups in hscontrol/types/users.go.
// - FromClaim() calls SetGroups(claims.Groups) so login populates the column.
// - OIDCClaims.Groups is FlexibleStringSlice so providers like JumpCloud
// that return a single string instead of a one-element array also work.
//
// Verification is done by reading the SQLite database inside the headscale
// container directly, because the gRPC User message does not currently
// expose Groups. Adding groups to the gRPC API is a separate, larger change.
func TestOIDCGroupsPersisted(t *testing.T) {
IntegrationSkip(t)
// mockoidc serves logins in strict queue order, so keep NodesPerUser=1.
spec := ScenarioSpec{
NodesPerUser: 1,
Users: []string{"admin", "dev", "solo"},
OIDCUsers: []mockoidc.MockUser{
oidcMockUserWithGroups("admin", true, []string{"admins", "engineering"}),
oidcMockUserWithGroups("dev", true, []string{"engineering"}),
// User with empty groups — must round-trip as no Groups stored.
oidcMockUserWithGroups("solo", true, nil),
},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
oidcMap := map[string]string{
"HEADSCALE_OIDC_ISSUER": scenario.mockOIDC.Issuer(),
"HEADSCALE_OIDC_CLIENT_ID": scenario.mockOIDC.ClientID(),
"CREDENTIALS_DIRECTORY_TEST": "/tmp",
"HEADSCALE_OIDC_CLIENT_SECRET_PATH": "${CREDENTIALS_DIRECTORY_TEST}/hs_client_oidc_secret",
// Make sure the OIDC scope set includes "groups" so the IdP emits the claim.
"HEADSCALE_OIDC_SCOPE": "openid,profile,email,groups",
}
err = scenario.CreateHeadscaleEnvWithLoginURL(
nil,
hsic.WithTestName("oidcgroups"),
hsic.WithConfigEnv(oidcMap),
hsic.WithFileInContainer("/tmp/hs_client_oidc_secret", []byte(scenario.mockOIDC.ClientSecret())),
)
requireNoErrHeadscaleEnv(t, err)
// Drive the OIDC login flow for every client.
_, err = scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
headscale, err := scenario.Headscale()
require.NoError(t, err)
// Query the SQLite database inside the headscale container for the groups
// column. CLI/gRPC do not expose it yet; this is the authoritative store.
const dbPath = "/tmp/integration_test_db.sqlite3"
out, err := headscale.Execute([]string{
"sqlite3", dbPath,
"-cmd", ".mode tabs",
"SELECT name, COALESCE(groups, '') FROM users WHERE provider = 'oidc' ORDER BY name;",
})
require.NoError(t, err, "querying users.groups from sqlite")
got := parseGroupsRows(t, out)
want := map[string][]string{
"admin": {"admins", "engineering"},
"dev": {"engineering"},
"solo": nil,
}
for name, wantGroups := range want {
gotGroups, ok := got[name]
assert.True(t, ok, "user %q not present in users table", name)
assert.ElementsMatch(t, wantGroups, gotGroups,
"groups mismatch for user %q (raw rows: %q)", name, out)
}
}
// parseGroupsRows parses the tab-separated output of:
//
// SELECT name, COALESCE(groups, '') FROM users ...
//
// Returns a map of username -> decoded groups slice. An empty groups column
// (stored as "" by SetGroups when the input slice is empty) decodes to nil.
func parseGroupsRows(t *testing.T, raw string) map[string][]string {
t.Helper()
rows := map[string][]string{}
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
require.Len(t, parts, 2, "unexpected sqlite row format: %q", line)
name, groupsJSON := parts[0], parts[1]
if groupsJSON == "" {
rows[name] = nil
continue
}
var gs []string
require.NoError(t, json.Unmarshal([]byte(groupsJSON), &gs),
"groups column for %q is not valid JSON: %q", name, groupsJSON)
sort.Strings(gs)
rows[name] = gs
}
return rows
}
// oidcMockUserWithGroups extends [oidcMockUser] with a Groups claim.
// mockoidc populates the id_token / userinfo from this struct verbatim.
func oidcMockUserWithGroups(username string, emailVerified bool, groups []string) mockoidc.MockUser {
u := oidcMockUser(username, emailVerified)
u.Groups = groups
return u
}