|
| 1 | +//go:build integration |
| 2 | + |
| 3 | +/* |
| 4 | +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 5 | +
|
| 6 | +Licensed under the Apache License, Version 2.0 (the "License"); |
| 7 | +you may not use this file except in compliance with the License. |
| 8 | +You may obtain a copy of the License at |
| 9 | +
|
| 10 | +http://www.apache.org/licenses/LICENSE-2.0 |
| 11 | +
|
| 12 | +Unless required by applicable law or agreed to in writing, software |
| 13 | +distributed under the License is distributed on an "AS IS" BASIS, |
| 14 | +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 15 | +See the License for the specific language governing permissions and |
| 16 | +limitations under the License. |
| 17 | +
|
| 18 | +SPDX-License-Identifier: Apache-2.0 |
| 19 | +*/ |
| 20 | + |
| 21 | +package roles_test |
| 22 | + |
| 23 | +import ( |
| 24 | + "context" |
| 25 | + "strings" |
| 26 | + "testing" |
| 27 | + |
| 28 | + "go.corp.nvidia.com/osmo/tests/common/database" |
| 29 | + "go.corp.nvidia.com/osmo/utils/roles" |
| 30 | +) |
| 31 | + |
| 32 | +// insertRoleWithSyncMode writes a roles row with the given sync_mode and an |
| 33 | +// empty policies list. The roles table has a NOT NULL DEFAULT for policies, |
| 34 | +// so the minimal insert is enough for the role-sync tests below. |
| 35 | +func insertRoleWithSyncMode(t *testing.T, fixture *database.PostgresFixture, |
| 36 | + name, syncMode string) { |
| 37 | + t.Helper() |
| 38 | + fixture.ExecSQL(t, |
| 39 | + `INSERT INTO roles (name, sync_mode) VALUES ($1, $2)`, name, syncMode) |
| 40 | +} |
| 41 | + |
| 42 | +// insertRoleMapping creates an external→internal role mapping row. |
| 43 | +func insertRoleMapping(t *testing.T, fixture *database.PostgresFixture, |
| 44 | + roleName, externalRole string) { |
| 45 | + t.Helper() |
| 46 | + fixture.ExecSQL(t, |
| 47 | + `INSERT INTO role_external_mappings (role_name, external_role) |
| 48 | + VALUES ($1, $2)`, roleName, externalRole) |
| 49 | +} |
| 50 | + |
| 51 | +// insertUser creates a users row directly (bypassing SyncUserRoles' upsert) so |
| 52 | +// pre-existing role assignments can be set up before the function under test |
| 53 | +// runs. |
| 54 | +func insertUser(t *testing.T, fixture *database.PostgresFixture, userID string) { |
| 55 | + t.Helper() |
| 56 | + fixture.ExecSQL(t, |
| 57 | + `INSERT INTO users (id, created_by) VALUES ($1, $1)`, userID) |
| 58 | +} |
| 59 | + |
| 60 | +// insertUserRole writes a user_roles row directly. |
| 61 | +func insertUserRole(t *testing.T, fixture *database.PostgresFixture, |
| 62 | + userID, roleName string) { |
| 63 | + t.Helper() |
| 64 | + fixture.ExecSQL(t, |
| 65 | + `INSERT INTO user_roles (user_id, role_name) VALUES ($1, $2)`, |
| 66 | + userID, roleName) |
| 67 | +} |
| 68 | + |
| 69 | +// readUserRoleNames returns the role names currently assigned to userID, |
| 70 | +// ordered by role_name for deterministic comparison. |
| 71 | +func readUserRoleNames(t *testing.T, fixture *database.PostgresFixture, |
| 72 | + userID string) []string { |
| 73 | + t.Helper() |
| 74 | + rows, err := fixture.Pool.Query(context.Background(), |
| 75 | + `SELECT role_name FROM user_roles WHERE user_id = $1 ORDER BY role_name`, |
| 76 | + userID) |
| 77 | + if err != nil { |
| 78 | + t.Fatalf("failed to query user_roles: %v", err) |
| 79 | + } |
| 80 | + defer rows.Close() |
| 81 | + |
| 82 | + var names []string |
| 83 | + for rows.Next() { |
| 84 | + var name string |
| 85 | + if scanErr := rows.Scan(&name); scanErr != nil { |
| 86 | + t.Fatalf("scan failed: %v", scanErr) |
| 87 | + } |
| 88 | + names = append(names, name) |
| 89 | + } |
| 90 | + return names |
| 91 | +} |
| 92 | + |
| 93 | +// containsString reports whether slice contains target. Helper kept outside |
| 94 | +// test bodies to avoid in-test loops. |
| 95 | +func containsString(slice []string, target string) bool { |
| 96 | + for _, item := range slice { |
| 97 | + if item == target { |
| 98 | + return true |
| 99 | + } |
| 100 | + } |
| 101 | + return false |
| 102 | +} |
| 103 | + |
| 104 | +// TestSyncUserRoles_Integration_EmptyUserName covers the early-return guard |
| 105 | +// for empty user names: SyncUserRoles must return (nil, nil) without writing |
| 106 | +// to or reading from the database. |
| 107 | +func TestSyncUserRoles_Integration_EmptyUserName(t *testing.T) { |
| 108 | + fixture := database.StartPostgresWithSchema(t) |
| 109 | + |
| 110 | + result, err := roles.SyncUserRoles(context.Background(), fixture.Client, |
| 111 | + "", []string{"idp-admins"}, silentLogger()) |
| 112 | + if err != nil { |
| 113 | + t.Fatalf("expected nil error for empty userName, got: %v", err) |
| 114 | + } |
| 115 | + if result != nil { |
| 116 | + t.Errorf("expected nil result for empty userName, got: %v", result) |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +// TestSyncUserRoles_Integration_ImportMode_AddsMappedRole covers the success |
| 121 | +// path that exercises upsertUser, the syncAndReturnRoles SQL, the "added" |
| 122 | +// change_type branch, and the logger.Info emit (because Added is non-empty). |
| 123 | +// The role row pre-exists in the roles table; the user row is created by |
| 124 | +// upsertUser inside SyncUserRoles. |
| 125 | +func TestSyncUserRoles_Integration_ImportMode_AddsMappedRole(t *testing.T) { |
| 126 | + fixture := database.StartPostgresWithSchema(t) |
| 127 | + |
| 128 | + insertRoleWithSyncMode(t, fixture, "osmo-admin", roles.SyncModeImport) |
| 129 | + insertRoleMapping(t, fixture, "osmo-admin", "idp-admins") |
| 130 | + |
| 131 | + result, err := roles.SyncUserRoles(context.Background(), fixture.Client, |
| 132 | + "alice", []string{"idp-admins"}, silentLogger()) |
| 133 | + if err != nil { |
| 134 | + t.Fatalf("unexpected error: %v", err) |
| 135 | + } |
| 136 | + |
| 137 | + if !containsString(result, "osmo-admin") { |
| 138 | + t.Errorf("expected returned roles to contain %q, got: %v", |
| 139 | + "osmo-admin", result) |
| 140 | + } |
| 141 | + |
| 142 | + stored := readUserRoleNames(t, fixture, "alice") |
| 143 | + if !containsString(stored, "osmo-admin") { |
| 144 | + t.Errorf("expected user_roles to contain %q after sync, got: %v", |
| 145 | + "osmo-admin", stored) |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +// TestSyncUserRoles_Integration_ForceMode_RemovesUnmappedRole covers the |
| 150 | +// "force" sync semantics that strip a role when its external mapping is |
| 151 | +// absent from the request, and exercises the "removed" change_type branch |
| 152 | +// of the row-scan switch. |
| 153 | +func TestSyncUserRoles_Integration_ForceMode_RemovesUnmappedRole(t *testing.T) { |
| 154 | + fixture := database.StartPostgresWithSchema(t) |
| 155 | + |
| 156 | + insertRoleWithSyncMode(t, fixture, "osmo-eng", roles.SyncModeForce) |
| 157 | + insertRoleMapping(t, fixture, "osmo-eng", "idp-eng") |
| 158 | + insertUser(t, fixture, "bob") |
| 159 | + insertUserRole(t, fixture, "bob", "osmo-eng") |
| 160 | + |
| 161 | + // External roles do NOT include "idp-eng", so force-mode removes osmo-eng. |
| 162 | + result, err := roles.SyncUserRoles(context.Background(), fixture.Client, |
| 163 | + "bob", []string{"idp-other"}, silentLogger()) |
| 164 | + if err != nil { |
| 165 | + t.Fatalf("unexpected error: %v", err) |
| 166 | + } |
| 167 | + |
| 168 | + if containsString(result, "osmo-eng") { |
| 169 | + t.Errorf("expected returned roles NOT to contain force-removed %q, got: %v", |
| 170 | + "osmo-eng", result) |
| 171 | + } |
| 172 | + |
| 173 | + stored := readUserRoleNames(t, fixture, "bob") |
| 174 | + if containsString(stored, "osmo-eng") { |
| 175 | + t.Errorf("expected user_roles NOT to contain %q after force sync, got: %v", |
| 176 | + "osmo-eng", stored) |
| 177 | + } |
| 178 | +} |
| 179 | + |
| 180 | +// TestSyncUserRoles_Integration_ImportMode_DoesNotRemoveExisting covers the |
| 181 | +// "import" semantics: an existing role is preserved even when its external |
| 182 | +// mapping is absent. The role flows through the union as change_type |
| 183 | +// "existing" and is returned in RoleNames without being deleted. |
| 184 | +func TestSyncUserRoles_Integration_ImportMode_DoesNotRemoveExisting(t *testing.T) { |
| 185 | + fixture := database.StartPostgresWithSchema(t) |
| 186 | + |
| 187 | + insertRoleWithSyncMode(t, fixture, "osmo-viewer", roles.SyncModeImport) |
| 188 | + insertRoleMapping(t, fixture, "osmo-viewer", "idp-viewers") |
| 189 | + insertUser(t, fixture, "carol") |
| 190 | + insertUserRole(t, fixture, "carol", "osmo-viewer") |
| 191 | + |
| 192 | + result, err := roles.SyncUserRoles(context.Background(), fixture.Client, |
| 193 | + "carol", []string{"idp-other"}, silentLogger()) |
| 194 | + if err != nil { |
| 195 | + t.Fatalf("unexpected error: %v", err) |
| 196 | + } |
| 197 | + |
| 198 | + if !containsString(result, "osmo-viewer") { |
| 199 | + t.Errorf("expected import-mode role to be retained, got: %v", result) |
| 200 | + } |
| 201 | + |
| 202 | + stored := readUserRoleNames(t, fixture, "carol") |
| 203 | + if !containsString(stored, "osmo-viewer") { |
| 204 | + t.Errorf("expected user_roles to still contain %q, got: %v", |
| 205 | + "osmo-viewer", stored) |
| 206 | + } |
| 207 | +} |
| 208 | + |
| 209 | +// TestSyncUserRoles_Integration_IgnoreMode_NotAddedEvenWhenMapped covers the |
| 210 | +// SQL filter "WHERE r.sync_mode != $3" (where $3 is SyncModeIgnore): roles |
| 211 | +// configured with sync_mode='ignore' are entirely outside the sync set, so |
| 212 | +// even a matching external role does not cause assignment. |
| 213 | +func TestSyncUserRoles_Integration_IgnoreMode_NotAddedEvenWhenMapped(t *testing.T) { |
| 214 | + fixture := database.StartPostgresWithSchema(t) |
| 215 | + |
| 216 | + insertRoleWithSyncMode(t, fixture, "osmo-static", roles.SyncModeIgnore) |
| 217 | + insertRoleMapping(t, fixture, "osmo-static", "idp-static") |
| 218 | + |
| 219 | + result, err := roles.SyncUserRoles(context.Background(), fixture.Client, |
| 220 | + "dave", []string{"idp-static"}, silentLogger()) |
| 221 | + if err != nil { |
| 222 | + t.Fatalf("unexpected error: %v", err) |
| 223 | + } |
| 224 | + |
| 225 | + if containsString(result, "osmo-static") { |
| 226 | + t.Errorf("expected ignore-mode role NOT to be added, got: %v", result) |
| 227 | + } |
| 228 | + |
| 229 | + stored := readUserRoleNames(t, fixture, "dave") |
| 230 | + if containsString(stored, "osmo-static") { |
| 231 | + t.Errorf("expected user_roles NOT to contain ignore-mode role, got: %v", |
| 232 | + stored) |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +// TestSyncUserRoles_Integration_NilExternalRoles covers the |
| 237 | +// "if len(externalRoles) == 0 { externalRoles = []string{} }" normalization: |
| 238 | +// passing nil must not crash and must produce a usable text[] for the SQL |
| 239 | +// driver. With force-mode roles defined but no current assignments, no |
| 240 | +// inserts or deletes are performed. |
| 241 | +func TestSyncUserRoles_Integration_NilExternalRoles(t *testing.T) { |
| 242 | + fixture := database.StartPostgresWithSchema(t) |
| 243 | + |
| 244 | + insertRoleWithSyncMode(t, fixture, "osmo-keep", roles.SyncModeForce) |
| 245 | + insertRoleMapping(t, fixture, "osmo-keep", "idp-keep") |
| 246 | + |
| 247 | + result, err := roles.SyncUserRoles(context.Background(), fixture.Client, |
| 248 | + "eve", nil, silentLogger()) |
| 249 | + if err != nil { |
| 250 | + t.Fatalf("unexpected error for nil externalRoles: %v", err) |
| 251 | + } |
| 252 | + if len(result) != 0 { |
| 253 | + t.Errorf("expected empty result for new user with no external roles, got: %v", |
| 254 | + result) |
| 255 | + } |
| 256 | +} |
| 257 | + |
| 258 | +// TestSyncUserRoles_Integration_NoChanges_ReturnsExistingRole covers the |
| 259 | +// "existing" change_type branch where the user already has the role, the |
| 260 | +// external mapping still matches, and neither Added nor Removed is populated. |
| 261 | +// This is the path where the logger.Info emit is skipped. |
| 262 | +func TestSyncUserRoles_Integration_NoChanges_ReturnsExistingRole(t *testing.T) { |
| 263 | + fixture := database.StartPostgresWithSchema(t) |
| 264 | + |
| 265 | + insertRoleWithSyncMode(t, fixture, "osmo-stable", roles.SyncModeImport) |
| 266 | + insertRoleMapping(t, fixture, "osmo-stable", "idp-stable") |
| 267 | + insertUser(t, fixture, "frank") |
| 268 | + insertUserRole(t, fixture, "frank", "osmo-stable") |
| 269 | + |
| 270 | + result, err := roles.SyncUserRoles(context.Background(), fixture.Client, |
| 271 | + "frank", []string{"idp-stable"}, silentLogger()) |
| 272 | + if err != nil { |
| 273 | + t.Fatalf("unexpected error: %v", err) |
| 274 | + } |
| 275 | + |
| 276 | + if !containsString(result, "osmo-stable") { |
| 277 | + t.Errorf("expected returned roles to contain pre-existing %q, got: %v", |
| 278 | + "osmo-stable", result) |
| 279 | + } |
| 280 | +} |
| 281 | + |
| 282 | +// TestSyncUserRoles_Integration_CanceledContext_UpsertError covers the error |
| 283 | +// wrap "upsert user: <err>" returned by SyncUserRoles when upsertUser's |
| 284 | +// Pool().Exec call fails. A canceled context fails the Exec immediately. |
| 285 | +func TestSyncUserRoles_Integration_CanceledContext_UpsertError(t *testing.T) { |
| 286 | + fixture := database.StartPostgresWithSchema(t) |
| 287 | + |
| 288 | + ctx, cancel := context.WithCancel(context.Background()) |
| 289 | + cancel() |
| 290 | + |
| 291 | + result, err := roles.SyncUserRoles(ctx, fixture.Client, |
| 292 | + "user-x", []string{"idp"}, silentLogger()) |
| 293 | + if err == nil { |
| 294 | + t.Fatalf("expected error from canceled context, got nil") |
| 295 | + } |
| 296 | + if !strings.Contains(err.Error(), "upsert user") { |
| 297 | + t.Errorf("expected error wrapped with %q, got: %v", "upsert user", err) |
| 298 | + } |
| 299 | + if result != nil { |
| 300 | + t.Errorf("expected nil result on error, got: %v", result) |
| 301 | + } |
| 302 | +} |
| 303 | + |
| 304 | +// TestSyncUserRoles_Integration_SyncQueryError covers the error wrap |
| 305 | +// "sync user roles: <err>" returned by SyncUserRoles when the |
| 306 | +// syncAndReturnRoles query fails. Dropping the roles table after the user |
| 307 | +// upsert succeeds makes the sync query reference a missing table; upsertUser |
| 308 | +// (which only touches users) still succeeds, so the second error path runs. |
| 309 | +func TestSyncUserRoles_Integration_SyncQueryError(t *testing.T) { |
| 310 | + fixture := database.StartPostgresWithSchema(t) |
| 311 | + |
| 312 | + // CASCADE also drops user_roles and role_external_mappings (both FK into |
| 313 | + // roles), so syncAndReturnRoles' query references missing tables. |
| 314 | + fixture.ExecSQL(t, `DROP TABLE roles CASCADE`) |
| 315 | + |
| 316 | + result, err := roles.SyncUserRoles(context.Background(), fixture.Client, |
| 317 | + "user-y", []string{"idp"}, silentLogger()) |
| 318 | + if err == nil { |
| 319 | + t.Fatalf("expected error after dropping roles table, got nil") |
| 320 | + } |
| 321 | + if !strings.Contains(err.Error(), "sync user roles") { |
| 322 | + t.Errorf("expected error wrapped with %q, got: %v", |
| 323 | + "sync user roles", err) |
| 324 | + } |
| 325 | + if result != nil { |
| 326 | + t.Errorf("expected nil result on error, got: %v", result) |
| 327 | + } |
| 328 | +} |
0 commit comments