Sfoglia il codice sorgente

Merge pull request #1178 from SonarSource/feature/jl/add_dates_to_users_and_user_tokens

Add dates to users and user tokens
tags/7.7
Julien Lancelot 5 anni fa
parent
commit
308d6a85e6
51 ha cambiato i file con 1288 aggiunte e 99 eliminazioni
  1. 2
    0
      server/sonar-db-core/src/main/resources/org/sonar/db/version/schema-h2.ddl
  2. 0
    4
      server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDao.java
  3. 18
    0
      server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDto.java
  4. 0
    2
      server/sonar-db-dao/src/main/java/org/sonar/db/user/UserMapper.java
  5. 5
    0
      server/sonar-db-dao/src/main/java/org/sonar/db/user/UserTokenDao.java
  6. 21
    0
      server/sonar-db-dao/src/main/java/org/sonar/db/user/UserTokenDto.java
  7. 3
    0
      server/sonar-db-dao/src/main/java/org/sonar/db/user/UserTokenMapper.java
  8. 3
    7
      server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml
  9. 19
    10
      server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserTokenMapper.xml
  10. 16
    1
      server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDaoTest.java
  11. 6
    0
      server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDbTester.java
  12. 15
    0
      server/sonar-db-dao/src/test/java/org/sonar/db/user/UserTokenDaoTest.java
  13. 46
    0
      server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUserTokens.java
  14. 46
    0
      server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUsers.java
  15. 3
    1
      server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v77/DbVersion77.java
  16. 57
    0
      server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUserTokensTest.java
  17. 57
    0
      server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUsersTest.java
  18. 1
    1
      server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v77/DbVersion77Test.java
  19. 9
    0
      server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUserTokensTest/user_tokens.sql
  20. 28
    0
      server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUsersTest/users.sql
  21. 2
    1
      server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java
  22. 31
    0
      server/sonar-server/src/main/java/org/sonar/server/authentication/UserLastConnectionDatesUpdater.java
  23. 73
    0
      server/sonar-server/src/main/java/org/sonar/server/authentication/UserLastConnectionDatesUpdaterImpl.java
  24. 5
    1
      server/sonar-server/src/main/java/org/sonar/server/user/UserSessionFactoryImpl.java
  25. 16
    5
      server/sonar-server/src/main/java/org/sonar/server/user/ws/SearchAction.java
  26. 5
    1
      server/sonar-server/src/main/java/org/sonar/server/usertoken/UserTokenAuthentication.java
  27. 5
    0
      server/sonar-server/src/main/java/org/sonar/server/usertoken/ws/SearchAction.java
  28. 1
    1
      server/sonar-server/src/test/java/org/sonar/server/authentication/AuthenticationModuleTest.java
  29. 115
    0
      server/sonar-server/src/test/java/org/sonar/server/authentication/UserLastConnectionDatesUpdaterImplTest.java
  30. 52
    25
      server/sonar-server/src/test/java/org/sonar/server/user/ws/SearchActionTest.java
  31. 10
    1
      server/sonar-server/src/test/java/org/sonar/server/usertoken/UserTokenAuthenticationTest.java
  32. 22
    0
      server/sonar-server/src/test/java/org/sonar/server/usertoken/ws/SearchActionTest.java
  33. 3
    17
      server/sonar-web/src/main/js/api/user-tokens.ts
  34. 12
    0
      server/sonar-web/src/main/js/app/types.d.ts
  35. 3
    3
      server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx
  36. 1
    0
      server/sonar-web/src/main/js/apps/users/UsersList.tsx
  37. 5
    0
      server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap
  38. 4
    3
      server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
  39. 10
    6
      server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx
  40. 5
    1
      server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
  41. 83
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/TokensForm-test.tsx
  42. 65
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/TokensFormItem-test.tsx
  43. 16
    8
      server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx
  44. 168
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensForm-test.tsx.snap
  45. 69
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensFormItem-test.tsx.snap
  46. 86
    0
      server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap
  47. 56
    0
      server/sonar-web/src/main/js/components/intl/DateFromNowHourPrecision.tsx
  48. 5
    0
      server/sonar-web/src/main/js/helpers/dates.ts
  49. 3
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties
  50. 1
    0
      sonar-ws/src/main/protobuf/ws-user_tokens.proto
  51. 1
    0
      sonar-ws/src/main/protobuf/ws-users.proto

+ 2
- 0
server/sonar-db-core/src/main/resources/org/sonar/db/version/schema-h2.ddl Vedi File

@@ -523,6 +523,7 @@ CREATE TABLE "USERS" (
"HOMEPAGE_TYPE" VARCHAR(40),
"HOMEPAGE_PARAMETER" VARCHAR(40),
"ORGANIZATION_UUID" VARCHAR(40),
"LAST_CONNECTION_DATE" BIGINT,
"CREATED_AT" BIGINT,
"UPDATED_AT" BIGINT
);
@@ -785,6 +786,7 @@ CREATE TABLE "USER_TOKENS" (
"USER_UUID" VARCHAR(255) NOT NULL,
"NAME" VARCHAR(100) NOT NULL,
"TOKEN_HASH" VARCHAR(255) NOT NULL,
"LAST_CONNECTION_DATE" BIGINT,
"CREATED_AT" BIGINT NOT NULL
);
CREATE UNIQUE INDEX "USER_TOKENS_TOKEN_HASH" ON "USER_TOKENS" ("TOKEN_HASH");

+ 0
- 4
server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDao.java Vedi File

@@ -178,10 +178,6 @@ public class UserDao implements Dao {
return mapper(dbSession).selectByExternalLoginAndIdentityProvider(externalLogin, externalIdentityProvider);
}

public List<UserDto> selectByExternalIdentityProvider(DbSession dbSession, String externalIdentityProvider) {
return mapper(dbSession).selectByExternalIdentityProvider(externalIdentityProvider);
}

public void scrollByUuids(DbSession dbSession, Collection<String> uuids, Consumer<UserDto> consumer) {
UserMapper mapper = mapper(dbSession);


+ 18
- 0
server/sonar-db-dao/src/main/java/org/sonar/db/user/UserDto.java Vedi File

@@ -56,6 +56,14 @@ public class UserDto {
private boolean root = false;
private boolean onboarded = false;
private String organizationUuid;

/**
* Date of the last time the user has accessed to the server.
* Can be null when user has never been authenticated, or has not been authenticated since the creation of the column in SonarQube 7.7.
*/
@Nullable
private Long lastConnectionDate;

private Long createdAt;
private Long updatedAt;

@@ -274,6 +282,16 @@ public class UserDto {
return this;
}

@CheckForNull
public Long getLastConnectionDate() {
return lastConnectionDate;
}

public UserDto setLastConnectionDate(@Nullable Long lastConnectionDate) {
this.lastConnectionDate = lastConnectionDate;
return this;
}

public Long getCreatedAt() {
return createdAt;
}

+ 0
- 2
server/sonar-db-dao/src/main/java/org/sonar/db/user/UserMapper.java Vedi File

@@ -64,8 +64,6 @@ public interface UserMapper {

UserDto selectByExternalLoginAndIdentityProvider(@Param("externalLogin") String externalLogin, @Param("externalIdentityProvider") String externalExternalIdentityProvider);

List<UserDto> selectByExternalIdentityProvider(@Param("externalIdentityProvider") String externalExternalIdentityProvider);

void scrollAll(ResultHandler<UserDto> handler);

/**

+ 5
- 0
server/sonar-db-dao/src/main/java/org/sonar/db/user/UserTokenDao.java Vedi File

@@ -31,10 +31,15 @@ import static org.sonar.core.util.stream.MoreCollectors.toList;
import static org.sonar.db.DatabaseUtils.executeLargeInputs;

public class UserTokenDao implements Dao {

public void insert(DbSession dbSession, UserTokenDto userTokenDto) {
mapper(dbSession).insert(userTokenDto);
}

public void update(DbSession dbSession, UserTokenDto userTokenDto) {
mapper(dbSession).update(userTokenDto);
}

@CheckForNull
public UserTokenDto selectByTokenHash(DbSession dbSession, String tokenHash) {
return mapper(dbSession).selectByTokenHash(tokenHash);

+ 21
- 0
server/sonar-db-dao/src/main/java/org/sonar/db/user/UserTokenDto.java Vedi File

@@ -19,12 +19,23 @@
*/
package org.sonar.db.user;

import javax.annotation.CheckForNull;
import javax.annotation.Nullable;

import static org.sonar.db.user.UserTokenValidator.checkTokenHash;

public class UserTokenDto {

private String userUuid;
private String name;
private String tokenHash;

/**
* Date of the last time this token has been used.
* Can be null when user has never been used it.
*/
private Long lastConnectionDate;

private Long createdAt;

public String getUserUuid() {
@@ -54,6 +65,16 @@ public class UserTokenDto {
return this;
}

@CheckForNull
public Long getLastConnectionDate() {
return lastConnectionDate;
}

public UserTokenDto setLastConnectionDate(@Nullable Long lastConnectionDate) {
this.lastConnectionDate = lastConnectionDate;
return this;
}

public Long getCreatedAt() {
return createdAt;
}

+ 3
- 0
server/sonar-db-dao/src/main/java/org/sonar/db/user/UserTokenMapper.java Vedi File

@@ -23,8 +23,11 @@ import java.util.List;
import org.apache.ibatis.annotations.Param;

public interface UserTokenMapper {

void insert(UserTokenDto userToken);

void update(UserTokenDto userToken);

UserTokenDto selectByTokenHash(String tokenHash);

UserTokenDto selectByUserUuidAndName(@Param("userUuid") String userUuid, @Param("name") String name);

+ 3
- 7
server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserMapper.xml Vedi File

@@ -23,6 +23,7 @@
u.homepage_type as "homepageType",
u.homepage_parameter as "homepageParameter",
u.organization_uuid as organizationUuid,
u.last_connection_date as "lastConnectionDate",
u.created_at as "createdAt",
u.updated_at as "updatedAt"
</sql>
@@ -150,13 +151,6 @@
WHERE u.external_login=#{externalLogin} AND u.external_identity_provider=#{externalIdentityProvider, jdbcType=VARCHAR}
</select>

<select id="selectByExternalIdentityProvider" parameterType="map" resultType="User">
SELECT
<include refid="userColumns"/>
FROM users u
WHERE u.external_identity_provider=#{externalIdentityProvider, jdbcType=VARCHAR}
</select>

<select id="countRootUsersButLogin" parameterType="String" resultType="long">
select
count(1)
@@ -175,6 +169,7 @@
scm_accounts = null,
salt = null,
crypted_password = null,
last_connection_date = null,
updated_at = #{now, jdbcType=BIGINT}
where
login = #{login, jdbcType=VARCHAR}
@@ -272,6 +267,7 @@
homepage_type = #{user.homepageType, jdbcType=VARCHAR},
homepage_parameter = #{user.homepageParameter, jdbcType=VARCHAR},
organization_uuid = #{user.organizationUuid, jdbcType=VARCHAR},
last_connection_date = #{user.lastConnectionDate,jdbcType=BIGINT},
updated_at = #{user.updatedAt,jdbcType=BIGINT}
where
uuid = #{user.uuid, jdbcType=VARCHAR}

+ 19
- 10
server/sonar-db-dao/src/main/resources/org/sonar/db/user/UserTokenMapper.xml Vedi File

@@ -7,6 +7,7 @@
t.user_uuid as "userUuid",
t.name as "name",
t.token_hash as "tokenHash",
t.last_connection_date as "lastConnectionDate",
t.created_at as "createdAt"
</sql>

@@ -17,32 +18,40 @@
token_hash,
created_at
) values (
#{userUuid,jdbcType=VARCHAR},
#{name,jdbcType=VARCHAR},
#{tokenHash,jdbcType=VARCHAR},
#{createdAt,jdbcType=BIGINT}
#{userUuid, jdbcType=VARCHAR},
#{name, jdbcType=VARCHAR},
#{tokenHash, jdbcType=VARCHAR},
#{createdAt, jdbcType=BIGINT}
)
</insert>

<insert id="update" parameterType="UserToken">
UPDATE user_tokens SET
last_connection_date = #{lastConnectionDate, jdbcType=BIGINT}
WHERE
user_uuid = #{userUuid, jdbcType=VARCHAR}
AND name = #{name, jdbcType=VARCHAR}
</insert>

<select id="selectByTokenHash" parameterType="String" resultType="UserToken">
SELECT
<include refid="userTokensColumns"/>
FROM user_tokens t
WHERE t.token_hash=#{tokenHash}
WHERE t.token_hash=#{tokenHash, jdbcType=VARCHAR}
</select>

<select id="selectByUserUuidAndName" parameterType="map" resultType="UserToken">
SELECT
<include refid="userTokensColumns"/>
FROM user_tokens t
WHERE t.user_uuid=#{userUuid} and t.name=#{name}
WHERE t.user_uuid=#{userUuid, jdbcType=VARCHAR} and t.name=#{name, jdbcType=VARCHAR}
</select>

<select id="selectByUserUuid" parameterType="map" resultType="UserToken">
SELECT
<include refid="userTokensColumns"/>
FROM user_tokens t
WHERE t.user_uuid=#{userUuid}
WHERE t.user_uuid=#{userUuid, jdbcType=VARCHAR}
</select>
<select id="countTokensByUserUuids" parameterType="map" resultType="UserTokenCount">
@@ -50,17 +59,17 @@
FROM user_tokens t
WHERE t.user_uuid in
<foreach collection="userUuids" open="(" close=")" item="userUuid" separator=",">
#{userUuid}
#{userUuid, jdbcType=VARCHAR}
</foreach>
GROUP BY t.user_uuid
</select>

<delete id="deleteByUserUuid">
DELETE FROM user_tokens WHERE user_uuid=#{userUuid}
DELETE FROM user_tokens WHERE user_uuid=#{userUuid, jdbcType=VARCHAR}
</delete>

<delete id="deleteByUserUuidAndName">
DELETE FROM user_tokens WHERE user_uuid=#{userUuid} and name=#{name}
DELETE FROM user_tokens WHERE user_uuid=#{userUuid, jdbcType=VARCHAR} and name=#{name, jdbcType=VARCHAR}
</delete>

</mapper>

+ 16
- 1
server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDaoTest.java Vedi File

@@ -363,6 +363,17 @@ public class UserDaoTest {
assertThat(user.getOrganizationUuid()).isEqualTo("ORG_UUID");
}

@Test
public void insert_user_does_not_set_last_connection_date() {
UserDto user = newUserDto().setLastConnectionDate(10_000_000_000L);
underTest.insert(db.getSession(), user);
db.getSession().commit();

UserDto reloaded = underTest.selectByUuid(db.getSession(), user.getUuid());

assertThat(reloaded.getLastConnectionDate()).isNull();
}

@Test
public void update_user() {
UserDto user = db.users().insertUser(u -> u
@@ -391,7 +402,8 @@ public class UserDaoTest {
.setLocal(false)
.setHomepageType("project")
.setHomepageParameter("OB1")
.setOrganizationUuid("ORG_UUID"));
.setOrganizationUuid("ORG_UUID")
.setLastConnectionDate(10_000_000_000L));

UserDto reloaded = underTest.selectByUuid(db.getSession(), user.getUuid());
assertThat(reloaded).isNotNull();
@@ -413,6 +425,7 @@ public class UserDaoTest {
assertThat(reloaded.getHomepageType()).isEqualTo("project");
assertThat(reloaded.getHomepageParameter()).isEqualTo("OB1");
assertThat(reloaded.getOrganizationUuid()).isEqualTo("ORG_UUID");
assertThat(reloaded.getLastConnectionDate()).isEqualTo(10_000_000_000L);
}

@Test
@@ -420,6 +433,7 @@ public class UserDaoTest {
UserDto user = insertActiveUser();
insertUserGroup(user);
UserDto otherUser = insertActiveUser();
underTest.update(db.getSession(), user.setLastConnectionDate(10_000_000_000L));
session.commit();

underTest.deactivateUser(session, user);
@@ -438,6 +452,7 @@ public class UserDaoTest {
assertThat(userReloaded.getUpdatedAt()).isEqualTo(NOW);
assertThat(userReloaded.getHomepageType()).isNull();
assertThat(userReloaded.getHomepageParameter()).isNull();
assertThat(userReloaded.getLastConnectionDate()).isNull();
assertThat(underTest.selectUserById(session, otherUser.getId())).isNotNull();
}


+ 6
- 0
server/sonar-db-dao/src/test/java/org/sonar/db/user/UserDbTester.java Vedi File

@@ -107,6 +107,12 @@ public class UserDbTester {
return user;
}

public UserDto updateLastConnectionDate(UserDto user, long lastConnectionDate) {
db.getDbClient().userDao().update(db.getSession(), user.setLastConnectionDate(lastConnectionDate));
db.getSession().commit();
return user;
}

public Optional<UserDto> selectUserByLogin(String login) {
return Optional.ofNullable(dbClient.userDao().selectByLogin(db.getSession(), login));
}

+ 15
- 0
server/sonar-db-dao/src/test/java/org/sonar/db/user/UserTokenDaoTest.java Vedi File

@@ -55,6 +55,21 @@ public class UserTokenDaoTest {
assertThat(userTokenFromDb.getUserUuid()).isEqualTo(userToken.getUserUuid());
}

@Test
public void update_last_connection_date() {
UserDto user1 = db.users().insertUser();
UserTokenDto userToken1 = db.users().insertToken(user1);
UserTokenDto userToken2 = db.users().insertToken(user1);
assertThat(underTest.selectByTokenHash(dbSession, userToken1.getTokenHash()).getLastConnectionDate()).isNull();

underTest.update(dbSession, userToken1.setLastConnectionDate(10_000_000_000L));

UserTokenDto userTokenReloaded = underTest.selectByTokenHash(dbSession, userToken1.getTokenHash());
assertThat(userTokenReloaded.getLastConnectionDate()).isEqualTo(10_000_000_000L);
assertThat(userTokenReloaded.getTokenHash()).isEqualTo(userToken1.getTokenHash());
assertThat(userTokenReloaded.getCreatedAt()).isEqualTo(userToken1.getCreatedAt());
}

@Test
public void select_by_token_hash() {
UserDto user = db.users().insertUser();

+ 46
- 0
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUserTokens.java Vedi File

@@ -0,0 +1,46 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package org.sonar.server.platform.db.migration.version.v77;

import java.sql.SQLException;
import org.sonar.db.Database;
import org.sonar.server.platform.db.migration.SupportsBlueGreen;
import org.sonar.server.platform.db.migration.def.BigIntegerColumnDef;
import org.sonar.server.platform.db.migration.sql.AddColumnsBuilder;
import org.sonar.server.platform.db.migration.step.DdlChange;

@SupportsBlueGreen
public class AddLastConnectionDateToUserTokens extends DdlChange {

public AddLastConnectionDateToUserTokens(Database db) {
super(db);
}

@Override
public void execute(Context context) throws SQLException {
context.execute(new AddColumnsBuilder(getDialect(), "user_tokens")
.addColumn(BigIntegerColumnDef.newBigIntegerColumnDefBuilder()
.setColumnName("last_connection_date")
.setIsNullable(true)
.build())
.build());
}

}

+ 46
- 0
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUsers.java Vedi File

@@ -0,0 +1,46 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package org.sonar.server.platform.db.migration.version.v77;

import java.sql.SQLException;
import org.sonar.db.Database;
import org.sonar.server.platform.db.migration.SupportsBlueGreen;
import org.sonar.server.platform.db.migration.def.BigIntegerColumnDef;
import org.sonar.server.platform.db.migration.sql.AddColumnsBuilder;
import org.sonar.server.platform.db.migration.step.DdlChange;

@SupportsBlueGreen
public class AddLastConnectionDateToUsers extends DdlChange {

public AddLastConnectionDateToUsers(Database db) {
super(db);
}

@Override
public void execute(Context context) throws SQLException {
context.execute(new AddColumnsBuilder(getDialect(), "users")
.addColumn(BigIntegerColumnDef.newBigIntegerColumnDefBuilder()
.setColumnName("last_connection_date")
.setIsNullable(true)
.build())
.build());
}

}

+ 3
- 1
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/version/v77/DbVersion77.java Vedi File

@@ -28,6 +28,8 @@ public class DbVersion77 implements DbVersion {
public void addSteps(MigrationStepRegistry registry) {
registry
.add(2600, "Drop elasticsearch index 'tests'", DropElasticsearchIndexTests.class)
.add(2601, "Delete lines with DATA_TYPE='TEST' from table FILES_SOURCE", DeleteTestDataTypeFromFileSources.class);
.add(2601, "Delete lines with DATA_TYPE='TEST' from table FILES_SOURCE", DeleteTestDataTypeFromFileSources.class)
.add(2602, "Add column LAST_CONNECTION_DATE to USERS table", AddLastConnectionDateToUsers.class)
.add(2603, "Add column LAST_USED_DATE to USER_TOKENS table", AddLastConnectionDateToUserTokens.class);
}
}

+ 57
- 0
server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUserTokensTest.java Vedi File

@@ -0,0 +1,57 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

package org.sonar.server.platform.db.migration.version.v77;

import java.sql.SQLException;
import java.sql.Types;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.sonar.db.CoreDbTester;

public class AddLastConnectionDateToUserTokensTest {

private static final String TABLE = "user_tokens";

@Rule
public final CoreDbTester db = CoreDbTester.createForSchema(AddLastConnectionDateToUserTokensTest.class, "user_tokens.sql");
@Rule
public ExpectedException expectedException = ExpectedException.none();

private AddLastConnectionDateToUserTokens underTest = new AddLastConnectionDateToUserTokens(db.database());

@Test
public void add_column() throws SQLException {
underTest.execute();

db.assertColumnDefinition(TABLE, "last_connection_date", Types.BIGINT, null);
}

@Test
public void migration_is_not_re_entrant() throws SQLException {
underTest.execute();

expectedException.expect(IllegalStateException.class);

underTest.execute();
}

}

+ 57
- 0
server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUsersTest.java Vedi File

@@ -0,0 +1,57 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

package org.sonar.server.platform.db.migration.version.v77;

import java.sql.SQLException;
import java.sql.Types;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.sonar.db.CoreDbTester;

public class AddLastConnectionDateToUsersTest {

private static final String TABLE = "users";

@Rule
public final CoreDbTester db = CoreDbTester.createForSchema(AddLastConnectionDateToUsersTest.class, "users.sql");
@Rule
public ExpectedException expectedException = ExpectedException.none();

private AddLastConnectionDateToUsers underTest = new AddLastConnectionDateToUsers(db.database());

@Test
public void add_column() throws SQLException {
underTest.execute();

db.assertColumnDefinition(TABLE, "last_connection_date", Types.BIGINT, null);
}

@Test
public void migration_is_not_re_entrant() throws SQLException {
underTest.execute();

expectedException.expect(IllegalStateException.class);

underTest.execute();
}

}

+ 1
- 1
server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/version/v77/DbVersion77Test.java Vedi File

@@ -36,7 +36,7 @@ public class DbVersion77Test {

@Test
public void verify_migration_count() {
verifyMigrationCount(underTest, 2);
verifyMigrationCount(underTest, 4);
}

}

+ 9
- 0
server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUserTokensTest/user_tokens.sql Vedi File

@@ -0,0 +1,9 @@
CREATE TABLE "USER_TOKENS" (
"ID" INTEGER NOT NULL GENERATED BY DEFAULT AS IDENTITY (START WITH 1, INCREMENT BY 1),
"USER_UUID" VARCHAR(255) NOT NULL,
"NAME" VARCHAR(100) NOT NULL,
"TOKEN_HASH" VARCHAR(255) NOT NULL,
"CREATED_AT" BIGINT NOT NULL
);
CREATE UNIQUE INDEX "USER_TOKENS_TOKEN_HASH" ON "USER_TOKENS" ("TOKEN_HASH");
CREATE UNIQUE INDEX "USER_TOKENS_USER_UUID_NAME" ON "USER_TOKENS" ("USER_UUID", "NAME");

+ 28
- 0
server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/version/v77/AddLastConnectionDateToUsersTest/users.sql Vedi File

@@ -0,0 +1,28 @@
CREATE TABLE "USERS" (
"ID" INTEGER NOT NULL GENERATED BY DEFAULT AS IDENTITY (START WITH 1, INCREMENT BY 1),
"UUID" VARCHAR(255) NOT NULL,
"LOGIN" VARCHAR(255) NOT NULL,
"NAME" VARCHAR(200),
"EMAIL" VARCHAR(100),
"CRYPTED_PASSWORD" VARCHAR(100),
"SALT" VARCHAR(40),
"HASH_METHOD" VARCHAR(10),
"ACTIVE" BOOLEAN DEFAULT TRUE,
"SCM_ACCOUNTS" VARCHAR(4000),
"EXTERNAL_ID" VARCHAR(255) NOT NULL,
"EXTERNAL_LOGIN" VARCHAR(255) NOT NULL,
"EXTERNAL_IDENTITY_PROVIDER" VARCHAR(100) NOT NULL,
"IS_ROOT" BOOLEAN NOT NULL,
"USER_LOCAL" BOOLEAN,
"ONBOARDED" BOOLEAN NOT NULL,
"HOMEPAGE_TYPE" VARCHAR(40),
"HOMEPAGE_PARAMETER" VARCHAR(40),
"ORGANIZATION_UUID" VARCHAR(40),
"CREATED_AT" BIGINT,
"UPDATED_AT" BIGINT
);
CREATE UNIQUE INDEX "USERS_UUID" ON "USERS" ("UUID");
CREATE UNIQUE INDEX "USERS_LOGIN" ON "USERS" ("LOGIN");
CREATE UNIQUE INDEX "UNIQ_EXTERNAL_ID" ON "USERS" ("EXTERNAL_IDENTITY_PROVIDER", "EXTERNAL_ID");
CREATE UNIQUE INDEX "UNIQ_EXTERNAL_LOGIN" ON "USERS" ("EXTERNAL_IDENTITY_PROVIDER", "EXTERNAL_LOGIN");
CREATE INDEX "USERS_UPDATED_AT" ON "USERS" ("UPDATED_AT");

+ 2
- 1
server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java Vedi File

@@ -52,6 +52,7 @@ public class AuthenticationModule extends Module {
BasicAuthentication.class,
ValidateAction.class,
HttpHeadersAuthentication.class,
RequestAuthenticatorImpl.class);
RequestAuthenticatorImpl.class,
UserLastConnectionDatesUpdaterImpl.class);
}
}

+ 31
- 0
server/sonar-server/src/main/java/org/sonar/server/authentication/UserLastConnectionDatesUpdater.java Vedi File

@@ -0,0 +1,31 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

package org.sonar.server.authentication;

import org.sonar.db.user.UserDto;
import org.sonar.db.user.UserTokenDto;

public interface UserLastConnectionDatesUpdater {

void updateLastConnectionDateIfNeeded(UserDto user);

void updateLastConnectionDateIfNeeded(UserTokenDto userToken);
}

+ 73
- 0
server/sonar-server/src/main/java/org/sonar/server/authentication/UserLastConnectionDatesUpdaterImpl.java Vedi File

@@ -0,0 +1,73 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

package org.sonar.server.authentication;

import javax.annotation.Nullable;
import org.sonar.api.utils.System2;
import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.user.UserDto;
import org.sonar.db.user.UserTokenDto;

public class UserLastConnectionDatesUpdaterImpl implements UserLastConnectionDatesUpdater {

private static final long ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000L;

private final DbClient dbClient;
private final System2 system2;

public UserLastConnectionDatesUpdaterImpl(DbClient dbClient, System2 system2) {
this.dbClient = dbClient;
this.system2 = system2;
}

@Override
public void updateLastConnectionDateIfNeeded(UserDto user) {
Long lastConnectionDate = user.getLastConnectionDate();
long now = system2.now();
if (doesNotRequireUpdate(lastConnectionDate, now)) {
return;
}
try (DbSession dbSession = dbClient.openSession(false)) {
dbClient.userDao().update(dbSession, user.setLastConnectionDate(now));
dbSession.commit();
}
}

@Override
public void updateLastConnectionDateIfNeeded(UserTokenDto userToken) {
Long lastConnectionDate = userToken.getLastConnectionDate();
long now = system2.now();
if (doesNotRequireUpdate(lastConnectionDate, now)) {
return;
}
try (DbSession dbSession = dbClient.openSession(false)) {
dbClient.userTokenDao().update(dbSession, userToken.setLastConnectionDate(now));
userToken.setLastConnectionDate(now);
dbSession.commit();
}
}

private static boolean doesNotRequireUpdate(@Nullable Long lastConnectionDate, long now) {
// Update date only once per hour in order to decrease pressure on DB
return lastConnectionDate != null && (now - lastConnectionDate) < ONE_HOUR_IN_MILLISECONDS;
}
}

+ 5
- 1
server/sonar-server/src/main/java/org/sonar/server/user/UserSessionFactoryImpl.java Vedi File

@@ -22,6 +22,7 @@ package org.sonar.server.user;
import org.sonar.api.server.ServerSide;
import org.sonar.db.DbClient;
import org.sonar.db.user.UserDto;
import org.sonar.server.authentication.UserLastConnectionDatesUpdater;
import org.sonar.server.organization.DefaultOrganizationProvider;
import org.sonar.server.organization.OrganizationFlags;

@@ -33,17 +34,20 @@ public class UserSessionFactoryImpl implements UserSessionFactory {
private final DbClient dbClient;
private final DefaultOrganizationProvider defaultOrganizationProvider;
private final OrganizationFlags organizationFlags;
private final UserLastConnectionDatesUpdater userLastConnectionDatesUpdater;

public UserSessionFactoryImpl(DbClient dbClient, DefaultOrganizationProvider defaultOrganizationProvider,
OrganizationFlags organizationFlags) {
OrganizationFlags organizationFlags, UserLastConnectionDatesUpdater userLastConnectionDatesUpdater) {
this.dbClient = dbClient;
this.defaultOrganizationProvider = defaultOrganizationProvider;
this.organizationFlags = organizationFlags;
this.userLastConnectionDatesUpdater = userLastConnectionDatesUpdater;
}

@Override
public ServerUserSession create(UserDto user) {
requireNonNull(user, "UserDto must not be null");
userLastConnectionDatesUpdater.updateLastConnectionDateIfNeeded(user);
return new ServerUserSession(dbClient, organizationFlags, defaultOrganizationProvider, user);
}


+ 16
- 5
server/sonar-server/src/main/java/org/sonar/server/user/ws/SearchAction.java Vedi File

@@ -24,6 +24,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
@@ -48,10 +49,12 @@ import org.sonarqube.ws.Users.SearchWsResponse;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Strings.emptyToNull;
import static java.util.Optional.ofNullable;
import static org.sonar.api.server.ws.WebService.Param.FIELDS;
import static org.sonar.api.server.ws.WebService.Param.PAGE;
import static org.sonar.api.server.ws.WebService.Param.PAGE_SIZE;
import static org.sonar.api.server.ws.WebService.Param.TEXT_QUERY;
import static org.sonar.api.utils.DateUtils.formatDateTime;
import static org.sonar.api.utils.Paging.forPageIndex;
import static org.sonar.core.util.stream.MoreCollectors.toList;
import static org.sonar.server.es.SearchOptions.MAX_LIMIT;
@@ -91,11 +94,19 @@ public class SearchAction implements UsersWsAction {
public void define(WebService.NewController controller) {
WebService.NewAction action = controller.createAction("search")
.setDescription("Get a list of active users. <br/>" +
"Administer System permission is required to show the 'groups' field.<br/>" +
"Field 'tokensCount' is only accessible to System Administrator and logged in user.<br/>" +
"When accessed anonymously, only logins and names are returned.")
"The following fields are only returned when user has Administer System permission or for logged-in in user :" +
"<ul>" +
" <li>'email'</li>" +
" <li>'externalIdentity'</li>" +
" <li>'externalProvider'</li>" +
" <li>'groups'</li>" +
" <li>'lastConnectionDate'</li>" +
" <li>'tokensCount'</li>" +
"</ul>" +
"Field 'lastConnectionDate' is only updated every hour, so it may not be accurate, for instance when a user authenticates many times in less than one hour.")
.setSince("3.6")
.setChangelog(
new Change("7.7", "New field 'lastConnectionDate' is added to response"),
new Change("7.4", "External identity is only returned to system administrators"),
new Change("6.4", "Paging response fields moved to a Paging object"),
new Change("6.4", "Avatar has been added to the response"),
@@ -153,16 +164,16 @@ public class SearchAction implements UsersWsAction {
setIfNeeded(FIELD_ACTIVE, fields, user.isActive(), userBuilder::setActive);
setIfNeeded(FIELD_LOCAL, fields, user.isLocal(), userBuilder::setLocal);
setIfNeeded(FIELD_EXTERNAL_PROVIDER, fields, user.getExternalIdentityProvider(), userBuilder::setExternalProvider);
setIfNeeded(isNeeded(FIELD_TOKENS_COUNT, fields) && user.getLogin().equals(userSession.getLogin()), tokensCount, userBuilder::setTokensCount);
setIfNeeded(isNeeded(FIELD_SCM_ACCOUNTS, fields) && !user.getScmAccountsAsList().isEmpty(), user.getScmAccountsAsList(),
scm -> userBuilder.setScmAccounts(ScmAccounts.newBuilder().addAllScmAccounts(scm)));
}
if (userSession.isSystemAdministrator()) {
if (userSession.isSystemAdministrator() || Objects.equals(userSession.getUuid(), user.getUuid())) {
setIfNeeded(FIELD_EMAIL, fields, user.getEmail(), userBuilder::setEmail);
setIfNeeded(isNeeded(FIELD_GROUPS, fields) && !groups.isEmpty(), groups,
g -> userBuilder.setGroups(Groups.newBuilder().addAllGroups(g)));
setIfNeeded(FIELD_EXTERNAL_IDENTITY, fields, user.getExternalLogin(), userBuilder::setExternalIdentity);
setIfNeeded(FIELD_TOKENS_COUNT, fields, tokensCount, userBuilder::setTokensCount);
ofNullable(user.getLastConnectionDate()).ifPresent(date -> userBuilder.setLastConnectionDate(formatDateTime(date)));
}
return userBuilder.build();
}

+ 5
- 1
server/sonar-server/src/main/java/org/sonar/server/usertoken/UserTokenAuthentication.java Vedi File

@@ -23,6 +23,7 @@ import java.util.Optional;
import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.user.UserTokenDto;
import org.sonar.server.authentication.UserLastConnectionDatesUpdater;

import static java.util.Optional.empty;
import static java.util.Optional.of;
@@ -30,10 +31,12 @@ import static java.util.Optional.of;
public class UserTokenAuthentication {
private final TokenGenerator tokenGenerator;
private final DbClient dbClient;
private final UserLastConnectionDatesUpdater userLastConnectionDatesUpdater;

public UserTokenAuthentication(TokenGenerator tokenGenerator, DbClient dbClient) {
public UserTokenAuthentication(TokenGenerator tokenGenerator, DbClient dbClient, UserLastConnectionDatesUpdater userLastConnectionDatesUpdater) {
this.tokenGenerator = tokenGenerator;
this.dbClient = dbClient;
this.userLastConnectionDatesUpdater = userLastConnectionDatesUpdater;
}

/**
@@ -48,6 +51,7 @@ public class UserTokenAuthentication {
if (userToken == null) {
return empty();
}
userLastConnectionDatesUpdater.updateLastConnectionDateIfNeeded(userToken);
return of(userToken.getUserUuid());
}
}

+ 5
- 0
server/sonar-server/src/main/java/org/sonar/server/usertoken/ws/SearchAction.java Vedi File

@@ -20,6 +20,7 @@
package org.sonar.server.usertoken.ws;

import java.util.List;
import org.sonar.api.server.ws.Change;
import org.sonar.api.server.ws.Request;
import org.sonar.api.server.ws.Response;
import org.sonar.api.server.ws.WebService;
@@ -29,6 +30,7 @@ import org.sonar.db.user.UserDto;
import org.sonar.db.user.UserTokenDto;
import org.sonarqube.ws.UserTokens.SearchWsResponse;

import static java.util.Optional.ofNullable;
import static org.sonar.api.utils.DateUtils.formatDateTime;
import static org.sonar.server.usertoken.ws.UserTokenSupport.ACTION_SEARCH;
import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_LOGIN;
@@ -49,7 +51,9 @@ public class SearchAction implements UserTokensWsAction {
WebService.NewAction action = context.createAction(ACTION_SEARCH)
.setDescription("List the access tokens of a user.<br>" +
"The login must exist and active.<br>" +
"Field 'lastConnectionDate' is only updated every hour, so it may not be accurate, for instance when a user is using a token many times in less than one hour.<br/" +
"It requires administration permissions to specify a 'login' and list the tokens of another user. Otherwise, tokens for the current user are listed.")
.setChangelog(new Change("7.7", "New field 'lastConnectionDate' is added to response"))
.setResponseExample(getClass().getResource("search-example.json"))
.setSince("5.3")
.setHandler(this);
@@ -82,6 +86,7 @@ public class SearchAction implements UserTokensWsAction {
.clear()
.setName(userTokenDto.getName())
.setCreatedAt(formatDateTime(userTokenDto.getCreatedAt()));
ofNullable(userTokenDto.getLastConnectionDate()).ifPresent(date -> userTokenBuilder.setLastConnectionDate(formatDateTime(date)));
searchWsResponse.addUserTokens(userTokenBuilder);
}


+ 1
- 1
server/sonar-server/src/test/java/org/sonar/server/authentication/AuthenticationModuleTest.java Vedi File

@@ -30,7 +30,7 @@ public class AuthenticationModuleTest {
public void verify_count_of_added_components() {
ComponentContainer container = new ComponentContainer();
new AuthenticationModule().configure(container);
assertThat(container.size()).isEqualTo(2 + 23);
assertThat(container.size()).isEqualTo(2 + 24);
}

}

+ 115
- 0
server/sonar-server/src/test/java/org/sonar/server/authentication/UserLastConnectionDatesUpdaterImplTest.java Vedi File

@@ -0,0 +1,115 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

package org.sonar.server.authentication;

import org.junit.Rule;
import org.junit.Test;
import org.sonar.api.utils.System2;
import org.sonar.api.utils.internal.TestSystem2;
import org.sonar.db.DbTester;
import org.sonar.db.user.UserDto;
import org.sonar.db.user.UserTokenDto;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

public class UserLastConnectionDatesUpdaterImplTest {

private static final long NOW = 10_000_000_000L;
private static final long ONE_MINUTE = 60_000L;
private static final long ONE_HOUR = ONE_MINUTE * 60L;
private static final long TWO_HOUR = ONE_HOUR * 2L;

@Rule
public DbTester db = DbTester.create();

private System2 system2 = new TestSystem2().setNow(NOW);

private UserLastConnectionDatesUpdaterImpl underTest = new UserLastConnectionDatesUpdaterImpl(db.getDbClient(), system2);

@Test
public void update_last_connection_date_from_user_when_last_connection_was_more_than_one_hour() {
UserDto user = db.users().insertUser();
db.users().updateLastConnectionDate(user, NOW - TWO_HOUR);

underTest.updateLastConnectionDateIfNeeded(user);

UserDto userReloaded = db.getDbClient().userDao().selectByUuid(db.getSession(), user.getUuid());
assertThat(userReloaded.getLastConnectionDate()).isEqualTo(NOW);
}

@Test
public void update_last_connection_date_from_user_when_no_last_connection_date() {
UserDto user = db.users().insertUser();

underTest.updateLastConnectionDateIfNeeded(user);

UserDto userReloaded = db.getDbClient().userDao().selectByUuid(db.getSession(), user.getUuid());
assertThat(userReloaded.getLastConnectionDate()).isEqualTo(NOW);
}

@Test
public void do_not_update_when_last_connection_from_user_was_less_than_one_hour() {
UserDto user = db.users().insertUser();
db.users().updateLastConnectionDate(user, NOW - ONE_MINUTE);

underTest.updateLastConnectionDateIfNeeded(user);

UserDto userReloaded = db.getDbClient().userDao().selectByUuid(db.getSession(), user.getUuid());
assertThat(userReloaded.getLastConnectionDate()).isEqualTo(NOW - ONE_MINUTE);
}

@Test
public void update_last_connection_date_from_user_token_when_last_connection_was_more_than_one_hour() {
UserDto user = db.users().insertUser();
UserTokenDto userToken = db.users().insertToken(user);
db.getDbClient().userTokenDao().update(db.getSession(), userToken.setLastConnectionDate(NOW - TWO_HOUR));
db.commit();

underTest.updateLastConnectionDateIfNeeded(userToken);

UserTokenDto userTokenReloaded = db.getDbClient().userTokenDao().selectByTokenHash(db.getSession(), userToken.getTokenHash());
assertThat(userTokenReloaded.getLastConnectionDate()).isEqualTo(NOW);
}

@Test
public void update_last_connection_date_from_user_token_when_no_last_connection_date() {
UserDto user = db.users().insertUser();
UserTokenDto userToken = db.users().insertToken(user);

underTest.updateLastConnectionDateIfNeeded(userToken);

UserTokenDto userTokenReloaded = db.getDbClient().userTokenDao().selectByTokenHash(db.getSession(), userToken.getTokenHash());
assertThat(userTokenReloaded.getLastConnectionDate()).isEqualTo(NOW);
}

@Test
public void do_not_update_when_last_connection_from_user_token_was_less_than_one_hour() {
UserDto user = db.users().insertUser();
UserTokenDto userToken = db.users().insertToken(user);
db.getDbClient().userTokenDao().update(db.getSession(), userToken.setLastConnectionDate(NOW - ONE_MINUTE));
db.commit();

underTest.updateLastConnectionDateIfNeeded(userToken);

UserTokenDto userTokenReloaded = db.getDbClient().userTokenDao().selectByTokenHash(db.getSession(), userToken.getTokenHash());
assertThat(userTokenReloaded.getLastConnectionDate()).isEqualTo(NOW - ONE_MINUTE);
}
}

+ 52
- 25
server/sonar-server/src/test/java/org/sonar/server/user/ws/SearchActionTest.java Vedi File

@@ -42,6 +42,7 @@ import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static org.sonar.api.utils.DateUtils.formatDateTime;
import static org.sonar.test.JsonAssert.assertJson;

public class SearchActionTest {
@@ -132,26 +133,6 @@ public class SearchActionTest {
.containsExactlyInAnyOrder(tuple(user.getLogin(), asList("john1", "john2")));
}

@Test
public void return_tokens_count_for_logged_user() {
UserDto user = db.users().insertUser();
db.users().insertToken(user);
db.users().insertToken(user);
userIndexer.indexOnStartup(null);

userSession.logIn();
assertThat(ws.newRequest()
.executeProtobuf(SearchWsResponse.class).getUsersList())
.extracting(User::getLogin, User::hasTokensCount)
.containsExactlyInAnyOrder(tuple(user.getLogin(), false));

userSession.logIn(user);
assertThat(ws.newRequest()
.executeProtobuf(SearchWsResponse.class).getUsersList())
.extracting(User::getLogin, User::getTokensCount)
.containsExactlyInAnyOrder(tuple(user.getLogin(), 2));
}

@Test
public void return_tokens_count_when_system_administer() {
UserDto user = db.users().insertUser();
@@ -167,7 +148,7 @@ public class SearchActionTest {

userSession.logIn();
assertThat(ws.newRequest()
.executeProtobuf(SearchWsResponse.class).getUsersList())
.executeProtobuf(SearchWsResponse.class).getUsersList())
.extracting(User::getLogin, User::hasTokensCount)
.containsExactlyInAnyOrder(tuple(user.getLogin(), false));
}
@@ -249,14 +230,14 @@ public class SearchActionTest {
userSession.logIn().setSystemAdministrator();
assertThat(ws.newRequest()
.executeProtobuf(SearchWsResponse.class).getUsersList())
.extracting(User::getLogin, User::getExternalIdentity)
.containsExactlyInAnyOrder(tuple(user.getLogin(), user.getExternalLogin()));
.extracting(User::getLogin, User::getExternalIdentity)
.containsExactlyInAnyOrder(tuple(user.getLogin(), user.getExternalLogin()));

userSession.logIn();
assertThat(ws.newRequest()
.executeProtobuf(SearchWsResponse.class).getUsersList())
.extracting(User::getLogin, User::hasExternalIdentity)
.containsExactlyInAnyOrder(tuple(user.getLogin(), false));
.extracting(User::getLogin, User::hasExternalIdentity)
.containsExactlyInAnyOrder(tuple(user.getLogin(), false));
}

@Test
@@ -276,6 +257,52 @@ public class SearchActionTest {
.containsExactlyInAnyOrder(tuple(user.getLogin(), user.getName(), false, false, false, false));
}

@Test
public void return_last_connection_date_when_system_administer() {
UserDto userWithLastConnectionDate = db.users().insertUser();
db.users().updateLastConnectionDate(userWithLastConnectionDate, 10_000_000_000L);
UserDto userWithoutLastConnectionDate = db.users().insertUser();
userIndexer.indexOnStartup(null);
userSession.logIn().setSystemAdministrator();

SearchWsResponse response = ws.newRequest()
.executeProtobuf(SearchWsResponse.class);

assertThat(response.getUsersList())
.extracting(User::getLogin, User::hasLastConnectionDate, User::getLastConnectionDate)
.containsExactlyInAnyOrder(
tuple(userWithLastConnectionDate.getLogin(), true, formatDateTime(10_000_000_000L)),
tuple(userWithoutLastConnectionDate.getLogin(), false, ""));
}

@Test
public void return_all_fields_for_logged_user() {
UserDto user = db.users().insertUser();
db.users().updateLastConnectionDate(user, 10_000_000_000L);
db.users().insertToken(user);
db.users().insertToken(user);
GroupDto group = db.users().insertGroup();
db.users().insertMember(group, user);
UserDto otherUser = db.users().insertUser();
userIndexer.indexOnStartup(null);

userSession.logIn(user);
assertThat(ws.newRequest().setParam("q", user.getLogin())
.executeProtobuf(SearchWsResponse.class).getUsersList())
.extracting(User::getLogin, User::getName, User::getEmail, User::getExternalIdentity, User::getExternalProvider,
User::hasScmAccounts, User::hasAvatar, User::hasGroups, User::getTokensCount, User::hasLastConnectionDate)
.containsExactlyInAnyOrder(
tuple(user.getLogin(), user.getName(), user.getEmail(), user.getExternalLogin(), user.getExternalIdentityProvider(), true, true, true, 2, true));

userSession.logIn(otherUser);
assertThat(ws.newRequest().setParam("q", user.getLogin())
.executeProtobuf(SearchWsResponse.class).getUsersList())
.extracting(User::getLogin, User::getName, User::hasEmail, User::hasExternalIdentity, User::hasExternalProvider,
User::hasScmAccounts, User::hasAvatar, User::hasGroups, User::hasTokensCount, User::hasLastConnectionDate)
.containsExactlyInAnyOrder(
tuple(user.getLogin(), user.getName(), false, false, true, true, true, false, false, false));
}

@Test
public void search_with_fields() {
UserDto user = db.users().insertUser();

+ 10
- 1
server/sonar-server/src/test/java/org/sonar/server/usertoken/UserTokenAuthenticationTest.java Vedi File

@@ -26,9 +26,14 @@ import org.junit.rules.ExpectedException;
import org.sonar.api.utils.System2;
import org.sonar.db.DbTester;
import org.sonar.db.user.UserDto;
import org.sonar.db.user.UserTokenDto;
import org.sonar.server.authentication.UserLastConnectionDatesUpdater;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class UserTokenAuthenticationTest {
@@ -40,8 +45,9 @@ public class UserTokenAuthenticationTest {
public DbTester db = DbTester.create(System2.INSTANCE);

private TokenGenerator tokenGenerator = mock(TokenGenerator.class);
private UserLastConnectionDatesUpdater userLastConnectionDatesUpdater = mock(UserLastConnectionDatesUpdater.class);

private UserTokenAuthentication underTest = new UserTokenAuthentication(tokenGenerator, db.getDbClient());
private UserTokenAuthentication underTest = new UserTokenAuthentication(tokenGenerator, db.getDbClient(), userLastConnectionDatesUpdater);

@Test
public void return_login_when_token_hash_found_in_db() {
@@ -57,11 +63,14 @@ public class UserTokenAuthenticationTest {

assertThat(login.isPresent()).isTrue();
assertThat(login.get()).isEqualTo(user1.getUuid());
verify(userLastConnectionDatesUpdater).updateLastConnectionDateIfNeeded(any(UserTokenDto.class));
}

@Test
public void return_absent_if_token_hash_is_not_found() {
Optional<String> login = underTest.authenticate("unknown-token");

assertThat(login.isPresent()).isFalse();
verify(userLastConnectionDatesUpdater, never()).updateLastConnectionDateIfNeeded(any(UserTokenDto.class));
}
}

+ 22
- 0
server/sonar-server/src/test/java/org/sonar/server/usertoken/ws/SearchActionTest.java Vedi File

@@ -27,6 +27,7 @@ import org.sonar.api.utils.System2;
import org.sonar.db.DbClient;
import org.sonar.db.DbTester;
import org.sonar.db.user.UserDto;
import org.sonar.db.user.UserTokenDto;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.NotFoundException;
import org.sonar.server.exceptions.UnauthorizedException;
@@ -34,8 +35,11 @@ import org.sonar.server.tester.UserSessionRule;
import org.sonar.server.ws.TestRequest;
import org.sonar.server.ws.WsActionTester;
import org.sonarqube.ws.UserTokens.SearchWsResponse;
import org.sonarqube.ws.UserTokens.SearchWsResponse.UserToken;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static org.sonar.api.utils.DateUtils.formatDateTime;
import static org.sonar.server.usertoken.ws.UserTokenSupport.PARAM_LOGIN;
import static org.sonar.test.JsonAssert.assertJson;

@@ -79,6 +83,24 @@ public class SearchActionTest {
assertThat(response.getUserTokensCount()).isEqualTo(1);
}

@Test
public void return_last_connection_date() {
UserDto user = db.users().insertUser();
UserTokenDto token1 = db.users().insertToken(user);
UserTokenDto token2 = db.users().insertToken(user);
db.getDbClient().userTokenDao().update(db.getSession(), token1.setLastConnectionDate(10_000_000_000L));
db.commit();
logInAsSystemAdministrator();

SearchWsResponse response = newRequest(user.getLogin());

assertThat(response.getUserTokensList())
.extracting(UserToken::getName, UserToken::hasLastConnectionDate, UserToken::getLastConnectionDate)
.containsExactlyInAnyOrder(
tuple(token1.getName(), true, formatDateTime(10_000_000_000L)),
tuple(token2.getName(), false, ""));
}

@Test
public void fail_when_login_does_not_exist() {
logInAsSystemAdministrator();

+ 3
- 17
server/sonar-web/src/main/js/api/user-tokens.ts Vedi File

@@ -20,29 +20,15 @@
import { getJSON, postJSON, post } from '../helpers/request';
import throwGlobalError from '../app/utils/throwGlobalError';

export interface UserToken {
name: string;
createdAt: string;
}

/** List tokens for given user login */
export function getTokens(login: string): Promise<UserToken[]> {
export function getTokens(login: string): Promise<T.UserToken[]> {
return getJSON('/api/user_tokens/search', { login }).then(r => r.userTokens, throwGlobalError);
}

export interface NewToken {
createdAt: string;
login: string;
name: string;
token: string;
}

/** Generate a user token */
export function generateToken(data: { name: string; login?: string }): Promise<NewToken> {
export function generateToken(data: { name: string; login?: string }): Promise<T.NewUserToken> {
return postJSON('/api/user_tokens/generate', data).catch(throwGlobalError);
}

/** Revoke a user token */
export function revokeToken(data: { name: string; login?: string }): Promise<void | Response> {
export function revokeToken(data: { name: string; login?: string }) {
return post('/api/user_tokens/revoke', data).catch(throwGlobalError);
}

+ 12
- 0
server/sonar-web/src/main/js/app/types.d.ts Vedi File

@@ -859,6 +859,7 @@ declare namespace T {
externalIdentity?: string;
externalProvider?: string;
groups?: string[];
lastConnectionDate?: string;
local: boolean;
login: string;
name: string;
@@ -866,6 +867,17 @@ declare namespace T {
tokensCount?: number;
}

export interface UserToken {
name: string;
createdAt: string;
lastConnectionDate?: string;
}

export interface NewUserToken extends UserToken {
login: string;
token: string;
}

export type Visibility = 'public' | 'private';

export interface Webhook {

+ 3
- 3
server/sonar-web/src/main/js/apps/tutorials/components/TokenStep.tsx Vedi File

@@ -22,10 +22,10 @@ import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router';
import * as classNames from 'classnames';
import Step from './Step';
import { getTokens, generateToken, revokeToken, UserToken } from '../../../api/user-tokens';
import AlertErrorIcon from '../../../components/icons-components/AlertErrorIcon';
import AlertSuccessIcon from '../../../components/icons-components/AlertSuccessIcon';
import { DeleteButton, SubmitButton, Button } from '../../../components/ui/buttons';
import { getTokens, generateToken, revokeToken } from '../../../api/user-tokens';
import { translate } from '../../../helpers/l10n';

interface Props {
@@ -44,7 +44,7 @@ interface State {
selection: string;
tokenName?: string;
token?: string;
tokens?: UserToken[];
tokens?: T.UserToken[];
}

export default class TokenStep extends React.PureComponent<Props, State> {
@@ -85,7 +85,7 @@ export default class TokenStep extends React.PureComponent<Props, State> {
getToken = () =>
this.state.selection === 'generate' ? this.state.token : this.state.existingToken;

getUniqueTokenName = (tokens: UserToken[]) => {
getUniqueTokenName = (tokens: T.UserToken[]) => {
const { initialTokenName = '' } = this.props;
const hasToken = (name: string) => tokens.find(token => token.name === name) !== undefined;


+ 1
- 0
server/sonar-web/src/main/js/apps/users/UsersList.tsx Vedi File

@@ -46,6 +46,7 @@ export default function UsersList({
<th />
<th className="nowrap" />
<th className="nowrap">{translate('my_profile.scm_accounts')}</th>
<th className="nowrap">{translate('users.last_connection')}</th>
{!organizationsEnabled && <th className="nowrap">{translate('my_profile.groups')}</th>}
<th className="nowrap">{translate('users.tokens')}</th>
<th className="nowrap">&nbsp;</th>

+ 5
- 0
server/sonar-web/src/main/js/apps/users/__tests__/__snapshots__/UsersList-test.tsx.snap Vedi File

@@ -19,6 +19,11 @@ exports[`should render correctly 1`] = `
>
my_profile.scm_accounts
</th>
<th
className="nowrap"
>
users.last_connection
</th>
<th
className="nowrap"
>

+ 4
- 3
server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx Vedi File

@@ -20,9 +20,9 @@
import * as React from 'react';
import TokensFormItem from './TokensFormItem';
import TokensFormNewToken from './TokensFormNewToken';
import { getTokens, generateToken, UserToken } from '../../../api/user-tokens';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import { SubmitButton } from '../../../components/ui/buttons';
import { getTokens, generateToken } from '../../../api/user-tokens';
import { translate } from '../../../helpers/l10n';

interface Props {
@@ -35,7 +35,7 @@ interface State {
loading: boolean;
newToken?: { name: string; token: string };
newTokenName: string;
tokens: UserToken[];
tokens: T.UserToken[];
}

export default class TokensForm extends React.PureComponent<Props, State> {
@@ -103,7 +103,7 @@ export default class TokensForm extends React.PureComponent<Props, State> {
}
};

handleRevokeToken = (revokedToken: UserToken) => {
handleRevokeToken = (revokedToken: T.UserToken) => {
this.setState(
state => ({
tokens: state.tokens.filter(token => token.name !== revokedToken.name)
@@ -175,6 +175,7 @@ export default class TokensForm extends React.PureComponent<Props, State> {
<thead>
<tr>
<th>{translate('name')}</th>
<th>{translate('my_account.tokens_last_usage')}</th>
<th className="text-right">{translate('created')}</th>
<th />
</tr>

+ 10
- 6
server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx Vedi File

@@ -18,18 +18,19 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { revokeToken, UserToken } from '../../../api/user-tokens';
import DateFormatter from '../../../components/intl/DateFormatter';
import DateFromNowHourPrecision from '../../../components/intl/DateFromNowHourPrecision';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import Tooltip from '../../../components/controls/Tooltip';
import DateFormatter from '../../../components/intl/DateFormatter';
import { Button } from '../../../components/ui/buttons';
import { translate } from '../../../helpers/l10n';
import { limitComponentName } from '../../../helpers/path';
import { revokeToken } from '../../../api/user-tokens';
import { translate } from '../../../helpers/l10n';

interface Props {
login: string;
onRevokeToken: (token: UserToken) => void;
token: UserToken;
onRevokeToken: (token: T.UserToken) => void;
token: T.UserToken;
}

interface State {
@@ -75,12 +76,15 @@ export default class TokensFormItem extends React.PureComponent<Props, State> {
<span>{limitComponentName(token.name)}</span>
</Tooltip>
</td>
<td className="nowrap">
<DateFromNowHourPrecision date={token.lastConnectionDate} />
</td>
<td className="thin nowrap text-right">
<DateFormatter date={token.createdAt} long={true} />
</td>
<td className="thin nowrap text-right">
<DeferredSpinner loading={loading}>
<i className="spinner-placeholder " />
<i className="spinner-placeholder" />
</DeferredSpinner>
<Button
className="button-red input-small spacer-left"

+ 5
- 1
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx Vedi File

@@ -23,8 +23,9 @@ import UserActions from './UserActions';
import UserGroups from './UserGroups';
import UserListItemIdentity from './UserListItemIdentity';
import UserScmAccounts from './UserScmAccounts';
import BulletListIcon from '../../../components/icons-components/BulletListIcon';
import Avatar from '../../../components/ui/Avatar';
import BulletListIcon from '../../../components/icons-components/BulletListIcon';
import DateFromNowHourPrecision from '../../../components/intl/DateFromNowHourPrecision';
import { ButtonIcon } from '../../../components/ui/buttons';
import { translate } from '../../../helpers/l10n';

@@ -59,6 +60,9 @@ export default class UserListItem extends React.PureComponent<Props, State> {
<td>
<UserScmAccounts scmAccounts={user.scmAccounts || []} />
</td>
<td>
<DateFromNowHourPrecision date={user.lastConnectionDate} />
</td>
{!organizationsEnabled && (
<td>
<UserGroups groups={user.groups || []} onUpdateUsers={onUpdateUsers} user={user} />

+ 83
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/TokensForm-test.tsx Vedi File

@@ -0,0 +1,83 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { shallow } from 'enzyme';
import TokensForm from '../TokensForm';
import { change, submit, waitAndUpdate } from '../../../../helpers/testUtils';
import { generateToken, getTokens } from '../../../../api/user-tokens';

jest.mock('../../../../api/user-tokens', () => ({
generateToken: jest.fn().mockResolvedValue({
name: 'baz',
createdAt: '2019-01-21T08:06:00+0100',
login: 'luke',
token: 'token_value'
}),
getTokens: jest.fn().mockResolvedValue([
{
name: 'foo',
createdAt: '2019-01-15T15:06:33+0100',
lastConnectionDate: '2019-01-18T15:06:33+0100'
},
{ name: 'bar', createdAt: '2019-01-18T15:06:33+0100' }
])
}));

beforeEach(() => {
(generateToken as jest.Mock).mockClear();
(getTokens as jest.Mock).mockClear();
});

it('should render correctly', async () => {
const wrapper = shallowRender();
expect(wrapper).toMatchSnapshot();
expect(getTokens).toHaveBeenCalledWith('luke');

await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
});

it('should create new tokens', async () => {
const wrapper = shallowRender();

await waitAndUpdate(wrapper);
expect(wrapper.find('TokensFormItem')).toHaveLength(2);
change(wrapper.find('input'), 'baz');
submit(wrapper.find('form'));

await waitAndUpdate(wrapper);
expect(generateToken).toHaveBeenCalledWith({ name: 'baz', login: 'luke' });
expect(wrapper.find('TokensFormItem')).toHaveLength(3);
});

it('should revoke tokens', async () => {
const updateTokensCount = jest.fn();
const wrapper = shallowRender({ updateTokensCount });

await waitAndUpdate(wrapper);
expect(wrapper.find('TokensFormItem')).toHaveLength(2);
wrapper.instance().handleRevokeToken({ createdAt: '2019-01-15T15:06:33+0100', name: 'foo' });
expect(updateTokensCount).toHaveBeenCalledWith('luke', 1);
expect(wrapper.find('TokensFormItem')).toHaveLength(1);
});

function shallowRender(props: Partial<TokensForm['props']> = {}) {
return shallow<TokensForm>(<TokensForm login="luke" updateTokensCount={jest.fn()} {...props} />);
}

+ 65
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/TokensFormItem-test.tsx Vedi File

@@ -0,0 +1,65 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { shallow } from 'enzyme';
import TokensFormItem from '../TokensFormItem';
import { revokeToken } from '../../../../api/user-tokens';
import { click, waitAndUpdate } from '../../../../helpers/testUtils';

jest.mock('../../../../components/intl/DateFormatter');
jest.mock('../../../../components/intl/DateFromNow');
jest.mock('../../../../components/intl/DateTimeFormatter');

jest.mock('../../../../api/user-tokens', () => ({
revokeToken: jest.fn().mockResolvedValue(undefined)
}));

const userToken: T.UserToken = {
name: 'foo',
createdAt: '2019-01-15T15:06:33+0100',
lastConnectionDate: '2019-01-18T15:06:33+0100'
};

beforeEach(() => {
(revokeToken as jest.Mock).mockClear();
});

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot();
});

it('should revoke the token', async () => {
const onRevokeToken = jest.fn();
const wrapper = shallowRender({ onRevokeToken });
expect(wrapper.find('Button')).toMatchSnapshot();
click(wrapper.find('Button'));
expect(wrapper.find('Button')).toMatchSnapshot();
click(wrapper.find('Button'));
expect(wrapper.find('DeferredSpinner').prop('loading')).toBe(true);
await waitAndUpdate(wrapper);
expect(revokeToken).toHaveBeenCalledWith({ login: 'luke', name: 'foo' });
expect(onRevokeToken).toHaveBeenCalledWith(userToken);
});

function shallowRender(props: Partial<TokensFormItem['props']> = {}) {
return shallow(
<TokensFormItem login="luke" onRevokeToken={jest.fn()} token={userToken} {...props} />
);
}

+ 16
- 8
server/sonar-web/src/main/js/apps/users/components/__tests__/UserListItem-test.tsx Vedi File

@@ -22,33 +22,41 @@ import { shallow } from 'enzyme';
import { click } from '../../../../helpers/testUtils';
import UserListItem from '../UserListItem';

const user = {
jest.mock('../../../../components/intl/DateFromNow');
jest.mock('../../../../components/intl/DateTimeFormatter');

const user: T.User = {
active: true,
lastConnectionDate: '2019-01-18T15:06:33+0100',
local: false,
login: 'obi',
name: 'One',
active: true,
scmAccounts: [],
local: false
scmAccounts: []
};

it('should render correctly', () => {
expect(getWrapper()).toMatchSnapshot();
expect(shallowRender()).toMatchSnapshot();
});

it('should render correctly without last connection date', () => {
expect(shallowRender({})).toMatchSnapshot();
});

it('should display a change password button', () => {
expect(
getWrapper({ organizationsEnabled: true })
shallowRender({ organizationsEnabled: true })
.find('UserGroups')
.exists()
).toBeFalsy();
});

it('should open the correct forms', () => {
const wrapper = getWrapper();
const wrapper = shallowRender();
click(wrapper.find('.js-user-tokens'));
expect(wrapper.find('TokensFormModal').exists()).toBeTruthy();
});

function getWrapper(props = {}) {
function shallowRender(props: Partial<UserListItem['props']> = {}) {
return shallow(
<UserListItem
isCurrentUser={false}

+ 168
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensForm-test.tsx.snap Vedi File

@@ -0,0 +1,168 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly 1`] = `
<Fragment>
<h3
className="spacer-bottom"
>
users.generate_tokens
</h3>
<form
autoComplete="off"
className="display-flex-center"
id="generate-token-form"
onSubmit={[Function]}
>
<input
className="spacer-right"
maxLength={100}
onChange={[Function]}
placeholder="users.enter_token_name"
required={true}
type="text"
value=""
/>
<SubmitButton
className="js-generate-token"
disabled={true}
>
users.generate
</SubmitButton>
</form>
<table
className="data zebra big-spacer-top "
>
<thead>
<tr>
<th>
name
</th>
<th>
my_account.tokens_last_usage
</th>
<th
className="text-right"
>
created
</th>
<th />
</tr>
</thead>
<tbody>
<DeferredSpinner
customSpinner={
<tr>
<td>
<i
className="spinner"
/>
</td>
</tr>
}
loading={true}
timeout={100}
>
<tr>
<td
className="note"
colSpan={3}
>
users.no_tokens
</td>
</tr>
</DeferredSpinner>
</tbody>
</table>
</Fragment>
`;

exports[`should render correctly 2`] = `
<Fragment>
<h3
className="spacer-bottom"
>
users.generate_tokens
</h3>
<form
autoComplete="off"
className="display-flex-center"
id="generate-token-form"
onSubmit={[Function]}
>
<input
className="spacer-right"
maxLength={100}
onChange={[Function]}
placeholder="users.enter_token_name"
required={true}
type="text"
value=""
/>
<SubmitButton
className="js-generate-token"
disabled={true}
>
users.generate
</SubmitButton>
</form>
<table
className="data zebra big-spacer-top "
>
<thead>
<tr>
<th>
name
</th>
<th>
my_account.tokens_last_usage
</th>
<th
className="text-right"
>
created
</th>
<th />
</tr>
</thead>
<tbody>
<DeferredSpinner
customSpinner={
<tr>
<td>
<i
className="spinner"
/>
</td>
</tr>
}
loading={false}
timeout={100}
>
<TokensFormItem
key="foo"
login="luke"
onRevokeToken={[Function]}
token={
Object {
"createdAt": "2019-01-15T15:06:33+0100",
"lastConnectionDate": "2019-01-18T15:06:33+0100",
"name": "foo",
}
}
/>
<TokensFormItem
key="bar"
login="luke"
onRevokeToken={[Function]}
token={
Object {
"createdAt": "2019-01-18T15:06:33+0100",
"name": "bar",
}
}
/>
</DeferredSpinner>
</tbody>
</table>
</Fragment>
`;

+ 69
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/TokensFormItem-test.tsx.snap Vedi File

@@ -0,0 +1,69 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly 1`] = `
<tr>
<td>
<Tooltip
overlay="foo"
>
<span>
foo
</span>
</Tooltip>
</td>
<td
className="nowrap"
>
<DateFromNowHourPrecision
date="2019-01-18T15:06:33+0100"
/>
</td>
<td
className="thin nowrap text-right"
>
<DateFormatter
date="2019-01-15T15:06:33+0100"
long={true}
/>
</td>
<td
className="thin nowrap text-right"
>
<DeferredSpinner
loading={false}
timeout={100}
>
<i
className="spinner-placeholder"
/>
</DeferredSpinner>
<Button
className="button-red input-small spacer-left"
disabled={false}
onClick={[Function]}
>
users.tokens.revoke
</Button>
</td>
</tr>
`;

exports[`should revoke the token 1`] = `
<Button
className="button-red input-small spacer-left"
disabled={false}
onClick={[Function]}
>
users.tokens.revoke
</Button>
`;

exports[`should revoke the token 2`] = `
<Button
className="button-red input-small spacer-left"
disabled={false}
onClick={[Function]}
>
users.tokens.sure
</Button>
`;

+ 86
- 0
server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserListItem-test.tsx.snap Vedi File

@@ -14,6 +14,7 @@ exports[`should render correctly 1`] = `
user={
Object {
"active": true,
"lastConnectionDate": "2019-01-18T15:06:33+0100",
"local": false,
"login": "obi",
"name": "One",
@@ -26,6 +27,89 @@ exports[`should render correctly 1`] = `
scmAccounts={Array []}
/>
</td>
<td>
<DateFromNowHourPrecision
date="2019-01-18T15:06:33+0100"
/>
</td>
<td>
<UserGroups
groups={Array []}
onUpdateUsers={[MockFunction]}
user={
Object {
"active": true,
"lastConnectionDate": "2019-01-18T15:06:33+0100",
"local": false,
"login": "obi",
"name": "One",
"scmAccounts": Array [],
}
}
/>
</td>
<td>
<ButtonIcon
className="js-user-tokens spacer-left button-small"
onClick={[Function]}
tooltip="users.update_tokens"
>
<BulletListIcon />
</ButtonIcon>
</td>
<td
className="thin nowrap text-right"
>
<UserActions
isCurrentUser={false}
onUpdateUsers={[MockFunction]}
user={
Object {
"active": true,
"lastConnectionDate": "2019-01-18T15:06:33+0100",
"local": false,
"login": "obi",
"name": "One",
"scmAccounts": Array [],
}
}
/>
</td>
</tr>
`;

exports[`should render correctly without last connection date 1`] = `
<tr>
<td
className="thin nowrap"
>
<Connect(Avatar)
name="One"
size={36}
/>
</td>
<UserListItemIdentity
user={
Object {
"active": true,
"lastConnectionDate": "2019-01-18T15:06:33+0100",
"local": false,
"login": "obi",
"name": "One",
"scmAccounts": Array [],
}
}
/>
<td>
<UserScmAccounts
scmAccounts={Array []}
/>
</td>
<td>
<DateFromNowHourPrecision
date="2019-01-18T15:06:33+0100"
/>
</td>
<td>
<UserGroups
groups={Array []}
@@ -33,6 +117,7 @@ exports[`should render correctly 1`] = `
user={
Object {
"active": true,
"lastConnectionDate": "2019-01-18T15:06:33+0100",
"local": false,
"login": "obi",
"name": "One",
@@ -59,6 +144,7 @@ exports[`should render correctly 1`] = `
user={
Object {
"active": true,
"lastConnectionDate": "2019-01-18T15:06:33+0100",
"local": false,
"login": "obi",
"name": "One",

+ 56
- 0
server/sonar-web/src/main/js/components/intl/DateFromNowHourPrecision.tsx Vedi File

@@ -0,0 +1,56 @@
/*
* SonarQube
* Copyright (C) 2009-2019 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { DateSource } from 'react-intl';
import DateFromNow from './DateFromNow';
import DateTimeFormatter from './DateTimeFormatter';
import Tooltip from '../controls/Tooltip';
import { differenceInHours } from '../../helpers/dates';
import { translate } from '../../helpers/l10n';

interface Props {
children?: (formattedDate: string) => React.ReactNode;
date?: DateSource;
}

export default class DateFromNowHourPrecision extends React.PureComponent<Props> {
render() {
const { children, date } = this.props;

let overrideDate: string | undefined;
if (!date) {
overrideDate = translate('never');
} else if (differenceInHours(Date.now(), date) < 1) {
overrideDate = translate('less_than_1_hour_ago');
}

if (overrideDate) {
return children ? children(overrideDate) : overrideDate;
}

return (
<Tooltip overlay={<DateTimeFormatter date={date!} />}>
<span>
<DateFromNow date={date!}>{children}</DateFromNow>
</span>
</Tooltip>
);
}
}

+ 5
- 0
server/sonar-web/src/main/js/helpers/dates.ts Vedi File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as _differenceInDays from 'date-fns/difference_in_days';
import * as _differenceInHours from 'date-fns/difference_in_hours';
import * as _differenceInSeconds from 'date-fns/difference_in_seconds';
import * as _differenceInYears from 'date-fns/difference_in_years';
import * as _isSameDay from 'date-fns/is_same_day';
@@ -67,6 +68,10 @@ export function differenceInDays(dateLeft: ParsableDate, dateRight: ParsableDate
return _differenceInDays(dateLeft, dateRight);
}

export function differenceInHours(dateLeft: ParsableDate, dateRight: ParsableDate): number {
return _differenceInHours(dateLeft, dateRight);
}

export function differenceInSeconds(dateLeft: ParsableDate, dateRight: ParsableDate): number {
return _differenceInSeconds(dateLeft, dateRight);
}

+ 3
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties Vedi File

@@ -233,6 +233,7 @@ facet_might_have_more_results=There might be more results, try another set of fi
false_positive=False positive
go_back_to_homepage=Go back to the homepage
last_analysis_before=Last analysis before
less_than_1_hour_ago=< 1 hour ago
logging_out=You're logging out, please wait...
manage=Manage
management=Management
@@ -1500,6 +1501,7 @@ my_account.no_project_notifications=You have not set project notifications yet.
my_account.profile=Profile
my_account.security=Security
my_account.tokens_description=If you want to enforce security by not providing credentials of a real {instance} user to run your code scan or to invoke web services, you can provide a User Token as a replacement of the user login. This will increase the security of your installation by not letting your analysis user's password going through your network.
my_account.tokens_last_usage=Last usage
my_account.projects=Projects
my_account.projects.description=Those projects are the ones you are administering.
my_account.projects.no_results=You are not administering any project yet.
@@ -3002,6 +3004,7 @@ users.create_user=Create User
users.update_user=Update User
users.minimum_x_characters=Minimum {0} characters
users.email=Email
users.last_connection=Last connection
users.update_groups=Update Groups
users.update_tokens=Update Tokens
users.add=Add user

+ 1
- 0
sonar-ws/src/main/protobuf/ws-user_tokens.proto Vedi File

@@ -40,5 +40,6 @@ message SearchWsResponse {
message UserToken {
optional string name = 1;
optional string createdAt = 2;
optional string lastConnectionDate = 3;
}
}

+ 1
- 0
sonar-ws/src/main/protobuf/ws-users.proto Vedi File

@@ -43,6 +43,7 @@ message SearchWsResponse {
optional string externalIdentity = 9;
optional string externalProvider = 10;
optional string avatar = 11;
optional string lastConnectionDate = 12;
}

message Groups {

Loading…
Annulla
Salva