Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
interval: "weekly"
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import OAuthTokenCoder from "../../src/features/auth/domain/OAuthTokenCoder"
import { z } from "zod"
import ZodJSONCoder from "../../src/common/utils/ZodJSONCoder"

const SampleAuthTokenSchema = z.object({
accessToken: z.string(),
refreshToken: z.string(),
accessTokenExpiryDate: z.coerce.date(),
refreshTokenExpiryDate: z.coerce.date()
})

type SampleAuthToken = z.infer<typeof SampleAuthTokenSchema>

test("It encodes a valid token", async () => {
const token = {
Expand All @@ -7,7 +17,7 @@ test("It encodes a valid token", async () => {
accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000),
refreshTokenExpiryDate: new Date(new Date().getTime() + 24 * 3600 * 1000)
}
const str = OAuthTokenCoder.encode(token)
const str = ZodJSONCoder.encode(SampleAuthTokenSchema, token)
const decodedToken = JSON.parse(str)
expect(decodedToken.accessToken).toBe(token.accessToken)
expect(decodedToken.refreshToken).toBe(token.refreshToken)
Expand All @@ -24,7 +34,7 @@ test("It decodes a valid token", async () => {
accessTokenExpiryDate: accessTokenExpiryDate,
refreshTokenExpiryDate: refreshTokenExpiryDate
})
const token = OAuthTokenCoder.decode(str)
const token: SampleAuthToken = ZodJSONCoder.decode(SampleAuthTokenSchema, str)
expect(token.accessToken).toBe("foo")
expect(token.refreshToken).toBe("bar")
expect(token.accessTokenExpiryDate).toEqual(accessTokenExpiryDate)
Expand All @@ -37,7 +47,7 @@ test("It throws an error when the returned OAuth token does not contain an acces
accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000),
refreshTokenExpiryDate: new Date(new Date().getTime() + 24 * 3600 * 1000)
})
expect(() => OAuthTokenCoder.decode(str)).toThrow()
expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow()
})

test("It throws an error when the returned OAuth token does not contain an refresh token", async () => {
Expand All @@ -46,7 +56,7 @@ test("It throws an error when the returned OAuth token does not contain an refre
accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000),
refreshTokenExpiryDate: new Date(new Date().getTime() + 24 * 3600 * 1000)
})
expect(() => OAuthTokenCoder.decode(str)).toThrow()
expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow()
})

test("It throws an error when the returned OAuth token does not contain an expiry date for the access token", async () => {
Expand All @@ -55,7 +65,7 @@ test("It throws an error when the returned OAuth token does not contain an expir
refreshToken: "bar",
refreshTokenExpiryDate: new Date(new Date().getTime() + 24 * 3600 * 1000)
})
expect(() => OAuthTokenCoder.decode(str)).toThrow()
expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow()
})

test("It throws an error when the returned OAuth token does not contain an expiry date for the refresh token", async () => {
Expand All @@ -64,7 +74,7 @@ test("It throws an error when the returned OAuth token does not contain an expir
refreshToken: "bar",
accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000)
})
expect(() => OAuthTokenCoder.decode(str)).toThrow()
expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow()
})

test("It throws an error when the returned OAuth token does not contain a valid expiry date for the access token", async () => {
Expand All @@ -74,7 +84,7 @@ test("It throws an error when the returned OAuth token does not contain a valid
accessTokenExpiryDate: "baz",
refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000)
})
expect(() => OAuthTokenCoder.decode(str)).toThrow()
expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow()
})

test("It throws an error when the returned OAuth token does not contain a valid expiry date for the refresh token", async () => {
Expand All @@ -84,5 +94,5 @@ test("It throws an error when the returned OAuth token does not contain a valid
accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000),
refreshTokenExpiryDate: "baz"
})
expect(() => OAuthTokenCoder.decode(str)).toThrow()
expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow()
})
41 changes: 41 additions & 0 deletions __test__/common/authHandler/logoutHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import logoutHandler from "../../../src/common/authHandler/logout"

test("It deletes the user's auth token", async () => {
let didDeleteAuthToken = false
logoutHandler({
async getOAuthToken() {
throw new Error("Not implemented")
},
async storeOAuthToken() {},
async deleteOAuthToken() {
didDeleteAuthToken = true
}
}, {
async getProjects() {
return []
},
async storeProjects() {},
async deleteProjects() {}
})
expect(didDeleteAuthToken).toBeTruthy()
})

test("It deletes the cached projects", async () => {
let didDeleteProjects = false
logoutHandler({
async getOAuthToken() {
throw new Error("Not implemented")
},
async storeOAuthToken() {},
async deleteOAuthToken() {}
}, {
async getProjects() {
return []
},
async storeProjects() {},
async deleteProjects() {
didDeleteProjects = true
}
})
expect(didDeleteProjects).toBeTruthy()
})
42 changes: 42 additions & 0 deletions __test__/projects/CachingProjectDataSource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Project from "../../src/features/projects/domain/Project"
import CachingProjectDataSource from "../../src/features/projects/domain/CachingProjectDataSource"

test("It caches projects read from the data source", async () => {
const projects = [{
id: "foo",
name: "foo",
versions: [{
id: "bar",
name: "bar",
specifications: [{
id: "baz.yml",
name: "baz.yml",
url: "https://example.com/baz.yml"
}]
}, {
id: "hello",
name: "hello",
specifications: [{
id: "world.yml",
name: "world.yml",
url: "https://example.com/world.yml"
}]
}]
}]
let cachedProjects: Project[] | undefined
const sut = new CachingProjectDataSource({
async getProjects() {
return projects
}
}, {
async getProjects() {
return []
},
async storeProjects(projects) {
cachedProjects = projects
},
async deleteProjects() {}
})
await sut.getProjects()
expect(cachedProjects).toEqual(projects)
})
22 changes: 0 additions & 22 deletions __test__/projects/ProjectPageState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,28 +32,6 @@ test("It gracefully errors when no project has been selected", async () => {
expect(sut.state).toEqual(ProjectPageState.NO_PROJECT_SELECTED)
})

test("It selects the first project when there is only one project", async () => {
const sut = getProjectPageState({
projects: [{
id: "foo",
name: "foo",
versions: [{
id: "bar",
name: "bar",
specifications: [{
id: "hello",
name: "hello.yml",
url: "https://example.com/hello.yml"
}]
}]
}]
})
expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION)
expect(sut.selection!.project.id).toEqual("foo")
expect(sut.selection!.version.id).toEqual("bar")
expect(sut.selection!.specification.id).toEqual("hello")
})

test("It selects the first version and specification of the specified project", async () => {
const sut = getProjectPageState({
selectedProjectId: "bar",
Expand Down
4 changes: 3 additions & 1 deletion src/app/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getProjectId, getSpecificationId, getVersionId } from "@/common/utils/url"
import SessionOAuthTokenBarrier from "@/features/auth/view/SessionOAuthTokenBarrier"
import ProjectsPage from "@/features/projects/view/client/ProjectsPage"
import ProjectsPage from "@/features/projects/view/ProjectsPage"
import { sessionProjectRepository } from "@/composition"

type PageParams = { slug: string | string[] }

Expand All @@ -9,6 +10,7 @@ export default async function Page({ params }: { params: PageParams }) {
return (
<SessionOAuthTokenBarrier>
<ProjectsPage
sessionProjectRepository={sessionProjectRepository}
projectId={getProjectId(url)}
versionId={getVersionId(url)}
specificationId={getSpecificationId(url)}
Expand Down
10 changes: 8 additions & 2 deletions src/app/api/auth/[auth0]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
} from "@auth0/nextjs-auth0"
import {
initialOAuthTokenService,
sessionOAuthTokenRepository
sessionOAuthTokenRepository,
sessionProjectRepository,
logoutHandler
} from "@/composition"

const { SHAPE_DOCS_BASE_URL } = process.env
Expand All @@ -26,7 +28,11 @@ const onError: AppRouterOnError = async () => {
}

const onLogout: NextAppRouterHandler = async (req: NextRequest, ctx: AppRouteHandlerFnContext) => {
await sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null)
await Promise.all([
sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null),
sessionProjectRepository.deleteProjects().catch(() => null)
])
await logoutHandler()
return await handleLogout(req, ctx)
}

Expand Down
26 changes: 23 additions & 3 deletions src/app/api/user/projects/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
import { NextResponse } from "next/server"
import { projectRepository } from "@/composition"
import { projectDataSource } from "@/composition"
import { UnauthorizedError } from "@/features/auth/domain/AuthError"

export async function GET() {
const projects = await projectRepository.getProjects()
return NextResponse.json({projects})
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 })
} else if (error instanceof Error) {
return NextResponse.json({
status: 500,
message: error.message
}, { status: 500 })
} else {
return NextResponse.json({
status: 500,
message: "Unknown error"
}, { status: 500 })
}
}
}
13 changes: 8 additions & 5 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import "./globals.css"
import type { Metadata } from "next"
import { UserProvider } from "@auth0/nextjs-auth0/client"
import { Inter } from "next/font/google"
import { CssBaseline } from "@mui/material"
import ThemeRegistry from "@/common/theme/ThemeRegistry"
import { UserProvider } from "@auth0/nextjs-auth0/client"
import ErrorHandler from "@/common/errorHandling/client/ErrorHandler"

const inter = Inter({ subsets: ["latin"] })

Expand All @@ -17,10 +18,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en">
<ThemeRegistry options={{ key: "mui" }}>
<UserProvider>
<body className={inter.className}>
<CssBaseline/>
{children}
</body>
<ErrorHandler>
<body className={inter.className}>
<CssBaseline/>
{children}
</body>
</ErrorHandler>
</UserProvider>
</ThemeRegistry>
</html>
Expand Down
7 changes: 5 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import SessionOAuthTokenBarrier from "@/features/auth/view/SessionOAuthTokenBarrier"
import ProjectsPage from "@/features/projects/view/client/ProjectsPage"
import ProjectsPage from "@/features/projects/view/ProjectsPage"
import { sessionProjectRepository } from "@/composition"

export default async function Page() {
return (
<SessionOAuthTokenBarrier>
<ProjectsPage/>
<ProjectsPage
sessionProjectRepository={sessionProjectRepository}
/>
</SessionOAuthTokenBarrier>
)
}
12 changes: 12 additions & 0 deletions src/common/authHandler/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import ISessionOAuthTokenRepository from "@/features/auth/domain/ISessionOAuthTokenRepository"
import ISessionProjectRepository from "@/features/projects/domain/ISessionProjectRepository"

export default async function logoutHandler(
sessionOAuthTokenRepository: ISessionOAuthTokenRepository,
sessionProjectRepository: ISessionProjectRepository
) {
await Promise.all([
sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null),
sessionProjectRepository.deleteProjects().catch(() => null)
])
}
23 changes: 23 additions & 0 deletions src/common/errorHandling/client/ErrorHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client"

import { SWRConfig } from "swr"
import { FetcherError } from "@/common/utils/fetcher"

export default function ErrorHandler({
children
}: {
children: React.ReactNode
}) {
const onSWRError = (error: FetcherError) => {
if (error.status == 401) {
if (typeof window !== "undefined") {
window.location.href = "/api/auth/logout"
}
}
}
return (
<SWRConfig value={{ onError: onSWRError }}>
{children}
</SWRConfig>
)
}
4 changes: 2 additions & 2 deletions src/common/keyValueStore/RedisKeyValueStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ export default class RedisKeyValueStore implements IKeyValueStore {
}

async set(key: string, data: string | number | Buffer): Promise<void> {
this.redis.set(key, data)
await this.redis.set(key, data)
}

async delete(key: string): Promise<void> {
this.redis.del(key)
await this.redis.del(key)
}
}
Loading