diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 192846b..0bd9aa2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,11 +11,45 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 - with: - java-version: 17 - distribution: temurin - cache: maven - - name: Test - run: ./mvnw -B package -Dquarkus.native.container-build=true + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: temurin + cache: maven + - name: Test + run: ./mvnw -B package + env: + GMAIL_CLIENT_ID: dummy + GMAIL_CLIENT_SECRET: dummy + GMAIL_REFRESH_TOKEN: dummy + GMAIL_USER_EMAIL: bot@test.com + QUARKUS_GITHUB_APP_APP_ID: 12345 + QUARKUS_GITHUB_APP_PRIVATE_KEY: | + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAxlVR3TIkouAOvH79vaJTgFhpfvVKQIeVkFRZPVXK/zY0Gvrh + 4JAqGjJoW/PfrQv5sdD36qtHH3a+G5hLZ6Ni+t/mtfjucxZfuLGC3kmJ1T3XqEKZ + gXXI2IR7vVSoImREvDQGEDyJwtHzLANlkbGg0cghVhWZSCAndO8BenalC2v94/rt + DfkPekH6dgU3Sf40T0sBSeSY94mOzTaqOR2pfV1rWlLRdWmo33zeHBv52Rlbt0dM + uXAureXWiHztkm5GCBC1dgM+CaxNtizNEgC91KcD0xuRCCM2WxH+r1lpszyIJDct + YbrFmVEYl/kjQpafhy7Nsk1fqSTyRdriZSYmTQIDAQABAoIBAQC+kJgaCuX8wYAn + SXWQ0fmdZlXnMNRpcF0a0pD0SAzGb1RdYBXMaXiqtyhiwc53PPxsCDdNecjayIMd + jJVXPTwLhTruOgMS/bp3gcgWwV34UHV4LJXGOGAE+jbS0hbDBMiudOYmj6RmVshp + z9G1zZCSQNMXHaWsEYkX59XpzzoB384nRul2QgEtwzUNR9XlpzgtJBLk3SACkvsN + mQ/DW8IWHXLg8vLn1LzVJ2e3B16H4MoE2TCHxqfMgr03IDRRJogkenQuQsFhevYT + o/mJyHSWavVgzMHG9I5m+eepF4Wyhj1Y4WyKAuMI+9dHAX/h7Lt8XFCQCh5DbkVG + zGr34sWBAoGBAOs7n7YZqNaaguovfIdRRsxxZr1yJAyDsr6w3yGImDZYju4c4WY9 + 5esO2kP3FA4p0c7FhQF5oOb1rBuHEPp36cpL4aGeK87caqTfq63WZAujoTZpr9Lp + BRbkL7w/xG7jpQ/clpA8sHzHGQs/nelxoOtC7E118FiRgvD/jdhlMyL9AoGBANfX + vyoN1pplfT2xR8QOjSZ+Q35S/+SAtMuBnHx3l0qH2bbBjcvM1MNDWjnRDyaYhiRu + i+KA7tqfib09+XpB3g5D6Ov7ls/Ldx0S/VcmVWtia2HK8y8iLGtokoBZKQ5AaFX2 + iQU8+tC4h69GnJYQKqNwgCUzh8+gHX5Y46oDiTmRAoGAYpOx8lX+czB8/Da6MNrW + mIZNT8atZLEsDs2ANEVRxDSIcTCZJId7+m1W+nRoaycLTWNowZ1+2ErLvR10+AGY + b7Ys79Wg9idYaY9yGn9lnZsMzAiuLeyIvXcSqgjvAKlVWrhOQFOughvNWvFl85Yy + oWSCMlPiTLtt7CCsCKsgKuECgYBgdIp6GZsIfkgclKe0hqgvRoeU4TR3gcjJlM9A + lBTo+pKhaBectplx9RxR8AnsPobbqwcaHnIfAuKDzjk5mEvKZjClnFXF4HAHbyAF + nRzZEy9XkWFhc80T5rRpZO7C7qdxmu2aiKixM3V3L3/0U58qULEDbubHMw9bEhAT + PudI8QKBgHEEiMm/hr9T41hbQi/LYanWnlFw1ue+osKuF8bXQuxnnHNuFT/c+9/A + vWhgqG6bOEHu+p/IPrYm4tBMYlwsyh4nXCyGgDJLbLIfzKwKAWCtH9LwnyDVhOow + GH9shdR+sW3Ew97xef02KAH4VlNANEmBV4sQNqWWvsYrcFm2rOdL + -----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/pom.xml b/pom.xml index 9853b29..e2cdfc4 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,30 @@ commons-io ${commons-io.version} + + io.quarkus + quarkus-mailer + + + com.google.apis + google-api-services-gmail + v1-rev20220404-1.32.1 + + + com.google.auth + google-auth-library-oauth2-http + 1.19.0 + + + com.google.http-client + google-http-client-gson + 1.43.3 + + + org.eclipse.angus + angus-mail + 2.0.3 + io.quarkus quarkus-junit5 @@ -76,6 +100,11 @@ ${mockito-core.version} test + + io.quarkus + quarkus-junit5-mockito + test + diff --git a/src/main/java/org/keycloak/gh/bot/email/CommandParser.java b/src/main/java/org/keycloak/gh/bot/email/CommandParser.java new file mode 100644 index 0000000..9e977a2 --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/email/CommandParser.java @@ -0,0 +1,94 @@ +package org.keycloak.gh.bot.email; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.keycloak.gh.bot.GitHubInstallationProvider; + +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Responsible for parsing raw text from GitHub comments into Command objects. + */ +@ApplicationScoped +public class CommandParser { + + @Inject + GitHubInstallationProvider gitHubProvider; + + private Pattern mentionStartOfLine; + private Pattern replySecurity; + private Pattern newSecAlert; + private String cachedBotName; + + public enum CommandType { + REPLY_KEYCLOAK_SECURITY, + NEW_SECALERT, + UNKNOWN + } + + public record Command(CommandType type, Optional subject, String body) {} + + public Optional parse(String text) { + if (text == null || text.isBlank()) return Optional.empty(); + + ensurePatternsInitialized(); + String trimmedText = text.trim(); + + if (!mentionStartOfLine.matcher(trimmedText).find()) { + return Optional.empty(); + } + + Matcher mSecurity = replySecurity.matcher(trimmedText); + if (mSecurity.find()) { + String body = mSecurity.group(1).trim(); + return body.isEmpty() ? Optional.empty() : Optional.of(new Command(CommandType.REPLY_KEYCLOAK_SECURITY, Optional.empty(), body)); + } + + Matcher mSecAlert = newSecAlert.matcher(trimmedText); + if (mSecAlert.find()) { + String subject = mSecAlert.group(1).trim(); + String body = mSecAlert.group(2).trim(); + if (subject.isEmpty() || body.isEmpty()) return Optional.empty(); + return Optional.of(new Command(CommandType.NEW_SECALERT, Optional.of(subject), body)); + } + + return Optional.of(new Command(CommandType.UNKNOWN, Optional.empty(), trimmedText)); + } + + public String getBotName() { + String login = gitHubProvider.getBotLogin(); + if (login == null) return "unknown-bot"; + return login.endsWith("[bot]") ? login.replace("[bot]", "") : login; + } + + public String getHelpMessage() { + String n = getBotName(); + return String.format(""" + I don't know this command or the format is incorrect. + **Rule:** Commands must be on their own line. The message body starts on the next line. + + **Available Commands:** + + `@%s /reply keycloak-security` + `REPLY BODY (Sent to: Sender + Keycloak Security List)` + + `@%s /new secalert "Subject"` + `EMAIL BODY (Sent to: SecAlert + Keycloak Security List)` + """, n, n); + } + + private synchronized void ensurePatternsInitialized() { + String currentBotName = getBotName(); + + if (cachedBotName == null || !cachedBotName.equals(currentBotName)) { + cachedBotName = currentBotName; + String quotedName = Pattern.quote(currentBotName); + + mentionStartOfLine = Pattern.compile("(?mi)^@" + quotedName + "\\b"); + replySecurity = Pattern.compile("(?msi)^@" + quotedName + "\\s+/reply\\s+keycloak-security\\s*[\r\n]+(.*)"); + newSecAlert = Pattern.compile("(?msi)^@" + quotedName + "\\s+/new\\s+secalert\\s+\"([^\"]+)\"\\s*[\r\n]+(.*)"); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/keycloak/gh/bot/email/CommandProcessor.java b/src/main/java/org/keycloak/gh/bot/email/CommandProcessor.java new file mode 100644 index 0000000..555324e --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/email/CommandProcessor.java @@ -0,0 +1,183 @@ +package org.keycloak.gh.bot.email; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import org.kohsuke.github.GHIssue; +import org.kohsuke.github.GHIssueComment; +import org.kohsuke.github.GHReaction; +import org.kohsuke.github.ReactionContent; + +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Handles the processing of GitHub comments to identify and execute bot commands + */ +@ApplicationScoped +public class CommandProcessor { + + private static final Logger LOG = Logger.getLogger(CommandProcessor.class); + private static final Pattern VISIBLE_MARKER_PATTERN = Pattern.compile("\\*\\*Gmail-Thread-ID:\\*\\*\\s*([a-f0-9]+)"); + private static final Pattern RAW_HEX_PATTERN = Pattern.compile("\\b([a-f0-9]{16})\\b"); + + private static final int MAX_PROCESSED_HISTORY = 10000; + + @ConfigProperty(name = "google.group.target") + String targetGroup; + + @ConfigProperty(name = "email.target.secalert") + String secAlertEmail; + + @Inject GitHubAdapter github; + @Inject CommandParser parser; + @Inject MailSender mailSender; + + private final Set processedComments = Collections.synchronizedSet(Collections.newSetFromMap( + new LinkedHashMap(MAX_PROCESSED_HISTORY + 1, .75F, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_PROCESSED_HISTORY; + } + })); + + private Instant lastPollTime = Instant.now().minus(10, ChronoUnit.MINUTES); + + public void processCommands() { + try { + String myLogin = parser.getBotName(); + if (myLogin == null || myLogin.isEmpty()) return; + + Instant executionStart = Instant.now(); + + Date querySince = Date.from(lastPollTime.minus(1, ChronoUnit.MINUTES)); + List updatedIssues = github.getIssuesUpdatedSince(querySince); + + for (GHIssue issue : updatedIssues) { + try { + scanIssue(issue, myLogin); + } catch (Exception e) { + LOG.errorf(e, "Failed to scan issue #%d", issue.getNumber()); + } + } + + lastPollTime = executionStart; + } catch (Exception e) { + LOG.error("Fatal error fetching updated issues", e); + } + } + + private void scanIssue(GHIssue issue, String myLogin) throws IOException { + Optional threadIdOpt = findThreadIdInComments(issue); + + List recentComments = issue.queryComments() + .since(Date.from(lastPollTime.minus(1, ChronoUnit.MINUTES))) + .list() + .toList(); + + for (GHIssueComment comment : recentComments) { + if (hasAlreadyProcessed(comment, myLogin)) continue; + + parser.parse(comment.getBody()).ifPresent(cmd -> executeCommand(issue, comment, cmd, threadIdOpt)); + } + } + + private void executeCommand(GHIssue issue, GHIssueComment comment, CommandParser.Command cmd, Optional threadId) { + boolean success = false; + ReactionContent reaction = ReactionContent.EYES; + + switch (cmd.type()) { + case NEW_SECALERT: + success = mailSender.sendNewEmail(secAlertEmail, targetGroup, cmd.subject().orElse("No Subject"), cmd.body()); + if (!success) { + replyWithError(issue, comment, "❌ Error: Failed to send email via Gmail API."); + reaction = ReactionContent.CONFUSED; + } + break; + case REPLY_KEYCLOAK_SECURITY: + if (threadId.isPresent()) { + success = mailSender.sendReply(threadId.get(), issue.getTitle(), cmd.body(), targetGroup); + if (!success) { + replyWithError(issue, comment, "❌ Error: Failed to send email via Gmail API."); + reaction = ReactionContent.CONFUSED; + } + } else { + replyWithError(issue, comment, "❌ Error: Gmail Thread ID not found."); + success = true; + reaction = ReactionContent.CONFUSED; + } + break; + case UNKNOWN: + sendHelpMessage(issue, comment); + success = true; + reaction = ReactionContent.CONFUSED; + break; + } + + if (success) { + processedComments.add(comment.getId()); + addReaction(comment, reaction); + LOG.debugf("✅ Command executed: %s", cmd.type()); + } + } + + private void addReaction(GHIssueComment comment, ReactionContent reaction) { + try { + comment.createReaction(reaction); + } catch (IOException e) { + LOG.errorf("Failed to react to comment %d", comment.getId()); + } + } + + private void replyWithError(GHIssue issue, GHIssueComment comment, String message) { + try { + github.commentOnIssue(issue, "@" + comment.getUser().getLogin() + " " + message); + } catch (IOException e) { + LOG.error("Failed to send error reply", e); + } + } + + private void sendHelpMessage(GHIssue issue, GHIssueComment comment) { + try { + String body = "@" + comment.getUser().getLogin() + " " + parser.getHelpMessage(); + github.commentOnIssue(issue, body); + } catch (IOException e) { + LOG.error("Failed to send help message", e); + } + } + + private boolean hasAlreadyProcessed(GHIssueComment comment, String myLogin) throws IOException { + if (processedComments.contains(comment.getId())) return true; + + for (GHReaction reaction : comment.listReactions()) { + String user = reaction.getUser().getLogin(); + if ((reaction.getContent() == ReactionContent.EYES || reaction.getContent() == ReactionContent.CONFUSED) && + (user.equalsIgnoreCase(myLogin) || user.equalsIgnoreCase(myLogin + "[bot]"))) { + processedComments.add(comment.getId()); + return true; + } + } + return false; + } + + private Optional findThreadIdInComments(GHIssue issue) throws IOException { + for (GHIssueComment comment : issue.getComments()) { + Matcher m = VISIBLE_MARKER_PATTERN.matcher(comment.getBody()); + if (m.find()) return Optional.of(m.group(1).trim()); + Matcher raw = RAW_HEX_PATTERN.matcher(comment.getBody()); + if (raw.find()) return Optional.of(raw.group(1).trim()); + } + return Optional.empty(); + } +} \ No newline at end of file diff --git a/src/main/java/org/keycloak/gh/bot/email/EmailSyncScheduler.java b/src/main/java/org/keycloak/gh/bot/email/EmailSyncScheduler.java new file mode 100644 index 0000000..650d8a2 --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/email/EmailSyncScheduler.java @@ -0,0 +1,26 @@ +package org.keycloak.gh.bot.email; + +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import io.quarkus.scheduler.Scheduled.ConcurrentExecution; + +/** + * Manages the scheduled execution of email synchronization and command processing tasks. + */ +@ApplicationScoped +public class EmailSyncScheduler { + + @Inject IncomingMailProcessor incomingMail; + @Inject CommandProcessor commandProcessor; + + @Scheduled(every = "${bot.email.sync.interval:60s}", concurrentExecution = ConcurrentExecution.SKIP) + public void syncGmailToGitHub() { + incomingMail.processUnreadEmails(); + } + + @Scheduled(every = "${bot.command.process.interval:10s}", concurrentExecution = ConcurrentExecution.SKIP) + public void processGitHubCommands() { + commandProcessor.processCommands(); + } +} \ No newline at end of file diff --git a/src/main/java/org/keycloak/gh/bot/email/GitHubAdapter.java b/src/main/java/org/keycloak/gh/bot/email/GitHubAdapter.java new file mode 100644 index 0000000..9e7561d --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/email/GitHubAdapter.java @@ -0,0 +1,83 @@ +package org.keycloak.gh.bot.email; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; +import org.keycloak.gh.bot.GitHubInstallationProvider; +import org.kohsuke.github.GHIssue; +import org.kohsuke.github.GHIssueState; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.PagedSearchIterable; + +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +/** + * Wraps the GitHub API client to provide domain-specific methods for the bot. + */ +@ApplicationScoped +public class GitHubAdapter { + + private static final Logger LOG = Logger.getLogger(GitHubAdapter.class); + + @Inject + GitHubInstallationProvider gitHubProvider; + + private GHRepository cachedRepository; + + private GHRepository getRepository() throws IOException { + if (cachedRepository != null) { + return cachedRepository; + } + String fullRepoName = gitHubProvider.getRepositoryFullName(); + if (fullRepoName == null) throw new IllegalStateException("Repository name is null."); + + cachedRepository = gitHubProvider.getGitHub().getRepository(fullRepoName); + return cachedRepository; + } + + public GHIssue createIssue(String subject, String body) { + try { + GHIssue issue = getRepository().createIssue(subject).body(body).create(); + LOG.debugf("🆕 Created Issue #%d", issue.getNumber()); + return issue; + } catch (Exception e) { + throw new RuntimeException("Failed to create GitHub issue", e); + } + } + + public void commentOnIssue(GHIssue issue, String commentBody) { + try { + issue.comment(commentBody); + LOG.debugf("💬 Commented on Issue #%d", issue.getNumber()); + } catch (Exception e) { + LOG.error("Failed to comment on issue", e); + } + } + + public Optional findIssueByThreadId(String threadId) throws IOException { + String repoName = gitHubProvider.getRepositoryFullName(); + String query = String.format("repo:%s \"%s\" in:comments type:issue", repoName, threadId); + + PagedSearchIterable issues = gitHubProvider.getGitHub().searchIssues().q(query).list(); + if (issues.getTotalCount() > 0) { + return Optional.of(issues.iterator().next()); + } + return Optional.empty(); + } + + public List getIssuesUpdatedSince(Date since) { + try { + return getRepository().queryIssues() + .state(GHIssueState.OPEN) + .since(since) + .list() + .toList(); + } catch (Exception e) { + LOG.error("Failed to fetch updated issues", e); + return List.of(); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/keycloak/gh/bot/email/GmailAdapter.java b/src/main/java/org/keycloak/gh/bot/email/GmailAdapter.java new file mode 100644 index 0000000..4acb5ca --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/email/GmailAdapter.java @@ -0,0 +1,127 @@ +package org.keycloak.gh.bot.email; + +import com.google.api.services.gmail.Gmail; +import com.google.api.services.gmail.model.ListMessagesResponse; +import com.google.api.services.gmail.model.Message; +import com.google.api.services.gmail.model.MessagePart; +import com.google.api.services.gmail.model.MessagePartBody; +import com.google.api.services.gmail.model.MessagePartHeader; +import com.google.api.services.gmail.model.ModifyMessageRequest; +import com.google.api.services.gmail.model.Thread; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.mail.internet.MimeMessage; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * A wrapper around the Google Gmail API client. + */ +@ApplicationScoped +public class GmailAdapter { + + private static final Logger LOG = Logger.getLogger(GmailAdapter.class); + + @Inject + Gmail gmail; + + @ConfigProperty(name = "gmail.batch.size", defaultValue = "20") + long batchSize; + + public List fetchUnreadMessages(String query) { + try { + ListMessagesResponse listResponse = gmail.users().messages().list("me") + .setQ(query) + .setMaxResults(batchSize) + .execute(); + return listResponse.getMessages() != null ? listResponse.getMessages() : Collections.emptyList(); + } catch (IOException e) { + LOG.error("Failed to fetch messages", e); + return Collections.emptyList(); + } + } + + public Message getMessage(String id) { + try { + return gmail.users().messages().get("me", id).execute(); + } catch (IOException e) { + LOG.errorf("Failed to get message %s", id, e); + return null; + } + } + + public Thread getThread(String threadId) { + try { + return gmail.users().threads().get("me", threadId).setFormat("METADATA").execute(); + } catch (IOException e) { + LOG.errorf("Failed to get thread %s", threadId, e); + return null; + } + } + + public void markAsRead(String messageId) { + try { + ModifyMessageRequest mods = new ModifyMessageRequest().setRemoveLabelIds(Collections.singletonList("UNREAD")); + gmail.users().messages().modify("me", messageId, mods).execute(); + } catch (IOException e) { + LOG.errorf("Failed to mark message %s as read", messageId, e); + } + } + + public void sendMessage(String threadId, MimeMessage email) { + try { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + email.writeTo(buffer); + String encodedEmail = Base64.getUrlEncoder().withoutPadding().encodeToString(buffer.toByteArray()); + + Message message = new Message(); + message.setRaw(encodedEmail); + if (threadId != null) { + message.setThreadId(threadId); + } + gmail.users().messages().send("me", message).execute(); + } catch (Exception e) { + throw new RuntimeException("Failed to send email via Gmail API", e); + } + } + + public String getHeader(Message message, String name) { + if (message == null || message.getPayload() == null || message.getPayload().getHeaders() == null) return ""; + return message.getPayload().getHeaders().stream() + .filter(h -> h.getName().equalsIgnoreCase(name)) + .findFirst() + .map(MessagePartHeader::getValue).orElse(""); + } + + public String getBody(Message message) { + if (message == null || message.getPayload() == null) return ""; + + MessagePartBody body = message.getPayload().getBody(); + if (body != null && body.getData() != null) { + return new String(Base64.getUrlDecoder().decode(body.getData())); + } + + return getPartsBody(message.getPayload().getParts()).orElse(""); + } + + private Optional getPartsBody(List parts) { + if (parts == null) return Optional.empty(); + for (MessagePart part : parts) { + if ("text/plain".equals(part.getMimeType()) && part.getBody() != null && part.getBody().getData() != null) { + return Optional.of(new String(Base64.getUrlDecoder().decode(part.getBody().getData()))); + } + if (part.getParts() != null) { + Optional result = getPartsBody(part.getParts()); + if (result.isPresent()) return result; + } + } + return Optional.empty(); + } +} \ No newline at end of file diff --git a/src/main/java/org/keycloak/gh/bot/email/GmailConfig.java b/src/main/java/org/keycloak/gh/bot/email/GmailConfig.java new file mode 100644 index 0000000..480e1fe --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/email/GmailConfig.java @@ -0,0 +1,47 @@ +package org.keycloak.gh.bot.email; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.gmail.Gmail; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.UserCredentials; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +/** + * Configuration producer for the Gmail API client. + */ +@ApplicationScoped +public class GmailConfig { + + @ConfigProperty(name = "gmail.client.id") String clientId; + @ConfigProperty(name = "gmail.client.secret") String clientSecret; + @ConfigProperty(name = "gmail.refresh.token") String refreshToken; + @ConfigProperty(name = "quarkus.application.name") String appName; + + @Produces + @Singleton + public Gmail createGmailClient() { + try { + UserCredentials credentials = UserCredentials.newBuilder() + .setClientId(clientId) + .setClientSecret(clientSecret) + .setRefreshToken(refreshToken) + .build(); + + return new Gmail.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(credentials)) + .setApplicationName(appName) + .build(); + } catch (IOException | GeneralSecurityException e) { + throw new RuntimeException("Failed to initialize Gmail Client", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/keycloak/gh/bot/email/IncomingMailProcessor.java b/src/main/java/org/keycloak/gh/bot/email/IncomingMailProcessor.java new file mode 100644 index 0000000..d65f74a --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/email/IncomingMailProcessor.java @@ -0,0 +1,118 @@ +package org.keycloak.gh.bot.email; + +import com.google.api.services.gmail.model.Message; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import org.keycloak.gh.bot.utils.Labels; +import org.kohsuke.github.GHIssue; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Polls Gmail for unread messages, filters out bot auto-replies to prevent loops, + * and synchronizes valid emails to GitHub. + */ +@ApplicationScoped +public class IncomingMailProcessor { + + private static final Logger LOG = Logger.getLogger(IncomingMailProcessor.class); + private static final String VISIBLE_MARKER_PREFIX = "**Gmail-Thread-ID:** "; + private static final Pattern SIGNATURE_PATTERN = Pattern.compile("(?m)^--\\s*$|^You received this message because you are subscribed.*"); + private static final String ISSUE_DESCRIPTION_TEMPLATE = "_Thread originally started in the keycloak-security mailing list. Replace the content here by a proper description._"; + + @ConfigProperty(name = "google.group.target") String targetGroup; + @ConfigProperty(name = "gmail.user.email") String botEmail; + + @Inject GmailAdapter gmail; + @Inject GitHubAdapter github; + @Inject CommandParser commandParser; + + public void processUnreadEmails() { + String query = "is:unread -from:" + botEmail; + + List messages = gmail.fetchUnreadMessages(query); + for (Message msgSummary : messages) { + processMessage(msgSummary); + } + } + + private void processMessage(Message msgSummary) { + Message msg = gmail.getMessage(msgSummary.getId()); + if (msg == null) return; + + try { + String from = gmail.getHeader(msg, "From"); + + if (isFromBot(from) || !isValidGroupMessage(msg)) { + gmail.markAsRead(msg.getId()); + return; + } + + String threadId = msg.getThreadId(); + String subject = gmail.getHeader(msg, "Subject"); + String cleanBody = sanitizeBody(gmail.getBody(msg)).orElse("(No content)"); + + github.findIssueByThreadId(threadId).ifPresentOrElse( + existing -> appendComment(existing, from, cleanBody, threadId), + () -> createNewIssue(threadId, subject, from, cleanBody) + ); + + gmail.markAsRead(msg.getId()); + + } catch (IOException e) { + LOG.warnf("Deferred processing message %s due to API error (Rate Limit?): %s", msg.getId(), e.getMessage()); + } catch (Exception e) { + LOG.errorf(e, "Failed to process message %s", msg.getId()); + } + } + + private void appendComment(GHIssue issue, String from, String body, String threadId) { + String comment = "**Reply from " + from + ":**\n\n" + body; + github.commentOnIssue(issue, comment); + LOG.debugf("✅ Commented on #%d (Thread %s)", issue.getNumber(), threadId); + } + + private void createNewIssue(String threadId, String subject, String from, String body) { + LOG.debugf("🤖 Creating Issue for Thread %s", threadId); + GHIssue newIssue = github.createIssue(subject, ISSUE_DESCRIPTION_TEMPLATE); + if (newIssue != null) { + try { + newIssue.addLabels(Labels.STATUS_TRIAGE); + } catch (Exception e) { + LOG.errorf(e, "Failed to label issue #%d", newIssue.getNumber()); + } + String firstComment = VISIBLE_MARKER_PREFIX + threadId + "\n**From:** " + from + "\n\n" + body; + github.commentOnIssue(newIssue, firstComment); + } + } + + private boolean isFromBot(String from) { + return from != null && from.toLowerCase().contains(botEmail.toLowerCase()); + } + + private boolean isValidGroupMessage(Message msg) { + String listId = gmail.getHeader(msg, "List-ID"); + String groupIdentifier = targetGroup.split("@")[0]; + if (listId != null && listId.contains(groupIdentifier)) return true; + + String to = gmail.getHeader(msg, "To"); + String cc = gmail.getHeader(msg, "Cc"); + return (to != null && to.contains(targetGroup)) || (cc != null && cc.contains(targetGroup)); + } + + private Optional sanitizeBody(String body) { + if (body == null || body.isBlank()) return Optional.empty(); + Matcher matcher = SIGNATURE_PATTERN.matcher(body); + if (matcher.find()) { + String trimmed = body.substring(0, matcher.start()).trim(); + return trimmed.isEmpty() ? Optional.empty() : Optional.of(trimmed); + } + return Optional.of(body.trim()); + } +} \ No newline at end of file diff --git a/src/main/java/org/keycloak/gh/bot/email/MailSender.java b/src/main/java/org/keycloak/gh/bot/email/MailSender.java new file mode 100644 index 0000000..7dd7450 --- /dev/null +++ b/src/main/java/org/keycloak/gh/bot/email/MailSender.java @@ -0,0 +1,104 @@ +package org.keycloak.gh.bot.email; + +import com.google.api.services.gmail.model.Message; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Properties; + +/** + * Handles the construction and sending of MIME emails via the Gmail API. + */ +@ApplicationScoped +public class MailSender { + + private static final Logger LOG = Logger.getLogger(MailSender.class); + + @ConfigProperty(name = "gmail.user.email") String botEmail; + + @Inject GmailAdapter gmail; + + private Session mailSession; + + @PostConstruct + public void init() { + this.mailSession = Session.getDefaultInstance(new Properties(), null); + } + + public boolean sendNewEmail(String to, String cc, String subject, String body) { + try { + MimeMessage email = createBaseMessage(); + + email.addRecipient(jakarta.mail.Message.RecipientType.TO, new InternetAddress(to)); + + if (cc != null && !cc.isBlank()) { + email.addRecipient(jakarta.mail.Message.RecipientType.CC, new InternetAddress(cc)); + } + + email.setSubject(subject); + email.setText(body); + + gmail.sendMessage(null, email); + return true; + } catch (Exception e) { + LOG.error("Failed to send new email", e); + return false; + } + } + + public boolean sendReply(String threadId, String subject, String body, String ccTarget) { + try { + com.google.api.services.gmail.model.Thread thread = gmail.getThread(threadId); + if (thread == null || thread.getMessages() == null) return false; + + Message targetMsg = findLastHumanMessage(thread.getMessages()); + if (targetMsg == null) return false; + + MimeMessage email = createBaseMessage(); + setupThreadingHeaders(email, targetMsg); + + String sender = gmail.getHeader(targetMsg, "From"); + if (sender != null) email.addRecipient(jakarta.mail.Message.RecipientType.TO, new InternetAddress(sender)); + if (ccTarget != null) email.addRecipient(jakarta.mail.Message.RecipientType.CC, new InternetAddress(ccTarget)); + + email.setSubject(subject.startsWith("Re:") ? subject : "Re: " + subject); + email.setText(body); + + gmail.sendMessage(threadId, email); + return true; + } catch (Exception e) { + LOG.error("Failed to send reply email", e); + return false; + } + } + + private MimeMessage createBaseMessage() throws Exception { + MimeMessage email = new MimeMessage(mailSession); + email.setFrom(new InternetAddress(botEmail)); + return email; + } + + private void setupThreadingHeaders(MimeMessage email, Message targetMsg) throws Exception { + String parentId = gmail.getHeader(targetMsg, "Message-ID"); + String refs = gmail.getHeader(targetMsg, "References"); + if (parentId != null && !parentId.isEmpty()) { + email.setHeader("In-Reply-To", parentId); + email.setHeader("References", (refs == null || refs.isEmpty() ? "" : refs + " ") + parentId); + } + } + + private Message findLastHumanMessage(List history) { + for (int i = history.size() - 1; i >= 0; i--) { + String from = gmail.getHeader(history.get(i), "From"); + if (from != null && !from.toLowerCase().contains(botEmail.toLowerCase())) return history.get(i); + } + return history.get(history.size() - 1); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b6a1fa5..f65269d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -22,3 +22,19 @@ autoBump.normal.reactions=10 autoExpire.cron=0 6 * * * ? autoExpire.low.expiresDays=90 autoExpire.normal.expiresDays=180 + +quarkus.scheduler.enabled=true + +# --- Gmail Credentials --- +gmail.client.id=${GMAIL_CLIENT_ID} +gmail.client.secret=${GMAIL_CLIENT_SECRET} +gmail.refresh.token=${GMAIL_REFRESH_TOKEN} +gmail.user.email=${GMAIL_USER_EMAIL} + +# --- Targets --- +google.group.target=keycloak-security-lab@googlegroups.com +email.target.secalert=bruno@abstractj.org + +# --- Test Configuration --- +# Disable scheduler during tests +%test.quarkus.scheduler.enabled=false \ No newline at end of file diff --git a/src/main/resources/application.properties.bkp2 b/src/main/resources/application.properties.bkp2 new file mode 100644 index 0000000..f65269d --- /dev/null +++ b/src/main/resources/application.properties.bkp2 @@ -0,0 +1,40 @@ +quarkus.application.name=keycloak-github-bot +quarkus.application.version=${buildNumber:999-SNAPSHOT} + +quarkus.openshift.labels."app"=keycloak-github-bot +quarkus.openshift.annotations."kubernetes.io/tls-acme"=true +quarkus.openshift.env.vars.QUARKUS_GITHUB_APP_APP_ID=817634 +quarkus.openshift.env.vars.QUARKUS_GITHUB_APP_APP_NAME=keycloak-github-bot +quarkus.openshift.env.vars.QUARKUS_OPTS=-Dquarkus.http.host=0.0.0.0 -Xmx150m +quarkus.openshift.env.secrets=keycloak-github-bot + +quarkus.openshift.idempotent=true + + +missingInfo.cron=0 4 * * * ? +missingInfo.expiration.unit=DAYS +missingInfo.expiration.value=14 + +autoBump.cron=0 5 * * * ? +autoBump.low.reactions=5 +autoBump.normal.reactions=10 + +autoExpire.cron=0 6 * * * ? +autoExpire.low.expiresDays=90 +autoExpire.normal.expiresDays=180 + +quarkus.scheduler.enabled=true + +# --- Gmail Credentials --- +gmail.client.id=${GMAIL_CLIENT_ID} +gmail.client.secret=${GMAIL_CLIENT_SECRET} +gmail.refresh.token=${GMAIL_REFRESH_TOKEN} +gmail.user.email=${GMAIL_USER_EMAIL} + +# --- Targets --- +google.group.target=keycloak-security-lab@googlegroups.com +email.target.secalert=bruno@abstractj.org + +# --- Test Configuration --- +# Disable scheduler during tests +%test.quarkus.scheduler.enabled=false \ No newline at end of file diff --git a/src/test/java/org/keycloak/gh/bot/email/CommandParserTest.java b/src/test/java/org/keycloak/gh/bot/email/CommandParserTest.java new file mode 100644 index 0000000..89cbd79 --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/email/CommandParserTest.java @@ -0,0 +1,72 @@ +package org.keycloak.gh.bot.email; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.gh.bot.email.CommandParser.Command; +import org.keycloak.gh.bot.email.CommandParser.CommandType; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CommandParserTest { + + private CommandParser parser; + + @BeforeEach + public void setup() { + parser = new CommandParser(); + parser.gitHubProvider = mock(org.keycloak.gh.bot.GitHubInstallationProvider.class); + when(parser.gitHubProvider.getBotLogin()).thenReturn("keycloak-bot"); + } + + @Test + public void testReplyParsing() { + String text = """ + @keycloak-bot /reply keycloak-security + This is the body. + Multiple lines. + """; + + Optional cmd = parser.parse(text); + + assertTrue(cmd.isPresent()); + assertEquals(CommandType.REPLY_KEYCLOAK_SECURITY, cmd.get().type()); + assertEquals("This is the body.\nMultiple lines.", cmd.get().body()); + } + + @Test + public void testNewSecAlertParsing() { + String text = """ + @keycloak-bot /new secalert "New CVE" + Details here. + """; + + Optional cmd = parser.parse(text); + + assertTrue(cmd.isPresent()); + assertEquals(CommandType.NEW_SECALERT, cmd.get().type()); + assertEquals("New CVE", cmd.get().subject().get()); + assertEquals("Details here.", cmd.get().body()); + } + + @Test + public void testUnknownCommandParsing() { + String text = "@keycloak-bot /unknown command"; + + Optional cmd = parser.parse(text); + + assertTrue(cmd.isPresent()); + assertEquals(CommandType.UNKNOWN, cmd.get().type()); + } + + @Test + public void testIgnoreNonMention() { + String text = "Just a comment without bot mention."; + Optional cmd = parser.parse(text); + assertTrue(cmd.isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/java/org/keycloak/gh/bot/email/CommandProcessorTest.java b/src/test/java/org/keycloak/gh/bot/email/CommandProcessorTest.java new file mode 100644 index 0000000..70f87b2 --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/email/CommandProcessorTest.java @@ -0,0 +1,142 @@ +package org.keycloak.gh.bot.email; + +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.gh.bot.email.CommandParser.Command; +import org.keycloak.gh.bot.email.CommandParser.CommandType; +import org.kohsuke.github.GHIssue; +import org.kohsuke.github.GHIssueComment; +import org.kohsuke.github.GHIssueCommentQueryBuilder; +import org.kohsuke.github.GHReaction; +import org.kohsuke.github.GHUser; +import org.kohsuke.github.PagedIterable; +import org.kohsuke.github.PagedIterator; +import org.kohsuke.github.ReactionContent; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Random; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@QuarkusTest +public class CommandProcessorTest { + // ... (Your test methods remain exactly as they were) ... + // Ensure NO ConfigProfile inner class + + @Inject + CommandProcessor commandProcessor; + + @InjectMock + MailSender mailSender; + + @InjectMock + GitHubAdapter githubAdapter; + + @InjectMock + CommandParser commandParser; + + @ConfigProperty(name = "quarkus.application.name") + String botName; + + @ConfigProperty(name = "google.group.target") + String targetGroup; + + @ConfigProperty(name = "email.target.secalert") + String secAlertEmail; + + private static final String THREAD_ID = "123456789abcdef"; + + @BeforeEach + public void setup() { + when(commandParser.getBotName()).thenReturn(botName); + when(githubAdapter.getIssuesUpdatedSince(any())).thenReturn(Collections.emptyList()); + } + + @Test + public void testNewSecAlertSuccess() throws IOException { + GHIssue issue = mock(GHIssue.class); + GHIssueComment comment = mockComment(); + + when(commandParser.parse(anyString())).thenReturn( + Optional.of(new Command(CommandType.NEW_SECALERT, Optional.of("CVE-123"), "Alert body.")) + ); + + when(issue.getComments()).thenReturn(List.of(comment)); + + mockQueryComments(issue, List.of(comment)); + + when(githubAdapter.getIssuesUpdatedSince(any())).thenReturn(List.of(issue)); + when(mailSender.sendNewEmail(any(), any(), any(), any())).thenReturn(true); + + commandProcessor.processCommands(); + + verify(mailSender).sendNewEmail(eq(secAlertEmail), eq(targetGroup), eq("CVE-123"), eq("Alert body.")); + verify(comment).createReaction(ReactionContent.EYES); + } + + @Test + public void testReplyKeycloakSecuritySuccess() throws IOException { + GHIssue issue = mock(GHIssue.class); + when(issue.getTitle()).thenReturn("Security Thread"); + GHIssueComment comment = mockComment(); + + when(commandParser.parse(anyString())).thenReturn( + Optional.of(new Command(CommandType.REPLY_KEYCLOAK_SECURITY, Optional.empty(), "Fixed.")) + ); + + when(comment.getBody()).thenReturn("**Gmail-Thread-ID:** " + THREAD_ID); + + when(issue.getComments()).thenReturn(List.of(comment)); + + mockQueryComments(issue, List.of(comment)); + + when(githubAdapter.getIssuesUpdatedSince(any())).thenReturn(List.of(issue)); + when(mailSender.sendReply(any(), any(), any(), any())).thenReturn(true); + + commandProcessor.processCommands(); + + verify(mailSender).sendReply(eq(THREAD_ID), eq("Security Thread"), eq("Fixed."), eq(targetGroup)); + verify(comment).createReaction(ReactionContent.EYES); + } + + private void mockQueryComments(GHIssue issue, List comments) throws IOException { + GHIssueCommentQueryBuilder queryBuilder = mock(GHIssueCommentQueryBuilder.class); + PagedIterable pagedIterable = mock(PagedIterable.class); + + when(issue.queryComments()).thenReturn(queryBuilder); + when(queryBuilder.since(any())).thenReturn(queryBuilder); + when(queryBuilder.list()).thenReturn(pagedIterable); + when(pagedIterable.toList()).thenReturn(comments); + } + + private GHIssueComment mockComment() throws IOException { + GHIssueComment comment = mock(GHIssueComment.class); + when(comment.getId()).thenReturn(new Random().nextLong()); + when(comment.getBody()).thenReturn(""); + + GHUser user = mock(GHUser.class); + when(user.getLogin()).thenReturn("tester"); + when(comment.getUser()).thenReturn(user); + + PagedIterable reactions = mock(PagedIterable.class); + PagedIterator iterator = mock(PagedIterator.class); + when(iterator.hasNext()).thenReturn(false); + when(reactions.iterator()).thenReturn(iterator); + + when(comment.listReactions()).thenReturn(reactions); + + return comment; + } +} \ No newline at end of file diff --git a/src/test/java/org/keycloak/gh/bot/email/GitHubAdapterTest.java b/src/test/java/org/keycloak/gh/bot/email/GitHubAdapterTest.java new file mode 100644 index 0000000..8237d49 --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/email/GitHubAdapterTest.java @@ -0,0 +1,57 @@ +package org.keycloak.gh.bot.email; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.gh.bot.GitHubInstallationProvider; +import org.kohsuke.github.GHIssueSearchBuilder; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.PagedSearchIterable; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class GitHubAdapterTest { + + GitHubAdapter gitHubAdapter; + GitHubInstallationProvider mockInstallationProvider; + + @BeforeEach + public void setup() { + gitHubAdapter = new GitHubAdapter(); + mockInstallationProvider = mock(GitHubInstallationProvider.class); + + gitHubAdapter.gitHubProvider = mockInstallationProvider; + } + + @Test + public void testFindIssueQueryStructure() throws IOException { + String repoName = "keycloak/keycloak-private"; + String threadId = "abc12345"; + + GitHub mockGitHub = mock(GitHub.class); + GHIssueSearchBuilder mockSearch = mock(GHIssueSearchBuilder.class); + + when(mockInstallationProvider.getRepositoryFullName()).thenReturn(repoName); + when(mockInstallationProvider.getGitHub()).thenReturn(mockGitHub); + + when(mockGitHub.searchIssues()).thenReturn(mockSearch); + when(mockSearch.q(anyString())).thenReturn(mockSearch); + when(mockSearch.list()).thenReturn(mock(PagedSearchIterable.class)); + + gitHubAdapter.findIssueByThreadId(threadId); + + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(String.class); + verify(mockSearch).q(queryCaptor.capture()); + + String query = queryCaptor.getValue(); + assertTrue(query.contains("repo:" + repoName)); + assertTrue(query.contains("\"" + threadId + "\"")); + assertTrue(query.contains("in:comments")); + } +} \ No newline at end of file diff --git a/src/test/java/org/keycloak/gh/bot/email/GmailAdapterTest.java b/src/test/java/org/keycloak/gh/bot/email/GmailAdapterTest.java new file mode 100644 index 0000000..445bad8 --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/email/GmailAdapterTest.java @@ -0,0 +1,70 @@ +package org.keycloak.gh.bot.email; + +import com.google.api.services.gmail.model.Message; +import com.google.api.services.gmail.model.MessagePart; +import com.google.api.services.gmail.model.MessagePartBody; +import com.google.api.services.gmail.model.MessagePartHeader; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@QuarkusTest +public class GmailAdapterTest { + + @Inject + GmailAdapter gmailAdapter; + + @Test + public void testGetHeaderCaseInsensitivity() { + Message msg = new Message(); + msg.setPayload(new MessagePart().setHeaders(List.of( + new MessagePartHeader().setName("Subject").setValue("Critical Alert"), + new MessagePartHeader().setName("from").setValue("security@example.com") + ))); + + assertEquals("Critical Alert", gmailAdapter.getHeader(msg, "Subject")); + assertEquals("security@example.com", gmailAdapter.getHeader(msg, "From")); + assertEquals("", gmailAdapter.getHeader(msg, "Non-Existent-Header")); + } + + @Test + public void testGetBodyDecodesBase64() { + String originalText = "Hello World"; + String encoded = Base64.getUrlEncoder().encodeToString(originalText.getBytes()); + + Message msg = new Message(); + MessagePart payload = new MessagePart(); + payload.setBody(new MessagePartBody().setData(encoded)); + msg.setPayload(payload); + + assertEquals(originalText, gmailAdapter.getBody(msg)); + } + + @Test + public void testGetBodyMultipartNested() { + String expectedText = "Clean Text"; + String encodedText = Base64.getUrlEncoder().encodeToString(expectedText.getBytes()); + String encodedHtml = Base64.getUrlEncoder().encodeToString("Bad".getBytes()); + + MessagePart textPart = new MessagePart() + .setMimeType("text/plain") + .setBody(new MessagePartBody().setData(encodedText)); + + MessagePart htmlPart = new MessagePart() + .setMimeType("text/html") + .setBody(new MessagePartBody().setData(encodedHtml)); + + Message msg = new Message(); + MessagePart root = new MessagePart(); + root.setParts(List.of(textPart, htmlPart)); + root.setBody(new MessagePartBody()); + msg.setPayload(root); + + assertEquals(expectedText, gmailAdapter.getBody(msg)); + } +} \ No newline at end of file diff --git a/src/test/java/org/keycloak/gh/bot/email/IncomingMailProcessorTest.java b/src/test/java/org/keycloak/gh/bot/email/IncomingMailProcessorTest.java new file mode 100644 index 0000000..f361ec0 --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/email/IncomingMailProcessorTest.java @@ -0,0 +1,125 @@ +package org.keycloak.gh.bot.email; + +import com.google.api.services.gmail.model.Message; +import com.google.api.services.gmail.model.MessagePart; +import com.google.api.services.gmail.model.MessagePartBody; +import com.google.api.services.gmail.model.MessagePartHeader; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.gh.bot.utils.Labels; +import org.kohsuke.github.GHIssue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@QuarkusTest +public class IncomingMailProcessorTest { + + @Inject + IncomingMailProcessor incomingMailProcessor; + + @InjectMock + GmailAdapter gmailAdapter; + + @InjectMock + GitHubAdapter githubAdapter; + + @ConfigProperty(name = "google.group.target") + String targetGroup; + + private static final String THREAD_ID = "123456789abcdef"; + + @BeforeEach + public void setup() { + when(gmailAdapter.fetchUnreadMessages(anyString())).thenReturn(Collections.emptyList()); + + when(gmailAdapter.getHeader(any(), anyString())).thenCallRealMethod(); + when(gmailAdapter.getBody(any())).thenCallRealMethod(); + } + + @Test + public void testNewThreadCreatesIssue() throws IOException { + Message message = createMockMessage(THREAD_ID, "Vulnerability", "Body content", "user@test.com"); + when(gmailAdapter.fetchUnreadMessages(anyString())).thenReturn(List.of(message)); + when(gmailAdapter.getMessage(message.getId())).thenReturn(message); + + when(githubAdapter.findIssueByThreadId(THREAD_ID)).thenReturn(Optional.empty()); + + GHIssue mockIssue = mock(GHIssue.class); + when(mockIssue.getNumber()).thenReturn(101); + when(githubAdapter.createIssue(anyString(), anyString())).thenReturn(mockIssue); + + incomingMailProcessor.processUnreadEmails(); + + verify(githubAdapter).createIssue(eq("Vulnerability"), anyString()); + verify(mockIssue).addLabels(Labels.STATUS_TRIAGE); + verify(gmailAdapter).markAsRead(message.getId()); + } + + @Test + public void testReplyAppendsComment() throws IOException { + Message message = createMockMessage(THREAD_ID, "Re: New Vuln", "More details here.", "user@test.com"); + when(gmailAdapter.fetchUnreadMessages(anyString())).thenReturn(List.of(message)); + when(gmailAdapter.getMessage(message.getId())).thenReturn(message); + + GHIssue existingIssue = mock(GHIssue.class); + + when(githubAdapter.findIssueByThreadId(THREAD_ID)).thenReturn(Optional.of(existingIssue)); + + incomingMailProcessor.processUnreadEmails(); + + verify(githubAdapter).commentOnIssue(eq(existingIssue), contains("More details here.")); + verify(githubAdapter, never()).createIssue(anyString(), anyString()); + verify(gmailAdapter).markAsRead(message.getId()); + } + + private Message createMockMessage(String threadId, String subject, String body, String from) { + Message msg = new Message(); + msg.setId(UUID.randomUUID().toString()); + msg.setThreadId(threadId); + + MessagePart payload = new MessagePart(); + List headers = new ArrayList<>(); + headers.add(createHeader("Subject", subject)); + headers.add(createHeader("From", from)); + + headers.add(createHeader("To", targetGroup)); + + String listId = targetGroup.replace("@", "."); + headers.add(createHeader("List-ID", "<" + listId + ">")); + + payload.setHeaders(headers); + payload.setMimeType("text/plain"); + + MessagePartBody partBody = new MessagePartBody(); + partBody.setData(Base64.getUrlEncoder().encodeToString(body.getBytes())); + payload.setBody(partBody); + msg.setPayload(payload); + return msg; + } + + private MessagePartHeader createHeader(String name, String value) { + MessagePartHeader h = new MessagePartHeader(); + h.setName(name); + h.setValue(value); + return h; + } +} \ No newline at end of file diff --git a/src/test/java/org/keycloak/gh/bot/email/MailSenderTest.java b/src/test/java/org/keycloak/gh/bot/email/MailSenderTest.java new file mode 100644 index 0000000..d1b22b5 --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/email/MailSenderTest.java @@ -0,0 +1,63 @@ +package org.keycloak.gh.bot.email; + +import com.google.api.services.gmail.model.Message; +import com.google.api.services.gmail.model.MessagePart; +import com.google.api.services.gmail.model.MessagePartHeader; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import jakarta.mail.internet.MimeMessage; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@QuarkusTest +public class MailSenderTest { + + @Inject + MailSender mailSender; + + @InjectMock + GmailAdapter gmailAdapter; + + @ConfigProperty(name = "gmail.user.email") + String botEmail; + + @Test + public void testReplyFindsLastHumanMessage() throws Exception { + String threadId = "thread-123"; + + Message humanMsg = createMsg("msg-human", "human@example.com"); + Message botMsg = createMsg("msg-bot", botEmail); + + com.google.api.services.gmail.model.Thread thread = new com.google.api.services.gmail.model.Thread(); + thread.setMessages(List.of(humanMsg, botMsg)); + + when(gmailAdapter.getThread(threadId)).thenReturn(thread); + + when(gmailAdapter.getHeader(any(), eq("From"))).thenCallRealMethod(); + when(gmailAdapter.getHeader(any(), eq("Message-ID"))).thenCallRealMethod(); + + mailSender.sendReply(threadId, "Re: Subject", "Body", null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); + verify(gmailAdapter).sendMessage(eq(threadId), captor.capture()); + + assertEquals("msg-human", captor.getValue().getHeader("In-Reply-To", null)); + } + + private Message createMsg(String id, String from) { + return new Message().setPayload(new MessagePart().setHeaders(List.of( + new MessagePartHeader().setName("Message-ID").setValue(id), + new MessagePartHeader().setName("From").setValue(from) + ))); + } +} \ No newline at end of file diff --git a/src/test/java/org/keycloak/gh/bot/email/MockGitHubInstallationProvider.java b/src/test/java/org/keycloak/gh/bot/email/MockGitHubInstallationProvider.java new file mode 100644 index 0000000..dead06e --- /dev/null +++ b/src/test/java/org/keycloak/gh/bot/email/MockGitHubInstallationProvider.java @@ -0,0 +1,34 @@ +package org.keycloak.gh.bot.email; + +import io.quarkus.test.Mock; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Priority; +import jakarta.enterprise.inject.Alternative; +import jakarta.inject.Singleton; +import org.keycloak.gh.bot.GitHubInstallationProvider; +import org.kohsuke.github.GitHub; +import org.mockito.Mockito; + +import java.io.IOException; + +@Mock +@Alternative +@Priority(1) +@Singleton +public class MockGitHubInstallationProvider extends GitHubInstallationProvider { + + @Override + @PostConstruct + public void init() throws IOException { + } + + @Override + public GitHub getGitHub() { + return Mockito.mock(GitHub.class); + } + + @Override + public String getRepositoryFullName() { + return "keycloak/keycloak"; + } +} \ No newline at end of file