|
@@ -437,6 +437,120 @@ final class AuthEndpointsTest extends AppTestCase
|
|
|
self::assertSame('viewer', $second['role']);
|
|
self::assertSame('viewer', $second['role']);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F33 regression. `OidcClaims->email` is nullable — some
|
|
|
|
|
+ * IdPs (or Entra app configurations that omit the email scope) do
|
|
|
|
|
+ * not release the claim, and the UI forwards `email: null` in that
|
|
|
|
|
+ * case. The endpoint must accept the request, persist
|
|
|
|
|
+ * `users.email = NULL`, and emit `user.created` with `target_label`
|
|
|
|
|
+ * falling back to `display_name`.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testUpsertOidcAcceptsMissingEmail(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
|
|
+ $headers = [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ // No `email` key at all (key omitted).
|
|
|
|
|
+ $response = $this->request('POST', '/api/v1/auth/users/upsert-oidc', $headers, json_encode([
|
|
|
|
|
+ 'subject' => 'no-email-sub',
|
|
|
|
|
+ 'display_name' => 'No Email',
|
|
|
|
|
+ 'groups' => [],
|
|
|
|
|
+ ]) ?: null);
|
|
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
|
|
+ $body = $this->decode($response);
|
|
|
|
|
+ self::assertNull($body['email']);
|
|
|
|
|
+ self::assertSame('No Email', $body['display_name']);
|
|
|
|
|
+
|
|
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
|
|
+ 'SELECT email, display_name FROM users WHERE id = :id',
|
|
|
|
|
+ ['id' => $body['user_id']],
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertIsArray($row);
|
|
|
|
|
+ self::assertNull($row['email'], 'email column must persist NULL when not released');
|
|
|
|
|
+ self::assertSame('No Email', $row['display_name']);
|
|
|
|
|
+
|
|
|
|
|
+ $audit = $this->db->fetchAssociative(
|
|
|
|
|
+ "SELECT target_label, details_json FROM audit_log WHERE action = 'user.created' ORDER BY id DESC LIMIT 1"
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertIsArray($audit);
|
|
|
|
|
+ self::assertSame('No Email', $audit['target_label'], 'target_label falls back to display_name when email null');
|
|
|
|
|
+ $details = json_decode((string) $audit['details_json'], true);
|
|
|
|
|
+ self::assertIsArray($details);
|
|
|
|
|
+ self::assertNull($details['email']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testUpsertOidcAcceptsExplicitNullEmail(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
|
|
+ $headers = [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ // Explicit JSON null — what the UI sends when OidcClaims->email is null.
|
|
|
|
|
+ $response = $this->request('POST', '/api/v1/auth/users/upsert-oidc', $headers, json_encode([
|
|
|
|
|
+ 'subject' => 'null-email-sub',
|
|
|
|
|
+ 'email' => null,
|
|
|
|
|
+ 'display_name' => 'Null Email',
|
|
|
|
|
+ 'groups' => [],
|
|
|
|
|
+ ]) ?: null);
|
|
|
|
|
+ self::assertSame(200, $response->getStatusCode());
|
|
|
|
|
+ $body = $this->decode($response);
|
|
|
|
|
+ self::assertNull($body['email']);
|
|
|
|
|
+
|
|
|
|
|
+ $row = $this->db->fetchAssociative(
|
|
|
|
|
+ 'SELECT email FROM users WHERE id = :id',
|
|
|
|
|
+ ['id' => $body['user_id']],
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertIsArray($row);
|
|
|
|
|
+ self::assertNull($row['email']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * SEC_REVIEW F33: subject and display_name remain required. Missing
|
|
|
|
|
+ * either still 400s — the relaxation only applies to `email`.
|
|
|
|
|
+ */
|
|
|
|
|
+ public function testUpsertOidcStillRejectsMissingSubject(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
|
|
+ $response = $this->request(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/api/v1/auth/users/upsert-oidc',
|
|
|
|
|
+ [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
|
|
+ ],
|
|
|
|
|
+ json_encode([
|
|
|
|
|
+ 'email' => 'x@example.com',
|
|
|
|
|
+ 'display_name' => 'X',
|
|
|
|
|
+ 'groups' => [],
|
|
|
|
|
+ ]) ?: null
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(400, $response->getStatusCode());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testUpsertOidcStillRejectsMissingDisplayName(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $token = $this->createToken(TokenKind::Service);
|
|
|
|
|
+ $response = $this->request(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/api/v1/auth/users/upsert-oidc',
|
|
|
|
|
+ [
|
|
|
|
|
+ 'Authorization' => 'Bearer ' . $token,
|
|
|
|
|
+ 'Content-Type' => 'application/json',
|
|
|
|
|
+ ],
|
|
|
|
|
+ json_encode([
|
|
|
|
|
+ 'subject' => 'x',
|
|
|
|
|
+ 'email' => 'x@example.com',
|
|
|
|
|
+ 'groups' => [],
|
|
|
|
|
+ ]) ?: null
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertSame(400, $response->getStatusCode());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
public function testUpsertOidcRejectsAdminToken(): void
|
|
public function testUpsertOidcRejectsAdminToken(): void
|
|
|
{
|
|
{
|
|
|
// Even an admin token can't call /auth/* — those are service-only.
|
|
// Even an admin token can't call /auth/* — those are service-only.
|