server: add basic is-admin tests and bug fixes

This commit is contained in:
Max Leiter 2022-04-05 16:17:08 -07:00
parent 06d847dfa3
commit e5b9b65b55
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
9 changed files with 182 additions and 106 deletions

View file

@ -49,9 +49,9 @@ const ExpirationBadge = ({
return ( return (
<Badge type={isExpired ? "error" : "warning"}> <Badge type={isExpired ? "error" : "warning"}>
<Tooltip <Tooltip
hideArrow
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}> text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}>
{isExpired ? "Expired" : `Expires ${timeUntilString}`} {isExpired ? "Expired" : `Expires ${timeUntilString}`}
hideArrow
</Tooltip> </Tooltip>
</Badge> </Badge>
) )

View file

@ -1,16 +1,16 @@
import * as request from 'supertest' import * as request from "supertest"
import { app } from '../app' import { app } from "../app"
describe('GET /health', () => { describe("GET /health", () => {
it('should return 200 and a status up', (done) => { it("should return 200 and a status up", (done) => {
request(app) request(app)
.get(`/health`) .get(`/health`)
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
.end((err, res) => { .end((err, res) => {
if (err) return done(err) if (err) return done(err)
expect(res.body).toMatchObject({ 'status': 'UP' }) expect(res.body).toMatchObject({ status: "UP" })
done() done()
}) })
}) })
}) })

View file

@ -1,62 +1,64 @@
import { config } from "../config" import { config } from "../config"
describe("Config", () => { describe("Config", () => {
it("should build a valid development config when no environment is set", () => { it("should build a valid development config when no environment is set", () => {
const emptyEnv = {}; const emptyEnv = {}
const result = config(emptyEnv); const result = config(emptyEnv)
expect(result).toHaveProperty("is_production", false) expect(result).toHaveProperty("is_production", false)
expect(result).toHaveProperty("port") expect(result).toHaveProperty("port")
expect(result).toHaveProperty("jwt_secret") expect(result).toHaveProperty("jwt_secret")
expect(result).toHaveProperty("drift_home") expect(result).toHaveProperty("drift_home")
expect(result).toHaveProperty("memory_db") expect(result).toHaveProperty("memory_db")
expect(result).toHaveProperty("enable_admin") expect(result).toHaveProperty("enable_admin")
expect(result).toHaveProperty("secret_key") expect(result).toHaveProperty("secret_key")
expect(result).toHaveProperty("registration_password") expect(result).toHaveProperty("registration_password")
expect(result).toHaveProperty("welcome_content") expect(result).toHaveProperty("welcome_content")
expect(result).toHaveProperty("welcome_title") expect(result).toHaveProperty("welcome_title")
}) })
it("should fail when building a prod environment without SECRET_KEY", () => { it("should fail when building a prod environment without SECRET_KEY", () => {
expect(() => config({ NODE_ENV: "production" })) expect(() => config({ NODE_ENV: "production" })).toThrow(
.toThrow(new Error("Missing environment variable: SECRET_KEY")) new Error("Missing environment variable: SECRET_KEY")
}) )
})
it("should build a prod config with a SECRET_KEY", () => { it("should build a prod config with a SECRET_KEY", () => {
const result = config({ NODE_ENV: "production", SECRET_KEY: "secret" }) const result = config({ NODE_ENV: "production", SECRET_KEY: "secret" })
expect(result).toHaveProperty("is_production", true) expect(result).toHaveProperty("is_production", true)
expect(result).toHaveProperty("secret_key", "secret") expect(result).toHaveProperty("secret_key", "secret")
}) })
describe("jwt_secret", () => { describe("jwt_secret", () => {
it("should use default jwt_secret when environment is blank string", () => { it("should use default jwt_secret when environment is blank string", () => {
const result = config({ JWT_SECRET: "" }) const result = config({ JWT_SECRET: "" })
expect(result).toHaveProperty("is_production", false) expect(result).toHaveProperty("is_production", false)
expect(result).toHaveProperty("jwt_secret", "myjwtsecret") expect(result).toHaveProperty("jwt_secret", "myjwtsecret")
}) })
}) })
describe("booleans", () => { describe("booleans", () => {
it("should parse 'true' as true", () => { it("should parse 'true' as true", () => {
const result = config({ MEMORY_DB: "true" }) const result = config({ MEMORY_DB: "true" })
expect(result).toHaveProperty("memory_db", true) expect(result).toHaveProperty("memory_db", true)
}) })
it("should parse 'false' as false", () => { it("should parse 'false' as false", () => {
const result = config({ MEMORY_DB: "false" }) const result = config({ MEMORY_DB: "false" })
expect(result).toHaveProperty("memory_db", false) expect(result).toHaveProperty("memory_db", false)
}) })
it("should fail when it is not parseable", () => { it("should fail when it is not parseable", () => {
expect(() => config({ MEMORY_DB: "foo" })) expect(() => config({ MEMORY_DB: "foo" })).toThrow(
.toThrow(new Error("Invalid boolean value: foo")) new Error("Invalid boolean value: foo")
}) )
it("should default to false when the string is empty", () => { })
const result = config({ MEMORY_DB: "" }) it("should default to false when the string is empty", () => {
const result = config({ MEMORY_DB: "" })
expect(result).toHaveProperty("memory_db", false) expect(result).toHaveProperty("memory_db", false)
}) })
}) })
}) })

View file

@ -0,0 +1,17 @@
import getHtmlFromFile from "@lib/get-html-from-drift-file"
describe("get-html-from-drift-file", () => {
it("should not wrap markdown in code blocks", () => {
const markdown = `## My Markdown`
const html = getHtmlFromFile({ content: markdown, title: "my-markdown.md" })
// the string is <h2><a href=\"#my-markdown\" id=\"my-markdown\" style=\"color:inherit\">My Markdown</a></h2>,
// but we dont wan't to be too strict in case markup changes
expect(html).toMatch(/<h2><a.*<\/a><\/h2>/)
})
it("should wrap code in code blocks", () => {
const code = `const foo = "bar"`
const html = getHtmlFromFile({ content: code, title: "my-code.js" })
expect(html).toMatch(/<pre><code class="prism-code language-js">/)
})
})

View file

@ -0,0 +1,50 @@
// import * as request from 'supertest'
// import { app } from '../../../app'
import { NextFunction, Response } from "express"
import isAdmin from "@lib/middleware/is-admin"
import { UserJwtRequest } from "@lib/middleware/jwt"
describe("is-admin middlware", () => {
let mockRequest: Partial<UserJwtRequest>
let mockResponse: Partial<Response>
let nextFunction: NextFunction = jest.fn()
beforeEach(() => {
mockRequest = {}
mockResponse = {
sendStatus: jest.fn()
}
})
it("should return 401 if no authorization header", async () => {
const res = mockResponse as Response
isAdmin(mockRequest as UserJwtRequest, res, nextFunction)
expect(res.sendStatus).toHaveBeenCalledWith(401)
})
it("should return 401 if no token is supplied", async () => {
const req = mockRequest as UserJwtRequest
req.headers = {
authorization: "Bearer"
}
isAdmin(req, mockResponse as Response, nextFunction)
expect(mockResponse.sendStatus).toBeCalledWith(401)
})
it("should return 404 if config.enable_admin is false", async () => {
jest.mock("../../config", () => ({
enable_admin: false
}))
const req = mockRequest as UserJwtRequest
req.headers = {
authorization: "Bearer 123"
}
isAdmin(req, mockResponse as Response, nextFunction)
expect(mockResponse.sendStatus).toBeCalledWith(404)
})
// TODO: 403 if !isAdmin
// Verify it calls next() if admin
// Requires mocking config.enable_admin
})

View file

@ -6,12 +6,12 @@ type Config = {
memory_db: boolean memory_db: boolean
enable_admin: boolean enable_admin: boolean
secret_key: string secret_key: string
registration_password: string, registration_password: string
welcome_content: string | undefined, welcome_content: string | undefined
welcome_title: string | undefined, welcome_title: string | undefined
} }
type EnvironmentValue = string | undefined; type EnvironmentValue = string | undefined
type Environment = { [key: string]: EnvironmentValue } type Environment = { [key: string]: EnvironmentValue }
export const config = (env: Environment): Config => { export const config = (env: Environment): Config => {
@ -34,7 +34,10 @@ export const config = (env: Environment): Config => {
return str return str
} }
const defaultIfUndefined = (str: EnvironmentValue, defaultValue: string): string => { const defaultIfUndefined = (
str: EnvironmentValue,
defaultValue: string
): string => {
if (str === undefined) { if (str === undefined) {
return defaultValue return defaultValue
} }
@ -52,11 +55,15 @@ export const config = (env: Environment): Config => {
} }
} }
const is_production = env.NODE_ENV === "production"; const is_production = env.NODE_ENV === "production"
const developmentDefault = (str: EnvironmentValue, name: string, defaultValue: string): string => { const developmentDefault = (
if (is_production) return throwIfUndefined(str, name); str: EnvironmentValue,
return defaultIfUndefined(str, defaultValue); name: string,
defaultValue: string
): string => {
if (is_production) return throwIfUndefined(str, name)
return defaultIfUndefined(str, defaultValue)
} }
validNodeEnvs(env.NODE_ENV) validNodeEnvs(env.NODE_ENV)
@ -72,7 +79,6 @@ export const config = (env: Environment): Config => {
registration_password: env.REGISTRATION_PASSWORD ?? "", registration_password: env.REGISTRATION_PASSWORD ?? "",
welcome_content: env.WELCOME_CONTENT, welcome_content: env.WELCOME_CONTENT,
welcome_title: env.WELCOME_TITLE welcome_title: env.WELCOME_TITLE
} }
return config return config
} }

View file

@ -5,37 +5,35 @@ import { File } from "@lib/models/File"
* returns rendered HTML from a Drift file * returns rendered HTML from a Drift file
*/ */
function getHtmlFromFile({ content, title }: Pick<File, "content" | "title">) { function getHtmlFromFile({ content, title }: Pick<File, "content" | "title">) {
const renderAsMarkdown = [ const renderAsMarkdown = [
"markdown", "markdown",
"md", "md",
"mdown", "mdown",
"mkdn", "mkdn",
"mkd", "mkd",
"mdwn", "mdwn",
"mdtxt", "mdtxt",
"mdtext", "mdtext",
"text", "text",
"" ""
] ]
const fileType = () => { const fileType = () => {
const pathParts = title.split(".") const pathParts = title.split(".")
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : "" const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : ""
return language return language
} }
const type = fileType() const type = fileType()
let contentToRender: string = content || "" let contentToRender: string = content || ""
if (!renderAsMarkdown.includes(type)) { if (!renderAsMarkdown.includes(type)) {
contentToRender = `~~~${type} contentToRender = `~~~${type}
${content} ${content}
~~~` ~~~`
} else { } else {
contentToRender = "\n" + content contentToRender = "\n" + content
} }
console.log(contentToRender.slice(0, 50)) const html = markdown(contentToRender)
const html = markdown(contentToRender) return html
return html
} }
export default getHtmlFromFile
export default getHtmlFromFile

View file

@ -11,16 +11,20 @@ export interface UserJwtRequest extends Request {
user?: User user?: User
} }
export default function authenticateToken( export default function isAdmin(
req: UserJwtRequest, req: UserJwtRequest,
res: Response, res: Response,
next: NextFunction next: NextFunction
) { ) {
if (!req.headers?.authorization) {
return res.sendStatus(401)
}
const authHeader = req.headers["authorization"] const authHeader = req.headers["authorization"]
const token = authHeader && authHeader.split(" ")[1] const token = authHeader && authHeader.split(" ")[1]
if (token == null) return res.sendStatus(401) if (!token) return res.sendStatus(401)
console.log(config)
if (!config.enable_admin) return res.sendStatus(404) if (!config.enable_admin) return res.sendStatus(404)
jwt.verify(token, config.jwt_secret, async (err: any, user: any) => { jwt.verify(token, config.jwt_secret, async (err: any, user: any) => {
if (err) return res.sendStatus(403) if (err) return res.sendStatus(403)
const userObj = await UserModel.findByPk(user.id, { const userObj = await UserModel.findByPk(user.id, {

View file

@ -357,4 +357,3 @@ posts.delete("/:id", jwt, async (req: UserJwtRequest, res, next) => {
next(e) next(e)
} }
}) })