• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

smartsheet / smartsheet-java-sdk / #44

25 Aug 2023 05:39PM UTC coverage: 50.55% (+0.1%) from 50.427%
#44

push

github-actions

web-flow
Fix remaining Checkstyle violations and Enable Checkstyle (#58)

* Fix remaining Checkstyle violations and Enable Checkstyle

We are now down to `20` checkstyle violations in main and `0` violations in test.

The remaining 20 violations are not trivial to fix, so I've set checkstyle to allow those 20 violations to exist, but to fail the build if we ever exceed 20 violations. This should make the build fail if any new violations are added.

For tests, we do not allow _any_ violations. This means adding a single violation will fail the build. Once the 20 violations in main are cleaned up, we can make main and test have the same config.

Note: This MR also changes our PR pipeline to run `./gradlew clean build` instead of `./gradlew clean test`. The reason for this is that build runs all the tests and performs all the other checks (such as checkstyle), whereas `test` didn't run checkstyle and we wouldn't have noticed violations until we tried to deploy.

148 of 148 new or added lines in 24 files covered. (100.0%)

3448 of 6821 relevant lines covered (50.55%)

0.51 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/main/java/com/smartsheet/api/internal/oauth/OAuthFlowImpl.java
1
package com.smartsheet.api.internal.oauth;
2

3
/*
4
 * #[license]
5
 * Smartsheet SDK for Java
6
 * %%
7
 * Copyright (C) 2023 Smartsheet
8
 * %%
9
 * Licensed under the Apache License, Version 2.0 (the "License");
10
 * you may not use this file except in compliance with the License.
11
 * You may obtain a copy of the License at
12
 *
13
 *      http://www.apache.org/licenses/LICENSE-2.0
14
 *
15
 * Unless required by applicable law or agreed to in writing, software
16
 * distributed under the License is distributed on an "AS IS" BASIS,
17
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
 * See the License for the specific language governing permissions and
19
 * limitations under the License.
20
 * %[license]
21
 */
22

23
import com.smartsheet.api.InvalidRequestException;
24
import com.smartsheet.api.internal.http.HttpClient;
25
import com.smartsheet.api.internal.http.HttpClientException;
26
import com.smartsheet.api.internal.http.HttpMethod;
27
import com.smartsheet.api.internal.http.HttpRequest;
28
import com.smartsheet.api.internal.http.HttpResponse;
29
import com.smartsheet.api.internal.json.JSONSerializerException;
30
import com.smartsheet.api.internal.json.JsonSerializer;
31
import com.smartsheet.api.internal.util.QueryUtil;
32
import com.smartsheet.api.internal.util.Util;
33
import com.smartsheet.api.oauth.AccessDeniedException;
34
import com.smartsheet.api.oauth.AccessScope;
35
import com.smartsheet.api.oauth.AuthorizationResult;
36
import com.smartsheet.api.oauth.InvalidOAuthClientException;
37
import com.smartsheet.api.oauth.InvalidOAuthGrantException;
38
import com.smartsheet.api.oauth.InvalidScopeException;
39
import com.smartsheet.api.oauth.InvalidTokenRequestException;
40
import com.smartsheet.api.oauth.OAuthAuthorizationCodeException;
41
import com.smartsheet.api.oauth.OAuthFlow;
42
import com.smartsheet.api.oauth.OAuthTokenException;
43
import com.smartsheet.api.oauth.Token;
44
import com.smartsheet.api.oauth.UnsupportedOAuthGrantTypeException;
45
import com.smartsheet.api.oauth.UnsupportedResponseTypeException;
46

47
import java.io.InputStream;
48
import java.net.URI;
49
import java.net.URISyntaxException;
50
import java.nio.charset.StandardCharsets;
51
import java.security.MessageDigest;
52
import java.security.NoSuchAlgorithmException;
53
import java.util.EnumSet;
54
import java.util.HashMap;
55
import java.util.Map;
56

57
/**
58
 * Default implementation of OAuthFlow.
59
 * <p>
60
 * Thread Safety: Implementation of this interface must be thread safe.
61
 */
62
public class OAuthFlowImpl implements OAuthFlow {
63
    /**
64
     * Represents the HttpClient.
65
     * <p>
66
     * It will be initialized in constructor and will not change afterwards.
67
     */
68
    private HttpClient httpClient;
69

70
    /**
71
     * Represents the JsonSerializer.
72
     * <p>
73
     * It will be initialized in constructor and will not change afterwards.
74
     */
75
    private JsonSerializer jsonSerializer;
76

77
    /**
78
     * Represents the Client ID.
79
     * <p>
80
     * It will be initialized in constructor and will not change afterwards.
81
     */
82
    private String clientId;
83

84
    /**
85
     * Represents the Client Secret.
86
     * <p>
87
     * It will be initialized in constructor and will not change afterwards.
88
     */
89
    private String clientSecret;
90

91
    /**
92
     * Represents the redirect URL.
93
     * <p>
94
     * It will be initialized in constructor and will not change afterwards.
95
     */
96
    private String redirectURL;
97

98
    /**
99
     * Represents the authorization URL.
100
     * <p>
101
     * It will be initialized in constructor and will not change afterwards.
102
     */
103
    private String authorizationURL;
104

105
    /**
106
     * Represents the token URL.
107
     * <p>
108
     * It will be initialized in constructor and will not change afterwards.
109
     */
110
    private String tokenURL;
111

112
    private static final String CODE = "code";
113
    private static final String CLIENT_ID = "client_id";
114
    private static final String REDIRECT_URI = "redirect_uri";
115
    private static final String ERROR = "error";
116
    private static final String REFRESH_TOKEN = "refresh_token";
117

118
    /**
119
     * Constructor.
120
     * <p>
121
     * @param clientId the client id
122
     * @param clientSecret the client secret
123
     * @param redirectURL the redirect url
124
     * @param authorizationURL the authorization url
125
     * @param tokenURL the token url
126
     * @param httpClient the http client
127
     * @param jsonSerializer the json serializer
128
     * @throws IllegalArgumentException If any argument is null, or empty string.
129
     */
130
    public OAuthFlowImpl(String clientId, String clientSecret, String redirectURL, String authorizationURL,
131
                         String tokenURL, HttpClient httpClient, JsonSerializer jsonSerializer) {
×
132
        Util.throwIfNull(clientId, clientSecret, redirectURL, authorizationURL, tokenURL, httpClient, jsonSerializer);
×
133
        Util.throwIfEmpty(clientId, clientSecret, redirectURL, authorizationURL, tokenURL);
×
134

135
        this.clientId = clientId;
×
136
        this.clientSecret = clientSecret;
×
137
        this.redirectURL = redirectURL;
×
138
        this.authorizationURL = authorizationURL;
×
139
        this.tokenURL = tokenURL;
×
140
        this.httpClient = httpClient;
×
141
        this.jsonSerializer = jsonSerializer;
×
142
    }
×
143

144
    /**
145
     * Generate a new authorization URL.
146
     * <p>
147
     * Exceptions: - IllegalArgumentException : if scopes is null/empty
148
     *
149
     * @param scopes the scopes
150
     * @param state an arbitrary string that will be returned to your app; intended to be used by you to ensure that
151
     *              this redirect is indeed from an OAuth flow that you initiated
152
     * @return the authorization URL
153
     */
154
    public String newAuthorizationURL(EnumSet<AccessScope> scopes, String state) {
155
        Util.throwIfNull(scopes);
×
156
        if (state == null) {
×
157
            state = "";
×
158
        }
159

160
        // Build a map of parameters for the URL
161
        Map<String, Object> params = new HashMap<>();
×
162
        params.put("response_type", CODE);
×
163
        params.put(CLIENT_ID, clientId);
×
164
        params.put(REDIRECT_URI, redirectURL);
×
165
        params.put("state", state);
×
166

167
        StringBuilder scopeBuffer = new StringBuilder();
×
168
        for (AccessScope scope : scopes) {
×
169
            scopeBuffer.append(scope.name() + ",");
×
170
        }
×
171
        params.put("scope", scopeBuffer.substring(0, scopeBuffer.length() - 1));
×
172

173
        // Generate the URL with the parameters
174
        return QueryUtil.generateUrl(authorizationURL, params);
×
175
    }
176

177
    /**
178
     * Extract AuthorizationResult from the authorization response URL (i.e. the redirectURL with the response
179
     * parameters from Smartsheet OAuth server).
180
     * <p>
181
     * Exceptions:
182
     * <p>
183
     *   - IllegalArgumentException : if authorizationResponseURL is null/empty, or a malformed URL
184
     * <p>
185
     *   - AccessDeniedException : if the user has denied the authorization request
186
     * <p>
187
     *   - UnsupportedResponseTypeException : if the response type isn't supported
188
     *   (note that this won't really happen in current implementation)
189
     * <p>
190
     *   - InvalidScopeException : if some of the specified scopes are invalid
191
     * <p>
192
     *   - OAuthAuthorizationCodeException : if any other error occurred during the operation
193
     *
194
     * @param authorizationResponseURL the authorization response URL
195
     * @return the authorization result
196
     * @throws URISyntaxException the URI syntax exception
197
     * @throws OAuthAuthorizationCodeException the o auth authorization code exception
198
     */
199
    public AuthorizationResult extractAuthorizationResult(String authorizationResponseURL)
200
            throws URISyntaxException, OAuthAuthorizationCodeException {
201
        Util.throwIfNull(authorizationResponseURL);
×
202
        Util.throwIfEmpty(authorizationResponseURL);
×
203

204
        // Get all of the parms from the URL
205
        URI uri = new URI(authorizationResponseURL);
×
206
        String query = uri.getQuery();
×
207
        if (query == null) {
×
208
            throw new OAuthAuthorizationCodeException("There must be a query string in the response URL");
×
209
        }
210

211
        Map<String, String> map = new HashMap<>();
×
212
        for (String param : query.split("&")) {
×
213
            int index = param.indexOf('=');
×
214
            map.put(param.substring(0, index), param.substring(index + 1));
×
215
        }
216

217
        // Check for an error response in the URL and throw it.
218
        String error = map.get(ERROR);
×
219
        if (error != null && !error.isEmpty()) {
×
220
            if ("access_denied".equals(error)) {
×
221
                throw new AccessDeniedException("Access denied.");
×
222
            } else if ("unsupported_response_type".equals(error)) {
×
223
                throw new UnsupportedResponseTypeException("response_type must be set to \"code\".");
×
224
            } else if ("invalid_scope".equals(error)) {
×
225
                throw new InvalidScopeException("One or more of the requested access scopes are invalid. " +
×
226
                        "Please check the list of access scopes");
227
            } else {
228
                throw new OAuthAuthorizationCodeException("An undefined error was returned of type: " + error);
×
229
            }
230
        }
231

232
        AuthorizationResult authorizationResult = new AuthorizationResult();
×
233
        authorizationResult.setCode(map.get(CODE));
×
234
        authorizationResult.setState(map.get("state"));
×
235
        Long expiresIn;
236
        try {
237
            expiresIn = Long.parseLong(map.get("expires_in"));
×
238
        } catch (NumberFormatException ex) {
×
239
            expiresIn = 0L;
×
240
        }
×
241
        authorizationResult.setExpiresInSeconds(expiresIn);
×
242

243
        return authorizationResult;
×
244
    }
245

246
    /**
247
     * Obtain a new token using AuthorizationResult.
248
     * <p>
249
     * Exceptions:
250
     *   - IllegalArgumentException : if authorizationResult is null
251
     *   - InvalidTokenRequestException : if the token request is invalid (note that this won't really happen in current implementation)
252
     *   - InvalidOAuthClientException : if the client information is invalid
253
     *   - InvalidOAuthGrantException : if the authorization code or refresh token is invalid or expired, the
254
     *   redirect_uri does not match, or the hash value does not match the client secret and/or code
255
     *   - UnsupportedOAuthGrantTypeException : if the grant type is invalid (note that this won't really happen in
256
     *   current implementation)
257
     *   - OAuthTokenException : if any other error occurred during the operation
258
     *
259
     * @param authorizationResult the authorization result
260
     * @return the token
261
     * @throws OAuthTokenException the o auth token exception
262
     * @throws JSONSerializerException the JSON serializer exception
263
     * @throws HttpClientException the http client exception
264
     * @throws URISyntaxException the URI syntax exception
265
     * @throws InvalidRequestException the invalid request exception
266
     */
267
    public Token obtainNewToken(AuthorizationResult authorizationResult)
268
            throws OAuthTokenException, JSONSerializerException, HttpClientException, URISyntaxException, InvalidRequestException {
269
        if (authorizationResult == null) {
×
270
            throw new IllegalArgumentException();
×
271
        }
272

273
        // Prepare the hash
274

275
        String doHash = clientSecret + "|" + authorizationResult.getCode();
×
276

277
        MessageDigest md;
278
        try {
279
            md = MessageDigest.getInstance("SHA-256");
×
280
        } catch (NoSuchAlgorithmException e) {
×
281
            throw new RuntimeException("Your JVM does not support SHA-256, which is required for OAuth with Smartsheet.", e);
×
282
        }
×
283
        byte[] digest;
284
        digest = md.digest(doHash.getBytes(StandardCharsets.UTF_8));
×
285

286
        //String hash = javax.xml.bind.DatatypeConverter.printHexBinary(digest);
287
        String hash = org.apache.commons.codec.binary.Hex.encodeHexString(digest);
×
288

289
        // create a Map of the parameters
290
        Map<String, Object> params = new HashMap<>();
×
291
        params.put("grant_type", "authorization_code");
×
292
        params.put(CLIENT_ID, clientId);
×
293
        params.put(CODE, authorizationResult.getCode());
×
294
        params.put(REDIRECT_URI, redirectURL);
×
295
        params.put("hash", hash);
×
296

297
        // Generate the URL and then get the token
298
        return requestToken(QueryUtil.generateUrl(tokenURL, params));
×
299
    }
300

301
    /**
302
     * Refresh token.
303
     * <p>
304
     * Exceptions:
305
     *   - IllegalArgumentException : if token is null.
306
     *   - InvalidTokenRequestException : if the token request is invalid
307
     *   - InvalidOAuthClientException : if the client information is invalid
308
     *   - InvalidOAuthGrantException : if the authorization code or refresh token is invalid or expired,
309
     *   the redirect_uri does not match, or the hash value does not match the client secret and/or code
310
     *   - UnsupportedOAuthGrantTypeException : if the grant type is invalid
311
     *   - OAuthTokenException : if any other error occurred during the operation
312
     *
313
     * @param token the token to refresh
314
     * @return the refreshed token
315
     * @throws OAuthTokenException the o auth token exception
316
     * @throws JSONSerializerException the JSON serializer exception
317
     * @throws HttpClientException the http client exception
318
     * @throws URISyntaxException the URI syntax exception
319
     * @throws InvalidRequestException the invalid request exception
320
     */
321
    public Token refreshToken(Token token)
322
            throws OAuthTokenException, JSONSerializerException, HttpClientException, URISyntaxException, InvalidRequestException {
323
        // Prepare the hash
324
        String doHash = clientSecret + "|" + token.getRefreshToken();
×
325
        MessageDigest md;
326
        try {
327
            md = MessageDigest.getInstance("SHA-256");
×
328
        } catch (NoSuchAlgorithmException e) {
×
329
            throw new RuntimeException("Your JVM does not support SHA-256, which is required for OAuth with Smartsheet.", e);
×
330
        }
×
331
        byte[] digest;
332
        digest = md.digest(doHash.getBytes(StandardCharsets.UTF_8));
×
333
        //String hash = javax.xml.bind.DatatypeConverter.printHexBinary(digest);
334
        String hash = org.apache.commons.codec.binary.Hex.encodeHexString(digest);
×
335

336
        // Create a map of the parameters
337
        Map<String, Object> params = new HashMap<>();
×
338
        params.put("grant_type", REFRESH_TOKEN);
×
339
        params.put(CLIENT_ID, clientId);
×
340
        params.put(REFRESH_TOKEN, token.getRefreshToken());
×
341
        params.put(REDIRECT_URI, redirectURL);
×
342
        params.put("hash", hash);
×
343

344
        // Generate the URL and get the token
345
        return requestToken(QueryUtil.generateUrl(tokenURL, params));
×
346
    }
347

348
    /**
349
     * Request a token.
350
     * <p>
351
     * Exceptions:
352
     *   - IllegalArgumentException : if url is null or empty
353
     *   - InvalidTokenRequestException : if the token request is invalid
354
     *   - InvalidOAuthClientException : if the client information is invalid
355
     *   - InvalidOAuthGrantException : if the authorization code or refresh token is invalid or
356
     *   expired, the redirect_uri does not match, or the hash value does not match the client secret and/or code
357
     *   - UnsupportedOAuthGrantTypeException : if the grant type is invalid
358
     *   - OAuthTokenException : if any other error occurred during the operation
359
     *
360
     * @param url the URL (with request parameters) from which the token will be requested
361
     * @return the token
362
     * @throws OAuthTokenException the o auth token exception
363
     * @throws JSONSerializerException the JSON serializer exception
364
     * @throws HttpClientException the http client exception
365
     * @throws URISyntaxException the URI syntax exception
366
     * @throws InvalidRequestException the invalid request exception
367
     */
368
    private Token requestToken(String url) throws OAuthTokenException, JSONSerializerException, HttpClientException,
369
            URISyntaxException, InvalidRequestException {
370

371
        // Create the request and send it to get the response/token.
372
        HttpRequest request = new HttpRequest();
×
373
        request.setUri(new URI(url));
×
374
        request.setMethod(HttpMethod.POST);
×
375
        request.setHeaders(new HashMap<>());
×
376
        request.getHeaders().put("Content-Type", "application/x-www-form-urlencoded");
×
377
        HttpResponse response = httpClient.request(request);
×
378

379
        // Create a map of the response
380
        InputStream inputStream = response.getEntity().getContent();
×
381
        Map<String, Object> map = jsonSerializer.deserializeMap(inputStream);
×
382
        httpClient.releaseConnection();
×
383

384
        // Check for a error response and throw it.
385
        if (response.getStatusCode() != 200 && map.get(ERROR) != null) {
×
386
            String errorType = map.get(ERROR).toString();
×
387
            String errorDescription = map.get("message") == null ? "" : (String) map.get("message");
×
388
            if ("invalid_request".equals(errorType)) {
×
389
                throw new InvalidTokenRequestException(errorDescription);
×
390
            } else if ("invalid_client".equals(errorType)) {
×
391
                throw new InvalidOAuthClientException(errorDescription);
×
392
            } else if ("invalid_grant".equals(errorType)) {
×
393
                throw new InvalidOAuthGrantException(errorDescription);
×
394
            } else if ("unsupported_grant_type".equals(errorType)) {
×
395
                throw new UnsupportedOAuthGrantTypeException(errorDescription);
×
396
            } else {
397
                throw new OAuthTokenException(errorDescription);
×
398
            }
399
        } else if (response.getStatusCode() != 200) {
×
400
            // Another error by not getting a 200 result
401
            throw new OAuthTokenException("Token request failed with http error code: " + response.getStatusCode());
×
402
        }
403

404
        // Create a token based on the response
405
        Token token = new Token();
×
406
        Object tempObj = map.get("access_token");
×
407
        token.setAccessToken(tempObj == null ? "" : (String) tempObj);
×
408
        tempObj = map.get("token_type");
×
409
        token.setTokenType(tempObj == null ? "" : (String) tempObj);
×
410
        tempObj = map.get(REFRESH_TOKEN);
×
411
        token.setRefreshToken(tempObj == null ? "" : (String) tempObj);
×
412

413
        Long expiresIn;
414
        try {
415
            expiresIn = Long.parseLong(String.valueOf(map.get("expires_in")));
×
416
        } catch (NumberFormatException nfe) {
×
417
            expiresIn = 0L;
×
418
        }
×
419
        token.setExpiresInSeconds(expiresIn);
×
420

421
        return token;
×
422
    }
423

424
    /**
425
     * Revoke access token.
426
     * <p>
427
     * Exceptions:
428
     *   - IllegalArgumentException : if url is null or empty
429
     *   - InvalidTokenRequestException : if the token request is invalid
430
     *   - InvalidOAuthClientException : if the client information is invalid
431
     *   - InvalidOAuthGrantException : if the authorization code or refresh token is invalid or
432
     *   expired, the redirect_uri does not match, or the hash value does not match the client secret and/or code
433
     *   - UnsupportedOAuthGrantTypeException : if the grant type is invalid
434
     *   - OAuthTokenException : if any other error occurred during the operation
435
     *
436
     * @param token the access token to revoke access from
437
     * @throws OAuthTokenException the o auth token exception
438
     * @throws JSONSerializerException the JSON serializer exception
439
     * @throws HttpClientException the http client exception
440
     * @throws URISyntaxException the URI syntax exception
441
     * @throws InvalidRequestException the invalid request exception
442
     */
443
    public void revokeAccessToken(Token token) throws OAuthTokenException, JSONSerializerException, HttpClientException,
444
            URISyntaxException, InvalidRequestException {
445
        HttpRequest request = new HttpRequest();
×
446
        request.setUri(new URI(tokenURL));
×
447
        request.setMethod(HttpMethod.DELETE);
×
448

449
        request.setHeaders(new HashMap<>());
×
450
        request.getHeaders().put("Authorization", "Bearer " + token.getAccessToken());
×
451
        HttpResponse response = httpClient.request(request);
×
452

453
        if (response.getStatusCode() != 200) {
×
454
            throw new OAuthTokenException("Token request failed with http error code: " + response.getStatusCode());
×
455
        }
456

457
        httpClient.releaseConnection();
×
458
    }
×
459

460
    /**
461
     * Gets the http client.
462
     *
463
     * @return the http client
464
     */
465
    public HttpClient getHttpClient() {
466
        return httpClient;
×
467
    }
468

469
    /**
470
     * Sets the http client.
471
     *
472
     * @param httpClient the new http client
473
     */
474
    public void setHttpClient(HttpClient httpClient) {
475
        this.httpClient = httpClient;
×
476
    }
×
477

478
    /**
479
     * Gets the json serializer.
480
     *
481
     * @return the json serializer
482
     */
483
    public JsonSerializer getJsonSerializer() {
484
        return jsonSerializer;
×
485
    }
486

487
    /**
488
     * Sets the json serializer.
489
     *
490
     * @param jsonSerializer the new json serializer
491
     */
492
    public void setJsonSerializer(JsonSerializer jsonSerializer) {
493
        this.jsonSerializer = jsonSerializer;
×
494
    }
×
495

496
    /**
497
     * Gets the client id.
498
     *
499
     * @return the client id
500
     */
501
    public String getClientId() {
502
        return clientId;
×
503
    }
504

505
    /**
506
     * Sets the client id.
507
     *
508
     * @param clientId the new client id
509
     */
510
    public void setClientId(String clientId) {
511
        this.clientId = clientId;
×
512
    }
×
513

514
    /**
515
     * Gets the client secret.
516
     *
517
     * @return the client secret
518
     */
519
    public String getClientSecret() {
520
        return clientSecret;
×
521
    }
522

523
    /**
524
     * Sets the client secret.
525
     *
526
     * @param clientSecret the new client secret
527
     */
528
    public void setClientSecret(String clientSecret) {
529
        this.clientSecret = clientSecret;
×
530
    }
×
531

532
    /**
533
     * Gets the redirect url.
534
     *
535
     * @return the redirect url
536
     */
537
    public String getRedirectURL() {
538
        return redirectURL;
×
539
    }
540

541
    /**
542
     * Sets the redirect url.
543
     *
544
     * @param redirectURL the new redirect url
545
     */
546
    public void setRedirectURL(String redirectURL) {
547
        this.redirectURL = redirectURL;
×
548
    }
×
549

550
    /**
551
     * Gets the authorization url.
552
     *
553
     * @return the authorization url
554
     */
555
    public String getAuthorizationURL() {
556
        return authorizationURL;
×
557
    }
558

559
    /**
560
     * Sets the authorization url.
561
     *
562
     * @param authorizationURL the new authorization url
563
     */
564
    public void setAuthorizationURL(String authorizationURL) {
565
        this.authorizationURL = authorizationURL;
×
566
    }
×
567

568
    /**
569
     * Gets the token url.
570
     *
571
     * @return the token url
572
     */
573
    public String getTokenURL() {
574
        return tokenURL;
×
575
    }
576

577
    /**
578
     * Sets the token url.
579
     *
580
     * @param tokenURL the new token url
581
     */
582
    public void setTokenURL(String tokenURL) {
583
        this.tokenURL = tokenURL;
×
584
    }
×
585
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc