Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shows error if user is not part of the organization #124

Merged
merged 14 commits into from
Nov 6, 2023
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ test("It forwards a GraphQL request", async () => {
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {}
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
return { state: "active" }
}
})
const request: GraphQLQueryRequest = {
query: "foo",
Expand Down Expand Up @@ -58,7 +61,10 @@ test("It forwards a request to get the repository content", async () => {
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {}
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
return { state: "active" }
}
})
const request: GetRepositoryContentRequest = {
repositoryOwner: "foo",
Expand Down Expand Up @@ -91,7 +97,10 @@ test("It forwards a request to get comments to a pull request", async () => {
forwardedRequest = request
return []
},
async addCommentToPullRequest() {}
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
return { state: "active" }
}
})
const request: GetPullRequestCommentsRequest = {
appInstallationId: 1234,
Expand Down Expand Up @@ -125,6 +134,9 @@ test("It forwards a request to add a comment to a pull request", async () => {
},
async addCommentToPullRequest(request: AddCommentToPullRequestRequest) {
forwardedRequest = request
},
async getOrganizationMembershipStatus() {
return { state: "active" }
}
})
const request: AddCommentToPullRequestRequest = {
Expand Down Expand Up @@ -164,7 +176,10 @@ test("It retries with a refreshed access token when receiving HTTP 401", async (
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {}
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
return { state: "active" }
}
})
const request: GraphQLQueryRequest = {
query: "foo",
Expand Down Expand Up @@ -195,7 +210,10 @@ test("It only retries a request once when receiving HTTP 401", async () => {
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {}
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
return { state: "active" }
}
})
const request: GraphQLQueryRequest = {
query: "foo",
Expand Down Expand Up @@ -229,7 +247,10 @@ test("It does not refresh an access token when the initial request was successfu
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {}
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
return { state: "active" }
}
})
const request: GraphQLQueryRequest = {
query: "foo",
Expand Down
143 changes: 143 additions & 0 deletions __test__/common/session/GitHubOrganizationSessionValidator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
GetOrganizationMembershipStatusRequest
} from "../../../src/common/github/IGitHubClient"
import GitHubOrganizationSessionValidator from "../../../src/common/session/GitHubOrganizationSessionValidator"

test("It requests organization membership status for the specified organization", async () => {
let queriedOrganizationName: string | undefined
const sut = new GitHubOrganizationSessionValidator(
{
async graphql() {
return {}
},
async getRepositoryContent() {
return { downloadURL: "https://example.com" }
},
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus(request: GetOrganizationMembershipStatusRequest) {
queriedOrganizationName = request.organizationName
return { state: "active" }
}
},
"foo"
)
await sut.validateSession()
expect(queriedOrganizationName).toBe("foo")
})

test("It considers session valid when membership state is \"active\"", async () => {
const sut = new GitHubOrganizationSessionValidator(
{
async graphql() {
return {}
},
async getRepositoryContent() {
return { downloadURL: "https://example.com" }
},
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
return { state: "active" }
}
},
"foo"
)
const isSessionValid = await sut.validateSession()
expect(isSessionValid).toBeTruthy()
})

test("It considers session invalid when membership state is \"pending\"", async () => {
const sut = new GitHubOrganizationSessionValidator(
{
async graphql() {
return {}
},
async getRepositoryContent() {
return { downloadURL: "https://example.com" }
},
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
return { state: "pending" }
}
},
"foo"
)
const isSessionValid = await sut.validateSession()
expect(isSessionValid).toBeFalsy()
})

test("It considers session invalid when receiving HTTP 404, indicating user is not member of the organization", async () => {
const sut = new GitHubOrganizationSessionValidator(
{
async graphql() {
return {}
},
async getRepositoryContent() {
return { downloadURL: "https://example.com" }
},
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
throw { status: 404, message: "User is not member of organization"}
}
},
"foo"
)
const isSessionValid = await sut.validateSession()
expect(isSessionValid).toBeFalsy()
})

test("It considers session invalid when receiving HTTP 404, indicating that the organization has blocked the GitHub app", async () => {
const sut = new GitHubOrganizationSessionValidator(
{
async graphql() {
return {}
},
async getRepositoryContent() {
return { downloadURL: "https://example.com" }
},
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
throw { status: 403, message: "Organization has blocked GitHub app"}
}
},
"foo"
)
const isSessionValid = await sut.validateSession()
expect(isSessionValid).toBeFalsy()
})

test("It forwards error when getting membership status throws unknown error", async () => {
const sut = new GitHubOrganizationSessionValidator(
{
async graphql() {
return {}
},
async getRepositoryContent() {
return { downloadURL: "https://example.com" }
},
async getPullRequestComments() {
return []
},
async addCommentToPullRequest() {},
async getOrganizationMembershipStatus() {
throw { status: 500 }
}
},
"foo"
)
await expect(sut.validateSession()).rejects.toEqual({ status: 500 })
})
46 changes: 46 additions & 0 deletions __test__/projects/SessionValidatingProjectDataSource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import SessionValidatingProjectDataSource from "../../src/features/projects/domain/SessionValidatingProjectDataSource"

test("It validates the session", async () => {
let didValidateSession = false
const sut = new SessionValidatingProjectDataSource({
async validateSession() {
didValidateSession = true
return true
},
}, {
async getProjects() {
return []
}
})
await sut.getProjects()
expect(didValidateSession).toBeTruthy()
})

test("It fetches projects when session is valid", async () => {
let didFetchProjects = false
const sut = new SessionValidatingProjectDataSource({
async validateSession() {
return true
},
}, {
async getProjects() {
didFetchProjects = true
return []
}
})
await sut.getProjects()
expect(didFetchProjects).toBeTruthy()
})

test("It throws error when session is invalid", async () => {
const sut = new SessionValidatingProjectDataSource({
async validateSession() {
return false
},
}, {
async getProjects() {
return []
}
})
expect(sut.getProjects()).rejects.toThrowError()
})
23 changes: 10 additions & 13 deletions src/app/api/user/projects/route.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
import { NextResponse } from "next/server"
import { projectDataSource } from "@/composition"
import { UnauthorizedError } from "@/features/auth/domain/AuthError"
import { UnauthorizedError, InvalidSessionError } from "@/common/errors"

export async function GET() {
try {
const projects = await projectDataSource.getProjects()
return NextResponse.json({projects})
} catch (error) {
if (error instanceof UnauthorizedError) {
return NextResponse.json({
status: 401,
message: error.message
}, { status: 401 })
return errorResponse(401, error.message)
} else if (error instanceof InvalidSessionError) {
return errorResponse(403, error.message)
} else if (error instanceof Error) {
return NextResponse.json({
status: 500,
message: error.message
}, { status: 500 })
return errorResponse(500, error.message)
} else {
return NextResponse.json({
status: 500,
message: "Unknown error"
}, { status: 500 })
return errorResponse(500, "Unknown error")
}
}
}

function errorResponse(status: number, message: string): NextResponse {
return NextResponse.json({ status, message }, { status })
}
5 changes: 5 additions & 0 deletions src/app/invalid-session/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import InvalidSessionPage from "@/features/auth/view/InvalidSessionPage"

export default async function Page() {
return <InvalidSessionPage/>
}
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { UserProvider } from "@auth0/nextjs-auth0/client"
import { config as fontAwesomeConfig } from "@fortawesome/fontawesome-svg-core"
import { CssBaseline } from "@mui/material"
import ThemeRegistry from "@/common/theme/ThemeRegistry"
import ErrorHandler from "@/common/errorHandling/client/ErrorHandler"
import ErrorHandler from "@/common/errors/client/ErrorHandler"
import "@fortawesome/fontawesome-svg-core/styles.css"

fontAwesomeConfig.autoAddCss = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ export default function ErrorHandler({
children: React.ReactNode
}) {
const onSWRError = (error: FetcherError) => {
if (typeof window === "undefined") {
return
}
if (error.status == 401) {
if (typeof window !== "undefined") {
window.location.href = "/api/auth/logout"
}
window.location.href = "/api/auth/logout"
} else if (error.status == 403) {
window.location.href = "/invalid-session"
}
}
return (
Expand Down
2 changes: 2 additions & 0 deletions src/common/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export class UnauthorizedError extends Error {}
export class InvalidSessionError extends Error {}
10 changes: 10 additions & 0 deletions src/common/github/AccessTokenRefreshingGitHubClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import IGitHubClient, {
GraphQlQueryResponse,
GetRepositoryContentRequest,
GetPullRequestCommentsRequest,
GetOrganizationMembershipStatusRequest,
GetOrganizationMembershipStatusRequestResponse,
AddCommentToPullRequestRequest,
RepositoryContent,
PullRequestComment
Expand Down Expand Up @@ -60,6 +62,14 @@ export default class AccessTokenRefreshingGitHubClient implements IGitHubClient
})
}

async getOrganizationMembershipStatus(
request: GetOrganizationMembershipStatusRequest
): Promise<GetOrganizationMembershipStatusRequestResponse> {
return await this.send(async () => {
return await this.gitHubClient.getOrganizationMembershipStatus(request)
})
}

private async send<T>(fn: () => Promise<T>): Promise<T> {
const accessToken = await this.accessTokenReader.getAccessToken()
try {
Expand Down
13 changes: 13 additions & 0 deletions src/common/github/GitHubClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import IGitHubClient, {
GetRepositoryContentRequest,
GetPullRequestCommentsRequest,
AddCommentToPullRequestRequest,
GetOrganizationMembershipStatusRequest,
GetOrganizationMembershipStatusRequestResponse,
RepositoryContent,
PullRequestComment
} from "./IGitHubClient"
Expand Down Expand Up @@ -90,4 +92,15 @@ export default class GitHubClient implements IGitHubClient {
body: request.body
})
}

async getOrganizationMembershipStatus(
request: GetOrganizationMembershipStatusRequest
): Promise<GetOrganizationMembershipStatusRequestResponse> {
const accessToken = await this.accessTokenReader.getAccessToken()
const octokit = new Octokit({ auth: accessToken })
const response = await octokit.rest.orgs.getMembershipForAuthenticatedUser({
org: request.organizationName
})
return { state: response.data.state }
}
}
Loading