aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.thicket/tickets.jsonl5
-rw-r--r--frontend/src/App.tsx5
-rw-r--r--frontend/src/components/FeedItem.tsx4
-rw-r--r--frontend/src/components/FeedItems.test.tsx10
-rw-r--r--frontend/src/components/FeedItems.tsx7
-rw-r--r--frontend/src/components/FeedList.tsx5
-rw-r--r--frontend/src/components/Login.tsx4
-rw-r--r--frontend/src/components/Settings.tsx7
-rw-r--r--frontend/src/components/TagView.test.tsx4
-rw-r--r--frontend/src/utils.ts31
-rw-r--r--web/web.go48
-rw-r--r--web/web_test.go44
12 files changed, 148 insertions, 26 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl
index 732d5d5..4984dec 100644
--- a/.thicket/tickets.jsonl
+++ b/.thicket/tickets.jsonl
@@ -31,7 +31,8 @@
{"id":"NK-chns2b","title":"reach parity between vanilla js and react v2 ui","description":"Continue implementing the vanilla js one with minimal overhad/depdnencies to be fast and lean. Make sure there are tests and rely on the v2 ui and legacy version as references.","type":"epic","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T04:45:06.813453353Z","updated":"2026-02-14T04:45:06.813453353Z"}
{"id":"NK-d4c8jv","title":"Vanilla JS Parity: Read/Star/Filter","description":"Implement read/unread toggle, star/unstar, and special filters (All, Unread, Starred) in vanilla JS prototype.","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T04:46:32.113504545Z","updated":"2026-02-14T04:47:46.412290355Z"}
{"id":"NK-doss0v","title":"v2 ui: change title fonts to Helvetica Neue","description":"to match style in legacy change font to match Helventic Neue where applicable","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T06:27:57.270935467Z","updated":"2026-02-14T06:31:42.798620609Z"}
-{"id":"NK-dp5efo","title":"v2 ui: themes","description":"simplify the themes selector by getting of the giant THEMES title\n\nloook at the logic for the theme colors, it doesn't look like the feed item text is changing right. look at the legacy one for behavior.","type":"task","status":"open","priority":0,"labels":null,"assignee":"","created":"2026-02-14T06:30:23.170098963Z","updated":"2026-02-14T06:30:23.170098963Z"}
+{"id":"NK-dp5efo","title":"v2 ui: themes","description":"simplify the themes selector by getting of the giant THEMES title\n\nloook at the logic for the theme colors, it doesn't look like the feed item text is changing right. look at the legacy one for behavior.\n\nLet's also simplify down to just light and dark themes (white bg, black bg) and use an emoji or something to distinguish.","type":"bug","status":"open","priority":0,"labels":null,"assignee":"","created":"2026-02-14T06:30:23.170098963Z","updated":"2026-02-14T06:30:23.170098963Z"}
+{"id":"NK-e07i2w","title":"v2 ui: sidebar design","description":"I don't like the new sidebar design in v2 compared to v1. although I realize the v1 is kind of dated. can you take another pass at it -- more like v1, but you can spice it up a little bit maybe. but remember, this is a minimalist, simple, fast rss reader.","type":"feature","status":"open","priority":1,"labels":null,"assignee":"","created":"2026-02-14T17:07:54.877100059Z","updated":"2026-02-14T17:07:54.877100059Z"}
{"id":"NK-ed1iah","title":"Make feed crawling async in API","description":"Currently, POST /api/feed triggers an immediate crawl which blocks the response (or at least keeps the goroutine alive). Refactor the crawling architecture to be truly async with a job queue or status updates, improving API responsiveness and reliability.","type":"cleanup","status":"icebox","priority":4,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.908243985Z","updated":"2026-02-13T04:26:55.908243985Z"}
{"id":"NK-ek0cox","title":"Implement Item Interactions","description":"Add ability to toggle read/unread and star/unstar status for items. Use PUT /item/:id","type":"","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-13T14:55:14.825454967Z","updated":"2026-02-13T14:58:18.307521003Z"}
{"id":"NK-fkc119","title":"setup github ci","description":"Maybe it'd be nice to have github run the tests. Is that a thing we can try to setup","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T03:16:32.574415787Z","updated":"2026-02-14T03:23:01.837550873Z"}
@@ -39,7 +40,7 @@
{"id":"NK-fnaohu","title":"UI Styling: Dark Mode Support","description":"","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-13T18:05:19.59504351Z","updated":"2026-02-13T18:11:46.326064329Z"}
{"id":"NK-fpzx66","title":"v2 ui - title styling","description":"The title of the article stays blue and bold regardless of read state.","type":"bug","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-14T03:22:55.339956853Z","updated":"2026-02-14T03:28:01.555909701Z"}
{"id":"NK-gdf99z","title":"TUI is terrible and needs fixing","description":"The TUI doesn't really work and doesn't make sense. Think very hard and look at the v2 HTML UI implementation and make something cool like that. Probably needs tests too.","type":"epic","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T03:51:59.882212859Z","updated":"2026-02-14T04:31:28.290051717Z"}
-{"id":"NK-gfh33y","title":"[security] Implement CSRF Protection for API","description":"Add CSRF protection to all state-changing API endpoints. 1. Implement a middleware that generates a CSRF token and sets it in a cookie (readable by JS) or header. 2. Update the AuthWrap middleware to validate the presence of this token in the X-CSRF-Token header for all unsafe methods (POST, PUT, DELETE). 3. Update the React frontend to read the token and include it in all API requests.","type":"","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-14T16:35:56.341543505Z","updated":"2026-02-14T16:36:49.305429179Z"}
+{"id":"NK-gfh33y","title":"[security] Implement CSRF Protection for API","description":"Add CSRF protection to all state-changing API endpoints. 1. Implement a middleware that generates a CSRF token and sets it in a cookie (readable by JS) or header. 2. Update the AuthWrap middleware to validate the presence of this token in the X-CSRF-Token header for all unsafe methods (POST, PUT, DELETE). 3. Update the React frontend to read the token and include it in all API requests.","type":"","status":"closed","priority":2,"labels":null,"assignee":"","created":"2026-02-14T16:35:56.341543505Z","updated":"2026-02-14T17:08:53.079904915Z"}
{"id":"NK-gnxc6e","title":"Feed list collapsed by default","description":"The list of feeds on the left side should be collapsed by default, with a little control to extend it.","type":"feature","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T00:58:15.661695308Z","updated":"2026-02-14T01:29:12.82081713Z"}
{"id":"NK-gqkh96","title":"Remaining test coverage gaps","description":"Cross-package test coverage is at 81.2%. The remaining untested functions are: GetFullContent (goose HTTP extraction), indexHandler/serveBoxedFile (rice.MustFindBox), Serve (starts HTTP server), main, util.init. To reach 90%, consider: (1) refactoring GetFullContent to accept an interface for HTTP fetching, (2) refactoring Serve to extract route setup into a testable function, (3) mocking rice.MustFindBox, (4) using feeds from https://trenchant.org/feeds.txt as static test fixtures for integration tests.","type":"cleanup","status":"closed","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:30.298141982Z","updated":"2026-02-14T02:44:05.399097286Z"}
{"id":"NK-hspao2","title":"Vanilla JS: Implement Test Infrastructure","description":"Setup testing infrastructure for vanilla JS prototype to ensure 80% coverage. Refactor app.js for testability and add unit tests.","type":"task","status":"closed","priority":1,"labels":null,"assignee":"","created":"2026-02-14T05:13:11.587767054Z","updated":"2026-02-14T05:13:11.587767054Z"}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4835cd3..7943f60 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './components/Login';
import './App.css';
+import { apiFetch } from './utils';
// Protected Route wrapper
function RequireAuth({ children }: { children: React.ReactElement }) {
@@ -9,7 +10,7 @@ function RequireAuth({ children }: { children: React.ReactElement }) {
const location = useLocation();
useEffect(() => {
- fetch('/api/auth')
+ apiFetch('/api/auth')
.then((res) => {
if (res.ok) {
setAuth(true);
@@ -70,7 +71,7 @@ function Dashboard({ theme, setTheme }: { theme: string; setTheme: (t: string) =
<button
onClick={() => {
- fetch('/api/logout', { method: 'POST' }).then(
+ apiFetch('/api/logout', { method: 'POST' }).then(
() => (window.location.href = '/v2/login')
);
}}
diff --git a/frontend/src/components/FeedItem.tsx b/frontend/src/components/FeedItem.tsx
index 9b40114..ae4462a 100644
--- a/frontend/src/components/FeedItem.tsx
+++ b/frontend/src/components/FeedItem.tsx
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
import type { Item } from '../types';
import './FeedItem.css';
+import { apiFetch } from '../utils';
+
interface FeedItemProps {
item: Item;
}
@@ -24,7 +26,7 @@ export default function FeedItem({ item: initialItem }: FeedItemProps) {
const previousItem = item;
setItem(newItem);
- fetch(`/api/item/${newItem._id}`, {
+ apiFetch(`/api/item/${newItem._id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx
index 4d96da9..ca0dc98 100644
--- a/frontend/src/components/FeedItems.test.tsx
+++ b/frontend/src/components/FeedItems.test.tsx
@@ -21,7 +21,7 @@ describe('FeedItems Component', () => {
});
it('renders loading state', () => {
- (global.fetch as any).mockImplementation(() => new Promise(() => {}));
+ (global.fetch as any).mockImplementation(() => new Promise(() => { }));
render(
<MemoryRouter initialEntries={['/feed/1']}>
<Routes>
@@ -70,7 +70,7 @@ describe('FeedItems Component', () => {
const params = new URLSearchParams();
params.append('feed_id', '1');
params.append('read_filter', 'unread');
- expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`);
+ expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`, expect.anything());
});
it('handles keyboard shortcuts', async () => {
@@ -138,7 +138,7 @@ describe('FeedItems Component', () => {
});
// Capture the callback
- let observerCallback: IntersectionObserverCallback = () => {};
+ let observerCallback: IntersectionObserverCallback = () => { };
// Override the mock to capture callback
class MockIntersectionObserver {
@@ -199,7 +199,7 @@ describe('FeedItems Component', () => {
.mockResolvedValueOnce({ ok: true, json: async () => initialItems })
.mockResolvedValueOnce({ ok: true, json: async () => moreItems });
- let observerCallback: IntersectionObserverCallback = () => {};
+ let observerCallback: IntersectionObserverCallback = () => { };
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
observerCallback = callback;
@@ -240,7 +240,7 @@ describe('FeedItems Component', () => {
const params = new URLSearchParams();
params.append('max_id', '101');
params.append('read_filter', 'unread');
- expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`);
+ expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`, expect.anything());
});
});
});
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx
index 81c9139..b497e9d 100644
--- a/frontend/src/components/FeedItems.tsx
+++ b/frontend/src/components/FeedItems.tsx
@@ -3,6 +3,7 @@ import { useParams, useSearchParams } from 'react-router-dom';
import type { Item } from '../types';
import FeedItem from './FeedItem';
import './FeedItems.css';
+import { apiFetch } from '../utils';
export default function FeedItems() {
const { feedId, tagName } = useParams<{ feedId: string; tagName: string }>();
@@ -61,7 +62,7 @@ export default function FeedItems() {
url += `?${queryString}`;
}
- fetch(url)
+ apiFetch(url)
.then((res) => {
if (!res.ok) {
throw new Error('Failed to fetch items');
@@ -103,7 +104,7 @@ export default function FeedItems() {
// Optimistic update
setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i)));
- fetch(`/api/item/${item._id}`, {
+ apiFetch(`/api/item/${item._id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true, starred: item.starred }),
@@ -115,7 +116,7 @@ export default function FeedItems() {
// Optimistic update
setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i)));
- fetch(`/api/item/${item._id}`, {
+ apiFetch(`/api/item/${item._id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: item.read, starred: !item.starred }),
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx
index 497baf8..d2dc8e1 100644
--- a/frontend/src/components/FeedList.tsx
+++ b/frontend/src/components/FeedList.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams, useLocation, useParams } from 'react-router-dom';
import type { Feed, Category } from '../types';
import './FeedList.css';
+import { apiFetch } from '../utils';
export default function FeedList({
theme,
@@ -38,11 +39,11 @@ export default function FeedList({
useEffect(() => {
Promise.all([
- fetch('/api/feed/').then((res) => {
+ apiFetch('/api/feed/').then((res) => {
if (!res.ok) throw new Error('Failed to fetch feeds');
return res.json();
}),
- fetch('/api/tag').then((res) => {
+ apiFetch('/api/tag').then((res) => {
if (!res.ok) throw new Error('Failed to fetch tags');
return res.json();
}),
diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx
index 5f63248..ba2cd96 100644
--- a/frontend/src/components/Login.tsx
+++ b/frontend/src/components/Login.tsx
@@ -2,6 +2,8 @@ import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import './Login.css';
+import { apiFetch } from '../utils';
+
export default function Login() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -16,7 +18,7 @@ export default function Login() {
const params = new URLSearchParams();
params.append('password', password);
- const res = await fetch('/api/login', {
+ const res = await apiFetch('/api/login', {
method: 'POST',
body: params,
});
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx
index b4f6a3b..3f508e9 100644
--- a/frontend/src/components/Settings.tsx
+++ b/frontend/src/components/Settings.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import type { Feed } from '../types';
import './Settings.css';
+import { apiFetch } from '../utils';
export default function Settings() {
const [feeds, setFeeds] = useState<Feed[]>([]);
@@ -10,7 +11,7 @@ export default function Settings() {
const fetchFeeds = () => {
setLoading(true);
- fetch('/api/feed/')
+ apiFetch('/api/feed/')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch feeds');
return res.json();
@@ -36,7 +37,7 @@ export default function Settings() {
if (!newFeedUrl) return;
setLoading(true);
- fetch('/api/feed/', {
+ apiFetch('/api/feed/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: newFeedUrl }),
@@ -59,7 +60,7 @@ export default function Settings() {
if (!globalThis.confirm('Are you sure you want to delete this feed?')) return;
setLoading(true);
- fetch(`/api/feed/${id}`, {
+ apiFetch(`/api/feed/${id}`, {
method: 'DELETE',
})
.then((res) => {
diff --git a/frontend/src/components/TagView.test.tsx b/frontend/src/components/TagView.test.tsx
index 10872bc..8f7eb86 100644
--- a/frontend/src/components/TagView.test.tsx
+++ b/frontend/src/components/TagView.test.tsx
@@ -35,7 +35,7 @@ describe('Tag View Integration', () => {
render(
<MemoryRouter>
- <FeedList />
+ <FeedList theme="light" setTheme={() => { }} />
</MemoryRouter>
);
@@ -81,6 +81,6 @@ describe('Tag View Integration', () => {
const params = new URLSearchParams();
params.append('tag', 'Tech');
params.append('read_filter', 'unread');
- expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`);
+ expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`, expect.anything());
});
});
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
new file mode 100644
index 0000000..129ebbb
--- /dev/null
+++ b/frontend/src/utils.ts
@@ -0,0 +1,31 @@
+export function getCookie(name: string): string | undefined {
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(';').shift();
+}
+
+/**
+ * A wrapper around fetch that automatically includes the CSRF token
+ * for state-changing requests (POST, PUT, DELETE).
+ */
+export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
+ const method = init?.method?.toUpperCase() || 'GET';
+ const isStateChanging = ['POST', 'PUT', 'DELETE'].includes(method);
+
+ const headers = new Headers(init?.headers || {});
+
+ if (isStateChanging) {
+ const token = getCookie('csrf_token');
+ if (token) {
+ headers.set('X-CSRF-Token', token);
+ }
+ }
+
+ // Ensure requests are treated as coming from our own origin if needed,
+ // but for a same-origin API, standard fetch defaults are usually fine.
+
+ return fetch(input, {
+ ...init,
+ headers,
+ });
+}
diff --git a/web/web.go b/web/web.go
index da951e5..3c53edf 100644
--- a/web/web.go
+++ b/web/web.go
@@ -1,7 +1,9 @@
package web
import (
+ "crypto/rand"
"encoding/base64"
+ "encoding/hex"
"fmt"
"io/fs"
"io/ioutil"
@@ -114,7 +116,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password")
if password == config.Config.DigestPassword {
v, _ := bcrypt.GenerateFromPassword([]byte(password), 0)
- c := http.Cookie{Name: AuthCookie, Value: string(v), Path: "/", MaxAge: SecondsInAYear, HttpOnly: false}
+ c := http.Cookie{Name: AuthCookie, Value: string(v), Path: "/", MaxAge: SecondsInAYear, HttpOnly: true}
http.SetCookie(w, &c)
http.Redirect(w, r, "/", 307)
} else {
@@ -126,7 +128,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request) {
}
func logoutHandler(w http.ResponseWriter, r *http.Request) {
- c := http.Cookie{Name: AuthCookie, MaxAge: 0, Path: "/", HttpOnly: false}
+ c := http.Cookie{Name: AuthCookie, MaxAge: 0, Path: "/", HttpOnly: true}
http.SetCookie(w, &c)
fmt.Fprintf(w, "you are logged out")
}
@@ -195,7 +197,7 @@ func apiLoginHandler(w http.ResponseWriter, r *http.Request) {
if password == config.Config.DigestPassword {
v, _ := bcrypt.GenerateFromPassword([]byte(password), 0)
- c := http.Cookie{Name: AuthCookie, Value: string(v), Path: "/", MaxAge: SecondsInAYear, HttpOnly: false}
+ c := http.Cookie{Name: AuthCookie, Value: string(v), Path: "/", MaxAge: SecondsInAYear, HttpOnly: true}
http.SetCookie(w, &c)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok"}`)
@@ -258,7 +260,7 @@ func NewRouter(cfg *config.Settings) http.Handler {
mux.Handle("/", GzipMiddleware(AuthWrap(http.HandlerFunc(indexHandler))))
- return mux
+ return CSRFMiddleware(mux)
}
func Serve(cfg *config.Settings) {
@@ -341,8 +343,44 @@ func GzipMiddleware(next http.Handler) http.Handler {
}
func apiLogoutHandler(w http.ResponseWriter, r *http.Request) {
- c := http.Cookie{Name: AuthCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: false}
+ c := http.Cookie{Name: AuthCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true}
http.SetCookie(w, &c)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok"}`)
}
+
+func generateRandomToken(n int) string {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return ""
+ }
+ return hex.EncodeToString(b)
+}
+
+func CSRFMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie("csrf_token")
+ var token string
+ if err != nil {
+ token = generateRandomToken(16)
+ http.SetCookie(w, &http.Cookie{
+ Name: "csrf_token",
+ Value: token,
+ Path: "/",
+ HttpOnly: false, // accessible by JS
+ SameSite: http.SameSiteLaxMode,
+ })
+ } else {
+ token = cookie.Value
+ }
+
+ if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete {
+ headerToken := r.Header.Get("X-CSRF-Token")
+ if headerToken == "" || headerToken != token {
+ http.Error(w, "CSRF token mismatch", http.StatusForbidden)
+ return
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/web/web_test.go b/web/web_test.go
index aca3aed..89ca998 100644
--- a/web/web_test.go
+++ b/web/web_test.go
@@ -730,3 +730,47 @@ func TestGzipMiddlewareNonCompressible(t *testing.T) {
t.Error("Expected no gzip for image/png")
}
}
+
+func TestCSRFMiddleware(t *testing.T) {
+ handler := CSRFMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ // Case 1: GET should succeed and set a cookie
+ req := httptest.NewRequest("GET", "/", nil)
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200 for GET, got %d", rr.Code)
+ }
+ cookies := rr.Result().Cookies()
+ var csrfCookie *http.Cookie
+ for _, c := range cookies {
+ if c.Name == "csrf_token" {
+ csrfCookie = c
+ break
+ }
+ }
+ if csrfCookie == nil {
+ t.Fatal("Expected csrf_token cookie to be set on first GET")
+ }
+
+ // Case 2: POST without token should fail
+ req = httptest.NewRequest("POST", "/", nil)
+ req.AddCookie(csrfCookie)
+ rr = httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusForbidden {
+ t.Errorf("Expected 403 for POST without token, got %d", rr.Code)
+ }
+
+ // Case 3: POST with valid token should succeed
+ req = httptest.NewRequest("POST", "/", nil)
+ req.AddCookie(csrfCookie)
+ req.Header.Set("X-CSRF-Token", csrfCookie.Value)
+ rr = httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("Expected 200 for POST with valid token, got %d", rr.Code)
+ }
+}