Skip to content

Commit

Permalink
Merge pull request #5 from evantahler/seesion
Browse files Browse the repository at this point in the history
Seesion
  • Loading branch information
evantahler authored Apr 2, 2024
2 parents b48c2fd + 17852df commit a15210e
Show file tree
Hide file tree
Showing 20 changed files with 389 additions and 65 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ servers.web.apiRoute="/api"
servers.web.assetRoute="/assets"
servers.web.pageRoute="/pages"

session.ttl=86400000
session.cookieName="__session"

db.connectionString="postgres://postgres:postgres@localhost:5432/bun"
db.connectionString.test="postgres://postgres:postgres@localhost:5432/bun-test"
db.autoMigrate=true
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ jobs:
services:
redis:
image: redis
ports:
- 6379:6379
postgres:
image: postgres
env:
Expand Down
6 changes: 4 additions & 2 deletions __tests__/actions/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ test("returns user when matched", async () => {
const response = (await res.json()) as ActionResponse<SessionCreate>;
expect(res.status).toBe(200);

expect(response.id).toEqual(1);
expect(response.name).toEqual("Mario Mario");
expect(response.user.id).toEqual(1);
expect(response.user.name).toEqual("Mario Mario");
expect(response.session.createdAt).toBeGreaterThan(0);
expect(response.session.data.userId).toEqual(response.user.id);
});

test("fails when users is not found", async () => {
Expand Down
103 changes: 76 additions & 27 deletions __tests__/actions/user.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { test, expect, beforeAll, afterAll } from "bun:test";
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { api, type ActionResponse } from "../../api";
import type { UserCreate } from "../../actions/user";
import type { UserCreate, UserEdit } from "../../actions/user";
import { config } from "../../config";
import type { SessionCreate } from "../../actions/session";

const url = `http://${config.server.web.host}:${config.server.web.port}`;

Expand All @@ -14,34 +15,82 @@ afterAll(async () => {
await api.stop();
});

test("user can be created", async () => {
const res = await fetch(url + "/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "person 1",
email: "[email protected]",
password: "password",
}),
describe("userCreate", () => {
test("user can be created", async () => {
const res = await fetch(url + "/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Mario Mario",
email: "[email protected]",
password: "mushroom1",
}),
});
const response = (await res.json()) as ActionResponse<UserCreate>;
expect(res.status).toBe(200);

expect(response.user.id).toEqual(1);
expect(response.user.email).toEqual("[email protected]");
});
const response = (await res.json()) as ActionResponse<UserCreate>;
expect(res.status).toBe(200);

expect(response.id).toEqual(1);
expect(response.email).toEqual("[email protected]");
test("email must be unique", async () => {
const res = await fetch(url + "/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Mario Mario",
email: "[email protected]",
password: "mushroom1",
}),
});
const response = (await res.json()) as ActionResponse<UserCreate>;
expect(res.status).toBe(500);
expect(response.error?.message).toMatch(/violates unique constraint/);
});
});

test("email must be unique", async () => {
const res = await fetch(url + "/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "person 1",
email: "[email protected]",
password: "password",
}),
describe("userEdit", () => {
test("it fails without a session", async () => {
const res = await fetch(url + "/api/user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "new name" }),
});
const response = (await res.json()) as ActionResponse<UserEdit>;
expect(res.status).toBe(500);
expect(response.error?.message).toMatch(/User not found/);
});

test("the user can be updated", async () => {
const sessionRes = await fetch(url + "/api/session", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "[email protected]",
password: "mushroom1",
}),
});
const sessionResponse =
(await sessionRes.json()) as ActionResponse<SessionCreate>;
expect(sessionRes.status).toBe(200);
const sessionId = sessionResponse.session.id;

await Bun.sleep(1001);

const res = await fetch(url + "/api/user", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Set-Cookie": `${config.session.cookieName}=${sessionId}`,
},
body: JSON.stringify({ name: "new name" }),
});
const response = (await res.json()) as ActionResponse<UserEdit>;
expect(res.status).toBe(200);
expect(response.user.name).toEqual("new name");
expect(response.user.email).toEqual("[email protected]");
expect(sessionResponse.user.updatedAt).toBeLessThan(
response.user.updatedAt,
);
});
const response = (await res.json()) as ActionResponse<UserCreate>;
expect(res.status).toBe(500);
expect(response.error?.message).toMatch(/violates unique constraint/);
});
18 changes: 15 additions & 3 deletions actions/session.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { eq } from "drizzle-orm";
import { api, type Action, type ActionParams } from "../api";
import { api, type Action, type ActionParams, Connection } from "../api";
import { users } from "../schema/users";
import { ensureString } from "../util/formatters";
import { emailValidator, passwordValidator } from "../util/validators";
import { serializeUser, checkPassword } from "../ops/UserOps";
import { ErrorType, TypedError } from "../classes/TypedError";
import { HTTP_METHOD } from "../classes/Action";
import type { SessionData } from "../initializers/session";

export class SessionCreate implements Action {
name = "sessionCreate";
Expand All @@ -23,7 +24,13 @@ export class SessionCreate implements Action {
},
};

run = async (params: ActionParams<SessionCreate>) => {
run = async (
params: ActionParams<SessionCreate>,
connection: Connection,
): Promise<{
user: Awaited<ReturnType<typeof serializeUser>>;
session: SessionData;
}> => {
const [user] = await api.db.db
.select()
.from(users)
Expand All @@ -41,6 +48,11 @@ export class SessionCreate implements Action {
);
}

return serializeUser(user);
await connection.updateSession({ userId: user.id });

return {
user: await serializeUser(user),
session: connection.session as SessionData,
};
};
}
48 changes: 46 additions & 2 deletions actions/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { api, Action, type ActionParams } from "../api";
import { eq } from "drizzle-orm";
import { api, Action, type ActionParams, Connection } from "../api";
import { HTTP_METHOD } from "../classes/Action";
import { hashPassword, serializeUser } from "../ops/UserOps";
import { users } from "../schema/users";
Expand All @@ -8,6 +9,7 @@ import {
nameValidator,
passwordValidator,
} from "../util/validators";
import { ErrorType, TypedError } from "../classes/TypedError";

export class UserCreate implements Action {
name = "userCreate";
Expand Down Expand Up @@ -40,6 +42,48 @@ export class UserCreate implements Action {
})
.returning();

return serializeUser(user);
return { user: await serializeUser(user) };
}
}

export class UserEdit implements Action {
name = "userEdit";
web = { route: "/user", method: HTTP_METHOD.POST };
inputs = {
name: {
required: false,
validator: nameValidator,
formatter: ensureString,
},
email: {
required: false,
validator: emailValidator,
formatter: ensureString,
},
password: {
required: false,
validator: passwordValidator,
formatter: ensureString,
},
};

async run(params: ActionParams<UserEdit>, connection: Connection) {
if (!connection?.session?.data.userId) {
throw new TypedError("User not found", ErrorType.CONNECTION_ACTION_RUN);
}

const { name, email, password } = params;
const updates = {} as Record<string, string>;
if (name) updates.name = name;
if (email) updates.email = email.toLowerCase();
if (password) updates.password_hash = await hashPassword(password);

const [user] = await api.db.db
.update(users)
.set(updates)
.where(eq(users.id, connection.session.data.userId))
.returning();

return { user: await serializeUser(user) };
}
}
Binary file modified bun.lockb
Binary file not shown.
55 changes: 45 additions & 10 deletions classes/Connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ import { config } from "../config";
import colors from "colors";
import type { Action, ActionParams } from "./Action";
import { ErrorType, TypedError } from "./TypedError";
import type { SessionData } from "../initializers/session";

export class Connection {
type: string;
ipAddress: string;
identifier: string;
id: string;
session?: SessionData;

constructor(type: string, ipAddress: string) {
this.id = crypto.randomUUID();
constructor(
type: string,
identifier: string,
id = crypto.randomUUID() as string,
) {
this.type = type;
this.ipAddress = ipAddress;
this.identifier = identifier;
this.id = id;
}

/**
Expand All @@ -39,6 +45,8 @@ export class Connection {
);
}

await this.loadSession();

const formattedParams = await this.formatParams(params, action);
response = await action.run(formattedParams, this);
} catch (e) {
Expand All @@ -63,17 +71,41 @@ export class Connection {
const duration = new Date().getTime() - reqStartTime;

logger.info(
`${messagePrefix} ${actionName} (${duration}ms) ${method.length > 0 ? `[${method}]` : ""} ${this.ipAddress}${url.length > 0 ? `(${url})` : ""} ${error ? error : ""} ${loggingParams}`,
`${messagePrefix} ${actionName} (${duration}ms) ${method.length > 0 ? `[${method}]` : ""} ${this.identifier}${url.length > 0 ? `(${url})` : ""} ${error ? error : ""} ${loggingParams}`,
);

return { response, error };
}

findAction(actionName: string | undefined) {
async updateSession(data: Record<string, any>) {
await this.loadSession();

if (!this.session) {
throw new TypedError(
"Session not found",
ErrorType.CONNECTION_SESSION_NOT_FOUND,
);
}

return api.session.update(this.session, data);
}

private async loadSession() {
if (this.session) return;

const session = await api.session.load(this);
if (session) {
this.session = session;
} else {
this.session = await api.session.create(this);
}
}

private findAction(actionName: string | undefined) {
return api.actions.actions.find((a) => a.name === actionName);
}

async formatParams(params: FormData, action: Action) {
private async formatParams(params: FormData, action: Action) {
if (!action.inputs) return {} as ActionParams<Action>;

const formattedParams = {} as ActionParams<Action>;
Expand All @@ -95,15 +127,18 @@ export class Connection {
);
}

if ((paramDefinition.required && value === undefined) || value === null) {
if (
paramDefinition.required === true &&
(value === undefined || value === null)
) {
throw new TypedError(
`Missing required param: ${key}`,
ErrorType.CONNECTION_ACTION_PARAM_REQUIRED,
key,
);
}

if (paramDefinition.formatter) {
if (paramDefinition.formatter && value !== undefined && value !== null) {
try {
value = paramDefinition.formatter(value);
} catch (e) {
Expand All @@ -116,7 +151,7 @@ export class Connection {
}
}

if (paramDefinition.validator) {
if (paramDefinition.validator && value !== undefined && value !== null) {
const validationResponse = paramDefinition.validator(value);
if (validationResponse !== true) {
throw new TypedError(
Expand Down
3 changes: 3 additions & 0 deletions classes/TypedError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export enum ErrorType {
"TASK_VALIDATION" = "TASK_VALIDATION",
"SERVER_VALIDATION" = "SERVER_VALIDATION",

// session
"CONNECTION_SESSION_NOT_FOUND" = "CONNECTION_SESSION_NOT_FOUND",

// actions
"CONNECTION_SERVER_ERROR" = "CONNECTION_SERVER_ERROR",
"CONNECTION_ACTION_NOT_FOUND" = "CONNECTION_ACTION_NOT_FOUND",
Expand Down
2 changes: 2 additions & 0 deletions config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { configProcess } from "./process";
import { configServerWeb } from "./server/web";
import { configDatabase } from "./database";
import { configRedis } from "./redis";
import { configSession } from "./session";

export const config = {
process: configProcess,
logger: configLogger,
database: configDatabase,
redis: configRedis,
session: configSession,
server: { web: configServerWeb },
};
6 changes: 6 additions & 0 deletions config/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { loadFromEnvIfSet } from "../util/config";

export const configSession = {
ttl: await loadFromEnvIfSet("session.ttl", 1000 * 60 * 60 * 24), // one day, in seconds
cookieName: await loadFromEnvIfSet("session.cookieName", "__session"),
};
Loading

0 comments on commit a15210e

Please sign in to comment.