kdlb9zs9n: Scaffold Next.js app and create the server session from a Minnect login
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Minnect host base URLs. Copy this file to .env.local (gitignored) to override.
|
||||
# Sane defaults are built in, so .env.local is optional.
|
||||
|
||||
# Profiles host: sign-in, auth/me, refresh, revoke, feed-token exchange.
|
||||
PROFILES_BASE_URL=https://profiles.minnect.com
|
||||
|
||||
# Feed host: home feed, posts, likes, comments (used by later tasks).
|
||||
FEED_BASE_URL=https://feed.sharedservices.vt.com
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -6,3 +6,7 @@ out/
|
||||
.worktrees/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# typescript / next
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6999
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "frontend-helper",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.5.28",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.10.7",
|
||||
"@types/react": "19.0.7",
|
||||
"@types/react-dom": "19.0.3",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "15.5.28",
|
||||
"typescript": "5.7.3",
|
||||
"vitest": "2.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { loginToMinnect, LoginError } from "@/lib/minnect";
|
||||
import { createSession, getSession } from "@/lib/session-store";
|
||||
import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie";
|
||||
|
||||
// This route holds the session on the server. The browser talks only to this
|
||||
// app; Minnect tokens never leave the server (ADR-001).
|
||||
//
|
||||
// This task implements POST (sign in) and GET (who am I). DELETE/revoke and
|
||||
// token refresh are separate, later tasks and are not implemented here.
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function badRequest(message: string) {
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/session
|
||||
* Body: { email, password }
|
||||
* On success: sets the opaque session cookie, returns { name, email, pictureUrl }.
|
||||
* On failure: no session, no cookie, one generic message.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
const record = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||
const email = typeof record.email === "string" ? record.email : "";
|
||||
const password = typeof record.password === "string" ? record.password : "";
|
||||
|
||||
if (!email || !password) {
|
||||
return badRequest("Email and password are required.");
|
||||
}
|
||||
|
||||
try {
|
||||
const { person, tokens } = await loginToMinnect(email, password);
|
||||
|
||||
const sessionId = createSession({
|
||||
profileAccessToken: tokens.profileAccessToken,
|
||||
profileAccessTokenExpiresAt: tokens.profileAccessTokenExpiresAt,
|
||||
profileRefreshToken: tokens.profileRefreshToken,
|
||||
feedAccessToken: tokens.feedAccessToken,
|
||||
person,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const res = NextResponse.json({
|
||||
name: person.name,
|
||||
email: person.email,
|
||||
pictureUrl: person.pictureUrl,
|
||||
});
|
||||
res.cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions());
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (err instanceof LoginError) {
|
||||
return NextResponse.json({ error: err.publicMessage }, { status: err.status });
|
||||
}
|
||||
// Never echo an unexpected error to the browser.
|
||||
return NextResponse.json({ error: "Could not sign you in." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/session
|
||||
* Returns { name, email, pictureUrl } for the signed-in person, or 401
|
||||
* { error: "sign_in" } when the session is missing or dead.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const sessionId = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: "sign_in" }, { status: 401 });
|
||||
}
|
||||
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "sign_in" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
name: session.person.name,
|
||||
email: session.person.email,
|
||||
pictureUrl: session.person.pictureUrl,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Minnect feed browser",
|
||||
description: "Sign in with your Minnect account to read and write the home feed.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Placeholder home page. The sign-in screen and the feed UI are built in
|
||||
// later tasks (tlv84yb7n and the feed tasks). This task delivers the server
|
||||
// session routes under /api/session.
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main style={{ maxWidth: 640, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||
<h1>Minnect feed browser</h1>
|
||||
<p>The sign-in screen is built in a later task.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// The single browser-facing cookie. It holds only an opaque session id,
|
||||
// never a Minnect token (ADR-001). httpOnly keeps it out of JavaScript.
|
||||
|
||||
export const SESSION_COOKIE = "mfb_session";
|
||||
|
||||
export interface SessionCookieOptions {
|
||||
httpOnly: true;
|
||||
secure: boolean;
|
||||
sameSite: "lax";
|
||||
path: "/";
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(): SessionCookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
// Secure is required in production (ADR-001). It is relaxed in local
|
||||
// development so the cookie works over plain http on localhost.
|
||||
secure: process.env.NODE_ENV !== "development",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Minnect host base URLs. Defaults match the build contract (docs spec, "Hosts").
|
||||
// Override per environment in .env.local (gitignored). Never put tokens here.
|
||||
|
||||
function baseUrl(value: string | undefined, fallback: string): string {
|
||||
const v = (value ?? "").trim();
|
||||
return (v || fallback).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export const PROFILES_BASE_URL = baseUrl(
|
||||
process.env.PROFILES_BASE_URL,
|
||||
"https://profiles.minnect.com",
|
||||
);
|
||||
|
||||
export const FEED_BASE_URL = baseUrl(
|
||||
process.env.FEED_BASE_URL,
|
||||
"https://feed.sharedservices.vt.com",
|
||||
);
|
||||
@@ -0,0 +1,188 @@
|
||||
import { PROFILES_BASE_URL } from "./env";
|
||||
import type { SessionPerson } from "./session-store";
|
||||
|
||||
// Talks to the Minnect profiles host to turn an email + password into the
|
||||
// tokens a session needs. Field-order extraction follows the build contract
|
||||
// (docs spec, "Login"). Never logs or echoes the Minnect error body.
|
||||
|
||||
/**
|
||||
* A login failure with a message that is safe to show the browser.
|
||||
* The raw Minnect error is never attached, so an unknown email and a wrong
|
||||
* password are indistinguishable to the client (spec requirement).
|
||||
*/
|
||||
export class LoginError extends Error {
|
||||
readonly status: number;
|
||||
readonly publicMessage: string;
|
||||
|
||||
constructor(status: number, publicMessage: string) {
|
||||
super(publicMessage);
|
||||
this.name = "LoginError";
|
||||
this.status = status;
|
||||
this.publicMessage = publicMessage;
|
||||
}
|
||||
}
|
||||
|
||||
const GENERIC_SIGN_IN_ERROR = "Email or password is incorrect.";
|
||||
const FEED_ERROR = "Could not open the feed.";
|
||||
|
||||
export interface LoginTokens {
|
||||
profileAccessToken: string;
|
||||
profileAccessTokenExpiresAt: number | null;
|
||||
profileRefreshToken: string;
|
||||
feedAccessToken: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
person: SessionPerson;
|
||||
tokens: LoginTokens;
|
||||
}
|
||||
|
||||
export interface LoginOptions {
|
||||
/** Injectable fetch, for testing. Defaults to the global fetch. */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
type JsonBody = Record<string, unknown> | null;
|
||||
|
||||
function jsonHeaders(token?: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function parseJson(res: Response): Promise<JsonBody> {
|
||||
try {
|
||||
return (await res.json()) as JsonBody;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
// Profile access/refresh tokens, in the spec's documented field order:
|
||||
// 1. data.accessToken / data.refreshToken
|
||||
// 2. accessToken / refreshToken
|
||||
// expiresIn (seconds) from data.expiresIn, then expiresIn.
|
||||
function readProfileTokens(body: JsonBody): {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
} {
|
||||
const root = asRecord(body);
|
||||
const data = asRecord(root.data);
|
||||
const accessToken = asString(data.accessToken) ?? asString(root.accessToken);
|
||||
const refreshToken =
|
||||
asString(data.refreshToken) ?? asString(root.refreshToken);
|
||||
const rawExpires = data.expiresIn ?? root.expiresIn;
|
||||
const expiresIn = typeof rawExpires === "number" ? rawExpires : undefined;
|
||||
return { accessToken, refreshToken, expiresIn };
|
||||
}
|
||||
|
||||
// Feed access token, in the spec's documented field order:
|
||||
// 1. accessToken 2. data.accessToken 3. token 4. data.token
|
||||
function readFeedToken(body: JsonBody): string | undefined {
|
||||
const root = asRecord(body);
|
||||
const data = asRecord(root.data);
|
||||
return (
|
||||
asString(root.accessToken) ??
|
||||
asString(data.accessToken) ??
|
||||
asString(root.token) ??
|
||||
asString(data.token)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the three-call sign-in: login -> auth/me -> feed-token exchange.
|
||||
* Returns the person to show and the tokens to store server-side.
|
||||
* Throws LoginError on any failure; it never creates a session itself.
|
||||
*/
|
||||
export async function loginToMinnect(
|
||||
email: string,
|
||||
password: string,
|
||||
options: LoginOptions = {},
|
||||
): Promise<LoginResult> {
|
||||
const doFetch = options.fetchImpl ?? fetch;
|
||||
|
||||
// 1. Sign in.
|
||||
const loginRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/login`, {
|
||||
method: "POST",
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (loginRes.status !== 200) {
|
||||
// Wrong password or unknown email both land here with one generic message.
|
||||
throw new LoginError(401, GENERIC_SIGN_IN_ERROR);
|
||||
}
|
||||
const loginBody = await parseJson(loginRes);
|
||||
const {
|
||||
accessToken: profileAccessToken,
|
||||
refreshToken: profileRefreshToken,
|
||||
expiresIn,
|
||||
} = readProfileTokens(loginBody);
|
||||
if (!profileAccessToken || !profileRefreshToken) {
|
||||
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
|
||||
}
|
||||
const profileAccessTokenExpiresAt =
|
||||
typeof expiresIn === "number" ? Date.now() + expiresIn * 1000 : null;
|
||||
|
||||
// 2. Who the person is.
|
||||
const meRes = await doFetch(`${PROFILES_BASE_URL}/api/v4/auth/me`, {
|
||||
method: "GET",
|
||||
headers: jsonHeaders(profileAccessToken),
|
||||
});
|
||||
if (meRes.status !== 200) {
|
||||
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
|
||||
}
|
||||
const meBody = await parseJson(meRes);
|
||||
const user = asRecord(asRecord(meBody).data);
|
||||
const userId = asString(user.userId);
|
||||
const personEmail = asString(user.email);
|
||||
if (!userId || !personEmail) {
|
||||
throw new LoginError(502, GENERIC_SIGN_IN_ERROR);
|
||||
}
|
||||
const name =
|
||||
[user.firstName, user.lastName]
|
||||
.map((p) => (typeof p === "string" ? p.trim() : ""))
|
||||
.filter((p) => p.length > 0)
|
||||
.join(" ") || personEmail;
|
||||
const pictureUrl = asString(user.profilePictureUrl) ?? null;
|
||||
|
||||
// 3. Feed token exchange.
|
||||
const feedRes = await doFetch(
|
||||
`${PROFILES_BASE_URL}/api/v4/auth/external-services/feed`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jsonHeaders(profileAccessToken),
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
if (feedRes.status !== 200) {
|
||||
throw new LoginError(502, FEED_ERROR);
|
||||
}
|
||||
const feedToken = readFeedToken(await parseJson(feedRes));
|
||||
if (!feedToken) {
|
||||
throw new LoginError(502, FEED_ERROR);
|
||||
}
|
||||
|
||||
return {
|
||||
person: { userId, name, email: personEmail, pictureUrl },
|
||||
tokens: {
|
||||
profileAccessToken,
|
||||
profileAccessTokenExpiresAt,
|
||||
profileRefreshToken,
|
||||
feedAccessToken: feedToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
// Server-side session store. The browser never sees any of this; it only holds
|
||||
// the opaque id (see cookie.ts). Keyed by a random 256-bit session id.
|
||||
//
|
||||
// NOTE: this is in-memory and per-process. It is fine for now but does not
|
||||
// survive a restart and is not shared across instances. A durable store
|
||||
// (e.g. Redis) is a later concern, tracked outside this task.
|
||||
|
||||
export interface SessionPerson {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
pictureUrl: string | null;
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
profileAccessToken: string;
|
||||
/** Epoch milliseconds when the profile access token expires, or null if unknown. */
|
||||
profileAccessTokenExpiresAt: number | null;
|
||||
profileRefreshToken: string;
|
||||
feedAccessToken: string;
|
||||
person: SessionPerson;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// Survive Next.js dev HMR by stashing the map on globalThis.
|
||||
const globalRef = globalThis as typeof globalThis & {
|
||||
__mfbSessionStore?: Map<string, SessionData>;
|
||||
};
|
||||
|
||||
const store: Map<string, SessionData> =
|
||||
globalRef.__mfbSessionStore ?? new Map<string, SessionData>();
|
||||
globalRef.__mfbSessionStore = store;
|
||||
|
||||
export function createSession(data: SessionData): string {
|
||||
const id = randomBytes(32).toString("hex");
|
||||
store.set(id, data);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getSession(id: string): SessionData | null {
|
||||
return store.get(id) ?? null;
|
||||
}
|
||||
|
||||
export function deleteSession(id: string): void {
|
||||
store.delete(id);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { GET, POST } from "@/app/api/session/route";
|
||||
import { SESSION_COOKIE } from "@/lib/cookie";
|
||||
import { getSession } from "@/lib/session-store";
|
||||
|
||||
// A fetch stub that walks the three Minnect calls. `overrides` lets a test
|
||||
// force a non-200 or a weird body at any step.
|
||||
function makeFetchStub(overrides: Partial<Record<"login" | "me" | "feed", Response>> = {}) {
|
||||
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
|
||||
if (url.endsWith("/api/v4/auth/login")) {
|
||||
return (
|
||||
overrides.login ??
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: { accessToken: "profile-access", refreshToken: "profile-refresh", expiresIn: 3600 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
)
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/api/v4/auth/me")) {
|
||||
return (
|
||||
overrides.me ??
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
userId: "user-123",
|
||||
email: "person@example.com",
|
||||
firstName: "Pat",
|
||||
lastName: "Rivera",
|
||||
profilePictureUrl: "https://cdn.example.com/pat.jpg",
|
||||
role: "user",
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
)
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/api/v4/auth/external-services/feed")) {
|
||||
return (
|
||||
overrides.feed ??
|
||||
new Response(JSON.stringify({ accessToken: "feed-access" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch to ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
function postRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/session", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("POST /api/session", () => {
|
||||
it("wrong password: 401, generic message, no cookie, no session", async () => {
|
||||
const serverError = {
|
||||
data: null,
|
||||
errors: [{ message: "User not found in our records", code: "UnauthorizedException" }],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
makeFetchStub({
|
||||
login: new Response(JSON.stringify(serverError), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await POST(postRequest({ email: "person@example.com", password: "wrong" }));
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(json).toEqual({ error: "Email or password is incorrect." });
|
||||
// The Minnect message must not leak through.
|
||||
expect(JSON.stringify(json)).not.toContain("records");
|
||||
// No cookie is set.
|
||||
expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("missing fields: 400, no cookie", async () => {
|
||||
vi.stubGlobal("fetch", makeFetchStub());
|
||||
const res = await POST(postRequest({ email: "person@example.com" }));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("correct login: 200, person body, opaque session cookie, server session created", async () => {
|
||||
vi.stubGlobal("fetch", makeFetchStub());
|
||||
|
||||
const res = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json).toEqual({
|
||||
name: "Pat Rivera",
|
||||
email: "person@example.com",
|
||||
pictureUrl: "https://cdn.example.com/pat.jpg",
|
||||
});
|
||||
|
||||
const cookie = res.cookies.get(SESSION_COOKIE);
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie?.httpOnly).toBe(true);
|
||||
expect(cookie?.sameSite).toBe("lax");
|
||||
// The cookie is an opaque id, not a Minnect token.
|
||||
const id = cookie!.value;
|
||||
expect(id).not.toContain("profile-access");
|
||||
expect(id).not.toContain("feed-access");
|
||||
|
||||
// The tokens live server-side only.
|
||||
const session = getSession(id);
|
||||
expect(session?.profileAccessToken).toBe("profile-access");
|
||||
expect(session?.profileRefreshToken).toBe("profile-refresh");
|
||||
expect(session?.feedAccessToken).toBe("feed-access");
|
||||
});
|
||||
|
||||
it("feed-token exchange fails: 502, feed message, no cookie", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
makeFetchStub({
|
||||
feed: new Response(JSON.stringify({ data: null }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const res = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||
const json = await res.json();
|
||||
expect(res.status).toBe(502);
|
||||
expect(json).toEqual({ error: "Could not open the feed." });
|
||||
expect(res.cookies.get(SESSION_COOKIE)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/session", () => {
|
||||
it("no cookie: 401 sign_in", async () => {
|
||||
const res = await GET(new NextRequest("http://localhost/api/session"));
|
||||
const json = await res.json();
|
||||
expect(res.status).toBe(401);
|
||||
expect(json).toEqual({ error: "sign_in" });
|
||||
});
|
||||
|
||||
it("unknown session id: 401 sign_in", async () => {
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/session", {
|
||||
headers: { cookie: `${SESSION_COOKIE}=does-not-exist` },
|
||||
}),
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(res.status).toBe(401);
|
||||
expect(json).toEqual({ error: "sign_in" });
|
||||
});
|
||||
|
||||
it("valid session: returns the person's name and email", async () => {
|
||||
vi.stubGlobal("fetch", makeFetchStub());
|
||||
const postRes = await POST(postRequest({ email: "person@example.com", password: "right" }));
|
||||
const id = postRes.cookies.get(SESSION_COOKIE)!.value;
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/session", {
|
||||
headers: { cookie: `${SESSION_COOKIE}=${id}` },
|
||||
}),
|
||||
);
|
||||
const json = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.name).toBe("Pat Rivera");
|
||||
expect(json.email).toBe("person@example.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user