Merge pull request #26 from LordMathis/feat/cookies

Implement cookie auth
This commit is contained in:
2024-12-09 21:19:35 +01:00
committed by GitHub
34 changed files with 1714 additions and 1108 deletions

View File

@@ -19,16 +19,10 @@ export const AuthProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
const initializeAuth = async () => { const initializeAuth = async () => {
try { try {
const storedToken = localStorage.getItem('accessToken');
if (storedToken) {
authApi.setAuthToken(storedToken);
const userData = await authApi.getCurrentUser(); const userData = await authApi.getCurrentUser();
setUser(userData); setUser(userData);
}
} catch (error) { } catch (error) {
console.error('Failed to initialize auth:', error); console.error('Failed to initialize auth:', error);
localStorage.removeItem('accessToken');
authApi.clearAuthToken();
} finally { } finally {
setLoading(false); setLoading(false);
setInitialized(true); setInitialized(true);
@@ -40,12 +34,7 @@ export const AuthProvider = ({ children }) => {
const login = useCallback(async (email, password) => { const login = useCallback(async (email, password) => {
try { try {
const { accessToken, user: userData } = await authApi.login( const { user: userData } = await authApi.login(email, password);
email,
password
);
localStorage.setItem('accessToken', accessToken);
authApi.setAuthToken(accessToken);
setUser(userData); setUser(userData);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',
@@ -70,18 +59,17 @@ export const AuthProvider = ({ children }) => {
} catch (error) { } catch (error) {
console.error('Logout failed:', error); console.error('Logout failed:', error);
} finally { } finally {
localStorage.removeItem('accessToken');
authApi.clearAuthToken();
setUser(null); setUser(null);
} }
}, []); }, []);
const refreshToken = useCallback(async () => { const refreshToken = useCallback(async () => {
try { try {
const { accessToken } = await authApi.refreshToken(); const success = await authApi.refreshToken();
localStorage.setItem('accessToken', accessToken); if (!success) {
authApi.setAuthToken(accessToken); await logout();
return true; }
return success;
} catch (error) { } catch (error) {
console.error('Token refresh failed:', error); console.error('Token refresh failed:', error);
await logout(); await logout();

View File

@@ -1,40 +1,32 @@
import { API_BASE_URL } from '../utils/constants'; import { API_BASE_URL } from '../utils/constants';
let authToken = null;
export const setAuthToken = (token) => {
authToken = token;
};
export const clearAuthToken = () => {
authToken = null;
};
export const getAuthHeaders = () => {
const headers = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
};
// Update the existing apiCall function to include auth headers
export const apiCall = async (url, options = {}) => { export const apiCall = async (url, options = {}) => {
try { try {
const headers = { const headers = {
...getAuthHeaders(), 'Content-Type': 'application/json',
...options.headers, ...options.headers,
}; };
if (options.method && options.method !== 'GET') {
const csrfToken = document.cookie
.split('; ')
.find((row) => row.startsWith('csrf_token='))
?.split('=')[1];
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
}
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
headers, headers,
credentials: 'include',
}); });
if (response.status === 429) {
throw new Error('Rate limit exceeded');
}
// Handle 401 responses // Handle 401 responses
if (response.status === 401) { if (response.status === 401) {
const isRefreshEndpoint = url.endsWith('/auth/refresh'); const isRefreshEndpoint = url.endsWith('/auth/refresh');
@@ -42,13 +34,14 @@ export const apiCall = async (url, options = {}) => {
// Attempt token refresh and retry the request // Attempt token refresh and retry the request
const refreshSuccess = await refreshToken(); const refreshSuccess = await refreshToken();
if (refreshSuccess) { if (refreshSuccess) {
// Retry the original request with the new token // Retry the original request
return apiCall(url, options); return apiCall(url, options);
} }
} }
throw new Error('Authentication failed'); throw new Error('Authentication failed');
} }
// Handle other error responses
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204) {
const errorData = await response.json().catch(() => null); const errorData = await response.json().catch(() => null);
throw new Error( throw new Error(
@@ -56,6 +49,7 @@ export const apiCall = async (url, options = {}) => {
); );
} }
// Return null for 204 responses
if (response.status === 204) { if (response.status === 204) {
return null; return null;
} }
@@ -73,26 +67,29 @@ export const login = async (email, password) => {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
return response.json();
const data = await response.json();
// No need to store tokens as they're in cookies now
return data;
}; };
export const logout = async () => { export const logout = async () => {
const sessionId = localStorage.getItem('sessionId');
await apiCall(`${API_BASE_URL}/auth/logout`, { await apiCall(`${API_BASE_URL}/auth/logout`, {
method: 'POST', method: 'POST',
headers: {
'X-Session-ID': sessionId,
},
}); });
return;
}; };
export const refreshToken = async () => { export const refreshToken = async () => {
const refreshToken = localStorage.getItem('refreshToken'); try {
const response = await apiCall(`${API_BASE_URL}/auth/refresh`, { const response = await apiCall(`${API_BASE_URL}/auth/refresh`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ refreshToken }),
}); });
return response.json(); return response.status === 200;
} catch (error) {
console.error('Token refresh failed:', error);
return false;
}
}; };
export const getCurrentUser = async () => { export const getCurrentUser = async () => {

View File

@@ -13,6 +13,9 @@ import (
// @license.name Apache 2.0 // @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html // @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @BasePath /api/v1 // @BasePath /api/v1
// @SecurityDefinitions.ApiKey CookieAuth
// @In cookie
// @Name access_token
func main() { func main() {
// Load configuration // Load configuration
cfg, err := app.LoadConfig() cfg, err := app.LoadConfig()

View File

@@ -23,7 +23,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Get system-wide statistics as an admin", "description": "Get system-wide statistics as an admin",
@@ -55,7 +55,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the list of all users", "description": "Returns the list of all users",
@@ -88,7 +88,7 @@ const docTemplate = `{
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Create a new user as an admin", "description": "Create a new user as an admin",
@@ -146,7 +146,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Get a specific user as an admin", "description": "Get a specific user as an admin",
@@ -191,7 +191,7 @@ const docTemplate = `{
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Update a specific user as an admin", "description": "Update a specific user as an admin",
@@ -254,7 +254,7 @@ const docTemplate = `{
"delete": { "delete": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Delete a specific user as an admin", "description": "Delete a specific user as an admin",
@@ -307,7 +307,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "List all workspaces and their stats as an admin", "description": "List all workspaces and their stats as an admin",
@@ -340,7 +340,7 @@ const docTemplate = `{
}, },
"/auth/login": { "/auth/login": {
"post": { "post": {
"description": "Logs in a user", "description": "Logs in a user and returns a session with access and refresh tokens",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -351,7 +351,6 @@ const docTemplate = `{
"auth" "auth"
], ],
"summary": "Login", "summary": "Login",
"operationId": "login",
"parameters": [ "parameters": [
{ {
"description": "Login request", "description": "Login request",
@@ -368,6 +367,12 @@ const docTemplate = `{
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/handlers.LoginResponse" "$ref": "#/definitions/handlers.LoginResponse"
},
"headers": {
"X-CSRF-Token": {
"type": "string",
"description": "CSRF token for future requests"
}
} }
}, },
"400": { "400": {
@@ -383,7 +388,7 @@ const docTemplate = `{
} }
}, },
"500": { "500": {
"description": "Failed to create session", "description": "Failed to generate CSRF token",
"schema": { "schema": {
"$ref": "#/definitions/handlers.ErrorResponse" "$ref": "#/definitions/handlers.ErrorResponse"
} }
@@ -393,11 +398,6 @@ const docTemplate = `{
}, },
"/auth/logout": { "/auth/logout": {
"post": { "post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Log out invalidates the user's session", "description": "Log out invalidates the user's session",
"tags": [ "tags": [
"auth" "auth"
@@ -427,7 +427,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the current authenticated user", "description": "Returns the current authenticated user",
@@ -469,22 +469,14 @@ const docTemplate = `{
], ],
"summary": "Refresh token", "summary": "Refresh token",
"operationId": "refreshToken", "operationId": "refreshToken",
"parameters": [
{
"description": "Refresh request",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.RefreshRequest"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "headers": {
"$ref": "#/definitions/handlers.RefreshResponse" "X-CSRF-Token": {
"type": "string",
"description": "New CSRF token"
}
} }
}, },
"400": { "400": {
@@ -498,6 +490,12 @@ const docTemplate = `{
"schema": { "schema": {
"$ref": "#/definitions/handlers.ErrorResponse" "$ref": "#/definitions/handlers.ErrorResponse"
} }
},
"500": {
"description": "Failed to generate CSRF token",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
} }
} }
} }
@@ -506,7 +504,7 @@ const docTemplate = `{
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Updates the user's profile", "description": "Updates the user's profile",
@@ -574,7 +572,7 @@ const docTemplate = `{
"delete": { "delete": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Deletes the user's account and all associated data", "description": "Deletes the user's account and all associated data",
@@ -641,7 +639,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Lists all workspaces for the current user", "description": "Lists all workspaces for the current user",
@@ -674,7 +672,7 @@ const docTemplate = `{
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Creates a new workspace", "description": "Creates a new workspace",
@@ -726,7 +724,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the name of the last opened workspace", "description": "Returns the name of the last opened workspace",
@@ -756,7 +754,7 @@ const docTemplate = `{
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Updates the name of the last opened workspace", "description": "Updates the name of the last opened workspace",
@@ -794,7 +792,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the current workspace", "description": "Returns the current workspace",
@@ -833,7 +831,7 @@ const docTemplate = `{
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Updates the current workspace", "description": "Updates the current workspace",
@@ -890,7 +888,7 @@ const docTemplate = `{
"delete": { "delete": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Deletes the current workspace", "description": "Deletes the current workspace",
@@ -937,7 +935,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Lists all files in the user's workspace", "description": "Lists all files in the user's workspace",
@@ -981,7 +979,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the path of the last opened file in the user's workspace", "description": "Returns the path of the last opened file in the user's workspace",
@@ -1026,7 +1024,7 @@ const docTemplate = `{
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Updates the last opened file in the user's workspace", "description": "Updates the last opened file in the user's workspace",
@@ -1088,7 +1086,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the paths of files with the given name in the user's workspace", "description": "Returns the paths of files with the given name in the user's workspace",
@@ -1142,7 +1140,7 @@ const docTemplate = `{
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the content of a file in the user's workspace", "description": "Returns the content of a file in the user's workspace",
@@ -1200,7 +1198,7 @@ const docTemplate = `{
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Saves the content of a file in the user's workspace", "description": "Saves the content of a file in the user's workspace",
@@ -1255,7 +1253,7 @@ const docTemplate = `{
"delete": { "delete": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Deletes a file in the user's workspace", "description": "Deletes a file in the user's workspace",
@@ -1309,7 +1307,7 @@ const docTemplate = `{
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Stages, commits, and pushes changes to the remote repository", "description": "Stages, commits, and pushes changes to the remote repository",
@@ -1365,7 +1363,7 @@ const docTemplate = `{
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Pulls changes from the remote repository", "description": "Pulls changes from the remote repository",
@@ -1493,15 +1491,12 @@ const docTemplate = `{
"handlers.LoginResponse": { "handlers.LoginResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"accessToken": { "expiresAt": {
"type": "string" "type": "string"
}, },
"refreshToken": { "sessionId": {
"type": "string" "type": "string"
}, },
"session": {
"$ref": "#/definitions/models.Session"
},
"user": { "user": {
"$ref": "#/definitions/models.User" "$ref": "#/definitions/models.User"
} }
@@ -1527,22 +1522,6 @@ const docTemplate = `{
} }
} }
}, },
"handlers.RefreshRequest": {
"type": "object",
"properties": {
"refreshToken": {
"type": "string"
}
}
},
"handlers.RefreshResponse": {
"type": "object",
"properties": {
"accessToken": {
"type": "string"
}
}
},
"handlers.SaveFileResponse": { "handlers.SaveFileResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1646,31 +1625,6 @@ const docTemplate = `{
} }
} }
}, },
"models.Session": {
"type": "object",
"properties": {
"createdAt": {
"description": "When this session was created",
"type": "string"
},
"expiresAt": {
"description": "When this session expires",
"type": "string"
},
"id": {
"description": "Unique session identifier",
"type": "string"
},
"refreshToken": {
"description": "The refresh token associated with this session",
"type": "string"
},
"userID": {
"description": "ID of the user this session belongs to",
"type": "integer"
}
}
},
"models.User": { "models.User": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -1807,6 +1761,13 @@ const docTemplate = `{
} }
} }
} }
},
"securityDefinitions": {
"CookieAuth": {
"type": "apiKey",
"name": "access_token",
"in": "cookie"
}
} }
}` }`

View File

@@ -16,7 +16,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Get system-wide statistics as an admin", "description": "Get system-wide statistics as an admin",
@@ -48,7 +48,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the list of all users", "description": "Returns the list of all users",
@@ -81,7 +81,7 @@
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Create a new user as an admin", "description": "Create a new user as an admin",
@@ -139,7 +139,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Get a specific user as an admin", "description": "Get a specific user as an admin",
@@ -184,7 +184,7 @@
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Update a specific user as an admin", "description": "Update a specific user as an admin",
@@ -247,7 +247,7 @@
"delete": { "delete": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Delete a specific user as an admin", "description": "Delete a specific user as an admin",
@@ -300,7 +300,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "List all workspaces and their stats as an admin", "description": "List all workspaces and their stats as an admin",
@@ -333,7 +333,7 @@
}, },
"/auth/login": { "/auth/login": {
"post": { "post": {
"description": "Logs in a user", "description": "Logs in a user and returns a session with access and refresh tokens",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -344,7 +344,6 @@
"auth" "auth"
], ],
"summary": "Login", "summary": "Login",
"operationId": "login",
"parameters": [ "parameters": [
{ {
"description": "Login request", "description": "Login request",
@@ -361,6 +360,12 @@
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/handlers.LoginResponse" "$ref": "#/definitions/handlers.LoginResponse"
},
"headers": {
"X-CSRF-Token": {
"type": "string",
"description": "CSRF token for future requests"
}
} }
}, },
"400": { "400": {
@@ -376,7 +381,7 @@
} }
}, },
"500": { "500": {
"description": "Failed to create session", "description": "Failed to generate CSRF token",
"schema": { "schema": {
"$ref": "#/definitions/handlers.ErrorResponse" "$ref": "#/definitions/handlers.ErrorResponse"
} }
@@ -386,11 +391,6 @@
}, },
"/auth/logout": { "/auth/logout": {
"post": { "post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Log out invalidates the user's session", "description": "Log out invalidates the user's session",
"tags": [ "tags": [
"auth" "auth"
@@ -420,7 +420,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the current authenticated user", "description": "Returns the current authenticated user",
@@ -462,22 +462,14 @@
], ],
"summary": "Refresh token", "summary": "Refresh token",
"operationId": "refreshToken", "operationId": "refreshToken",
"parameters": [
{
"description": "Refresh request",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.RefreshRequest"
}
}
],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "headers": {
"$ref": "#/definitions/handlers.RefreshResponse" "X-CSRF-Token": {
"type": "string",
"description": "New CSRF token"
}
} }
}, },
"400": { "400": {
@@ -491,6 +483,12 @@
"schema": { "schema": {
"$ref": "#/definitions/handlers.ErrorResponse" "$ref": "#/definitions/handlers.ErrorResponse"
} }
},
"500": {
"description": "Failed to generate CSRF token",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
} }
} }
} }
@@ -499,7 +497,7 @@
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Updates the user's profile", "description": "Updates the user's profile",
@@ -567,7 +565,7 @@
"delete": { "delete": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Deletes the user's account and all associated data", "description": "Deletes the user's account and all associated data",
@@ -634,7 +632,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Lists all workspaces for the current user", "description": "Lists all workspaces for the current user",
@@ -667,7 +665,7 @@
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Creates a new workspace", "description": "Creates a new workspace",
@@ -719,7 +717,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the name of the last opened workspace", "description": "Returns the name of the last opened workspace",
@@ -749,7 +747,7 @@
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Updates the name of the last opened workspace", "description": "Updates the name of the last opened workspace",
@@ -787,7 +785,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the current workspace", "description": "Returns the current workspace",
@@ -826,7 +824,7 @@
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Updates the current workspace", "description": "Updates the current workspace",
@@ -883,7 +881,7 @@
"delete": { "delete": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Deletes the current workspace", "description": "Deletes the current workspace",
@@ -930,7 +928,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Lists all files in the user's workspace", "description": "Lists all files in the user's workspace",
@@ -974,7 +972,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the path of the last opened file in the user's workspace", "description": "Returns the path of the last opened file in the user's workspace",
@@ -1019,7 +1017,7 @@
"put": { "put": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Updates the last opened file in the user's workspace", "description": "Updates the last opened file in the user's workspace",
@@ -1081,7 +1079,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the paths of files with the given name in the user's workspace", "description": "Returns the paths of files with the given name in the user's workspace",
@@ -1135,7 +1133,7 @@
"get": { "get": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Returns the content of a file in the user's workspace", "description": "Returns the content of a file in the user's workspace",
@@ -1193,7 +1191,7 @@
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Saves the content of a file in the user's workspace", "description": "Saves the content of a file in the user's workspace",
@@ -1248,7 +1246,7 @@
"delete": { "delete": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Deletes a file in the user's workspace", "description": "Deletes a file in the user's workspace",
@@ -1302,7 +1300,7 @@
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Stages, commits, and pushes changes to the remote repository", "description": "Stages, commits, and pushes changes to the remote repository",
@@ -1358,7 +1356,7 @@
"post": { "post": {
"security": [ "security": [
{ {
"BearerAuth": [] "CookieAuth": []
} }
], ],
"description": "Pulls changes from the remote repository", "description": "Pulls changes from the remote repository",
@@ -1486,15 +1484,12 @@
"handlers.LoginResponse": { "handlers.LoginResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"accessToken": { "expiresAt": {
"type": "string" "type": "string"
}, },
"refreshToken": { "sessionId": {
"type": "string" "type": "string"
}, },
"session": {
"$ref": "#/definitions/models.Session"
},
"user": { "user": {
"$ref": "#/definitions/models.User" "$ref": "#/definitions/models.User"
} }
@@ -1520,22 +1515,6 @@
} }
} }
}, },
"handlers.RefreshRequest": {
"type": "object",
"properties": {
"refreshToken": {
"type": "string"
}
}
},
"handlers.RefreshResponse": {
"type": "object",
"properties": {
"accessToken": {
"type": "string"
}
}
},
"handlers.SaveFileResponse": { "handlers.SaveFileResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1639,31 +1618,6 @@
} }
} }
}, },
"models.Session": {
"type": "object",
"properties": {
"createdAt": {
"description": "When this session was created",
"type": "string"
},
"expiresAt": {
"description": "When this session expires",
"type": "string"
},
"id": {
"description": "Unique session identifier",
"type": "string"
},
"refreshToken": {
"description": "The refresh token associated with this session",
"type": "string"
},
"userID": {
"description": "ID of the user this session belongs to",
"type": "integer"
}
}
},
"models.User": { "models.User": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -1800,5 +1754,12 @@
} }
} }
} }
},
"securityDefinitions": {
"CookieAuth": {
"type": "apiKey",
"name": "access_token",
"in": "cookie"
}
} }
} }

View File

@@ -57,12 +57,10 @@ definitions:
type: object type: object
handlers.LoginResponse: handlers.LoginResponse:
properties: properties:
accessToken: expiresAt:
type: string type: string
refreshToken: sessionId:
type: string type: string
session:
$ref: '#/definitions/models.Session'
user: user:
$ref: '#/definitions/models.User' $ref: '#/definitions/models.User'
type: object type: object
@@ -79,16 +77,6 @@ definitions:
example: Pulled changes from remote example: Pulled changes from remote
type: string type: string
type: object type: object
handlers.RefreshRequest:
properties:
refreshToken:
type: string
type: object
handlers.RefreshResponse:
properties:
accessToken:
type: string
type: object
handlers.SaveFileResponse: handlers.SaveFileResponse:
properties: properties:
filePath: filePath:
@@ -156,24 +144,6 @@ definitions:
workspaceName: workspaceName:
type: string type: string
type: object type: object
models.Session:
properties:
createdAt:
description: When this session was created
type: string
expiresAt:
description: When this session expires
type: string
id:
description: Unique session identifier
type: string
refreshToken:
description: The refresh token associated with this session
type: string
userID:
description: ID of the user this session belongs to
type: integer
type: object
models.User: models.User:
properties: properties:
createdAt: createdAt:
@@ -292,7 +262,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Get system statistics summary: Get system statistics
tags: tags:
- Admin - Admin
@@ -314,7 +284,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: List all users summary: List all users
tags: tags:
- Admin - Admin
@@ -350,7 +320,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Create a new user summary: Create a new user
tags: tags:
- Admin - Admin
@@ -384,7 +354,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Delete a specific user summary: Delete a specific user
tags: tags:
- Admin - Admin
@@ -413,7 +383,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Get a specific user summary: Get a specific user
tags: tags:
- Admin - Admin
@@ -454,7 +424,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Update a specific user summary: Update a specific user
tags: tags:
- Admin - Admin
@@ -476,7 +446,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: List all workspaces summary: List all workspaces
tags: tags:
- Admin - Admin
@@ -484,8 +454,7 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: Logs in a user description: Logs in a user and returns a session with access and refresh tokens
operationId: login
parameters: parameters:
- description: Login request - description: Login request
in: body in: body
@@ -498,6 +467,10 @@ paths:
responses: responses:
"200": "200":
description: OK description: OK
headers:
X-CSRF-Token:
description: CSRF token for future requests
type: string
schema: schema:
$ref: '#/definitions/handlers.LoginResponse' $ref: '#/definitions/handlers.LoginResponse'
"400": "400":
@@ -509,7 +482,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
"500": "500":
description: Failed to create session description: Failed to generate CSRF token
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
summary: Login summary: Login
@@ -530,8 +503,6 @@ paths:
description: Failed to logout description: Failed to logout
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
summary: Logout summary: Logout
tags: tags:
- auth - auth
@@ -551,7 +522,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Get current user summary: Get current user
tags: tags:
- auth - auth
@@ -561,20 +532,15 @@ paths:
- application/json - application/json
description: Refreshes the access token using the refresh token description: Refreshes the access token using the refresh token
operationId: refreshToken operationId: refreshToken
parameters:
- description: Refresh request
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.RefreshRequest'
produces: produces:
- application/json - application/json
responses: responses:
"200": "200":
description: OK description: OK
schema: headers:
$ref: '#/definitions/handlers.RefreshResponse' X-CSRF-Token:
description: New CSRF token
type: string
"400": "400":
description: Refresh token required description: Refresh token required
schema: schema:
@@ -583,6 +549,10 @@ paths:
description: Invalid refresh token description: Invalid refresh token
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Failed to generate CSRF token
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Refresh token summary: Refresh token
tags: tags:
- auth - auth
@@ -625,7 +595,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Delete account summary: Delete account
tags: tags:
- users - users
@@ -669,7 +639,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Update profile summary: Update profile
tags: tags:
- users - users
@@ -691,7 +661,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: List workspaces summary: List workspaces
tags: tags:
- workspaces - workspaces
@@ -723,7 +693,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Create workspace summary: Create workspace
tags: tags:
- workspaces - workspaces
@@ -753,7 +723,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Delete workspace summary: Delete workspace
tags: tags:
- workspaces - workspaces
@@ -778,7 +748,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Get workspace summary: Get workspace
tags: tags:
- workspaces - workspaces
@@ -815,7 +785,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Update workspace summary: Update workspace
tags: tags:
- workspaces - workspaces
@@ -843,7 +813,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: List files summary: List files
tags: tags:
- files - files
@@ -878,7 +848,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Delete file summary: Delete file
tags: tags:
- files - files
@@ -916,7 +886,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Get file content summary: Get file content
tags: tags:
- files - files
@@ -952,7 +922,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Save file summary: Save file
tags: tags:
- files - files
@@ -982,7 +952,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Get last opened file summary: Get last opened file
tags: tags:
- files - files
@@ -1021,7 +991,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Update last opened file summary: Update last opened file
tags: tags:
- files - files
@@ -1056,7 +1026,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Lookup file by name summary: Lookup file by name
tags: tags:
- files - files
@@ -1092,7 +1062,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Stage, commit, and push changes summary: Stage, commit, and push changes
tags: tags:
- git - git
@@ -1118,7 +1088,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Pull changes from remote summary: Pull changes from remote
tags: tags:
- git - git
@@ -1138,7 +1108,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Get last workspace name summary: Get last workspace name
tags: tags:
- workspaces - workspaces
@@ -1161,8 +1131,13 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.ErrorResponse' $ref: '#/definitions/handlers.ErrorResponse'
security: security:
- BearerAuth: [] - CookieAuth: []
summary: Update last workspace name summary: Update last workspace name
tags: tags:
- workspaces - workspaces
securityDefinitions:
CookieAuth:
in: cookie
name: access_token
type: apiKey
swagger: "2.0" swagger: "2.0"

View File

@@ -5,9 +5,9 @@ Generated documentation for all packages in the NovaMD project.
## Table of Contents ## Table of Contents
- [cmd/server](#cmd-server) - [cmd/server](#cmd-server)
- [docs](#docs)
- [internal/app](#internal-app) - [internal/app](#internal-app)
- [internal/auth](#internal-auth) - [internal/auth](#internal-auth)
- [internal/config](#internal-config)
- [internal/context](#internal-context) - [internal/context](#internal-context)
- [internal/db](#internal-db) - [internal/db](#internal-db)
- [internal/git](#internal-git) - [internal/git](#internal-git)
@@ -23,6 +23,31 @@ Package main provides the entry point for the application. It loads the
configuration, initializes the server, and starts the server. configuration, initializes the server, and starts the server.
``` ```
## docs
```go
package docs // import "novamd/docs"
Package docs Code generated by swaggo/swag. DO NOT EDIT
VARIABLES
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Host: "",
BasePath: "/api/v1",
Schemes: []string{},
Title: "NovaMD API",
Description: "This is the API for NovaMD markdown note taking app.",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
SwaggerInfo holds exported Swagger Info so clients can modify it
```
## internal/app ## internal/app
```go ```go
@@ -31,124 +56,6 @@ package app // import "novamd/internal/app"
Package app provides application-level functionality for initializing and Package app provides application-level functionality for initializing and
running the server running the server
FUNCTIONS
func SetupRoutes(r chi.Router, db db.Database, s storage.Manager, authMiddleware *auth.Middleware, sessionService *auth.SessionService)
SetupRoutes configures the API routes
TYPES
type Server struct {
// Has unexported fields.
}
Server represents the HTTP server and its dependencies
func NewServer(cfg *config.Config) (*Server, error)
NewServer initializes a new server instance with all dependencies
func (s *Server) Close() error
Close handles graceful shutdown of server dependencies
func (s *Server) Start() error
Start configures and starts the HTTP server
```
## internal/auth
```go
package auth // import "novamd/internal/auth"
Package auth provides JWT token generation and validation
TYPES
type Claims struct {
jwt.RegisteredClaims // Embedded standard JWT claims
UserID int `json:"uid"` // User identifier
Role string `json:"role"` // User role (admin, editor, viewer)
Type TokenType `json:"type"` // Token type (access or refresh)
}
Claims represents the custom claims we store in JWT tokens
type JWTConfig struct {
SigningKey string // Secret key used to sign tokens
AccessTokenExpiry time.Duration // How long access tokens are valid
RefreshTokenExpiry time.Duration // How long refresh tokens are valid
}
JWTConfig holds the configuration for the JWT service
type JWTManager interface {
GenerateAccessToken(userID int, role string) (string, error)
GenerateRefreshToken(userID int, role string) (string, error)
ValidateToken(tokenString string) (*Claims, error)
RefreshAccessToken(refreshToken string) (string, error)
}
JWTManager defines the interface for managing JWT tokens
func NewJWTService(config JWTConfig) (JWTManager, error)
NewJWTService creates a new JWT service with the provided configuration
Returns an error if the signing key is missing
type Middleware struct {
// Has unexported fields.
}
Middleware handles JWT authentication for protected routes
func NewMiddleware(jwtManager JWTManager) *Middleware
NewMiddleware creates a new authentication middleware
func (m *Middleware) Authenticate(next http.Handler) http.Handler
Authenticate middleware validates JWT tokens and sets user information in
context
func (m *Middleware) RequireRole(role string) func(http.Handler) http.Handler
RequireRole returns a middleware that ensures the user has the required role
func (m *Middleware) RequireWorkspaceAccess(next http.Handler) http.Handler
RequireWorkspaceAccess returns a middleware that ensures the user has access
to the workspace
type SessionService struct {
// Has unexported fields.
}
SessionService manages user sessions in the database
func NewSessionService(db db.SessionStore, jwtManager JWTManager) *SessionService
NewSessionService creates a new session service with the given database and
JWT manager
func (s *SessionService) CleanExpiredSessions() error
CleanExpiredSessions removes all expired sessions from the database
func (s *SessionService) CreateSession(userID int, role string) (*models.Session, string, error)
CreateSession creates a new user session for a user with the given userID
and role
func (s *SessionService) InvalidateSession(sessionID string) error
InvalidateSession removes a session with the given sessionID from the
database
func (s *SessionService) RefreshSession(refreshToken string) (string, error)
RefreshSession creates a new access token using a refreshToken
type TokenType string
TokenType represents the type of JWT token (access or refresh)
const (
AccessToken TokenType = "access" // AccessToken - Short-lived token for API access
RefreshToken TokenType = "refresh" // RefreshToken - Long-lived token for obtaining new access tokens
)
```
## internal/config
```go
package config // import "novamd/internal/config"
Package config provides the configuration for the application
TYPES TYPES
type Config struct { type Config struct {
@@ -156,7 +63,8 @@ type Config struct {
WorkDir string WorkDir string
StaticPath string StaticPath string
Port string Port string
AppURL string RootURL string
Domain string
CORSOrigins []string CORSOrigins []string
AdminEmail string AdminEmail string
AdminPassword string AdminPassword string
@@ -171,12 +79,132 @@ type Config struct {
func DefaultConfig() *Config func DefaultConfig() *Config
DefaultConfig returns a new Config instance with default values DefaultConfig returns a new Config instance with default values
func Load() (*Config, error) func LoadConfig() (*Config, error)
Load creates a new Config instance with values from environment variables LoadConfig creates a new Config instance with values from environment
variables
func (c *Config) Validate() error type Options struct {
Validate checks if the configuration is valid Config *Config
Database db.Database
Storage storage.Manager
JWTManager auth.JWTManager
SessionManager auth.SessionManager
CookieService auth.CookieManager
}
Options holds all dependencies and configuration for the server
func DefaultOptions(cfg *Config) (*Options, error)
DefaultOptions creates server options with default configuration
type Server struct {
// Has unexported fields.
}
Server represents the HTTP server and its dependencies
func NewServer(options *Options) *Server
NewServer creates a new server instance with the given options
func (s *Server) Close() error
Close handles graceful shutdown of server dependencies
func (s *Server) Router() chi.Router
Router returns the chi router for testing
func (s *Server) Start() error
Start configures and starts the HTTP server
```
## internal/auth
```go
package auth // import "novamd/internal/auth"
Package auth provides JWT token generation and validation
Package auth provides JWT token generation and validation
FUNCTIONS
func NewSessionService(db db.SessionStore, jwtManager JWTManager) *sessionManager
NewSessionService creates a new session service with the given database and
JWT manager revive:disable:unexported-return
TYPES
type Claims struct {
jwt.RegisteredClaims // Embedded standard JWT claims
UserID int `json:"uid"` // User identifier
Role string `json:"role"` // User role (admin, editor, viewer)
Type TokenType `json:"type"` // Token type (access or refresh)
}
Claims represents the custom claims we store in JWT tokens
type CookieManager interface {
GenerateAccessTokenCookie(token string) *http.Cookie
GenerateRefreshTokenCookie(token string) *http.Cookie
GenerateCSRFCookie(token string) *http.Cookie
InvalidateCookie(cookieType string) *http.Cookie
}
CookieManager interface defines methods for generating cookies
func NewCookieService(isDevelopment bool, domain string) CookieManager
NewCookieService creates a new cookie service
type JWTConfig struct {
SigningKey string // Secret key used to sign tokens
AccessTokenExpiry time.Duration // How long access tokens are valid
RefreshTokenExpiry time.Duration // How long refresh tokens are valid
}
JWTConfig holds the configuration for the JWT service
type JWTManager interface {
GenerateAccessToken(userID int, role string, sessionID string) (string, error)
GenerateRefreshToken(userID int, role string, sessionID string) (string, error)
ValidateToken(tokenString string) (*Claims, error)
}
JWTManager defines the interface for managing JWT tokens
func NewJWTService(config JWTConfig) (JWTManager, error)
NewJWTService creates a new JWT service with the provided configuration
Returns an error if the signing key is missing
type Middleware struct {
// Has unexported fields.
}
Middleware handles JWT authentication for protected routes
func NewMiddleware(jwtManager JWTManager, sessionManager SessionManager, cookieManager CookieManager) *Middleware
NewMiddleware creates a new authentication middleware
func (m *Middleware) Authenticate(next http.Handler) http.Handler
Authenticate middleware validates JWT tokens and sets user information in
context
func (m *Middleware) RequireRole(role string) func(http.Handler) http.Handler
RequireRole returns a middleware that ensures the user has the required role
func (m *Middleware) RequireWorkspaceAccess(next http.Handler) http.Handler
RequireWorkspaceAccess returns a middleware that ensures the user has access
to the workspace
type SessionManager interface {
CreateSession(userID int, role string) (*models.Session, string, error)
RefreshSession(refreshToken string) (string, error)
ValidateSession(sessionID string) (*models.Session, error)
InvalidateSession(token string) error
CleanExpiredSessions() error
}
SessionManager is an interface for managing user sessions
type TokenType string
TokenType represents the type of JWT token (access or refresh)
const (
AccessToken TokenType = "access" // AccessToken - Short-lived token for API access
RefreshToken TokenType = "refresh" // RefreshToken - Long-lived token for obtaining new access tokens
)
``` ```
## internal/context ## internal/context
@@ -271,6 +299,7 @@ type Migration struct {
type SessionStore interface { type SessionStore interface {
CreateSession(session *models.Session) error CreateSession(session *models.Session) error
GetSessionByRefreshToken(refreshToken string) (*models.Session, error) GetSessionByRefreshToken(refreshToken string) (*models.Session, error)
GetSessionByID(sessionID string) (*models.Session, error)
DeleteSession(sessionID string) error DeleteSession(sessionID string) error
CleanExpiredSessions() error CleanExpiredSessions() error
} }
@@ -350,7 +379,7 @@ TYPES
type Client interface { type Client interface {
Clone() error Clone() error
Pull() error Pull() error
Commit(message string) error Commit(message string) (CommitHash, error)
Push() error Push() error
EnsureRepo() error EnsureRepo() error
} }
@@ -359,6 +388,12 @@ type Client interface {
func New(url, username, token, workDir, commitName, commitEmail string) Client func New(url, username, token, workDir, commitName, commitEmail string) Client
New creates a new git Client instance New creates a new git Client instance
type CommitHash plumbing.Hash
CommitHash represents a Git commit hash
func (h CommitHash) String() string
String returns the string representation of the CommitHash
type Config struct { type Config struct {
URL string URL string
Username string Username string
@@ -380,6 +415,16 @@ Package handlers contains the request handlers for the api routes.
TYPES TYPES
type CommitRequest struct {
Message string `json:"message" example:"Initial commit"`
}
CommitRequest represents a request to commit changes
type CommitResponse struct {
CommitHash string `json:"commitHash" example:"a1b2c3d4"`
}
CommitResponse represents a response to a commit request
type CreateUserRequest struct { type CreateUserRequest struct {
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
@@ -393,6 +438,17 @@ type DeleteAccountRequest struct {
} }
DeleteAccountRequest represents a user account deletion request DeleteAccountRequest represents a user account deletion request
type DeleteWorkspaceResponse struct {
NextWorkspaceName string `json:"nextWorkspaceName"`
}
DeleteWorkspaceResponse contains the name of the next workspace after
deleting the current one
type ErrorResponse struct {
Message string `json:"message"`
}
ErrorResponse is a generic error response
type Handler struct { type Handler struct {
DB db.Database DB db.Database
Storage storage.Manager Storage storage.Manager
@@ -403,92 +459,286 @@ func NewHandler(db db.Database, s storage.Manager) *Handler
NewHandler creates a new handler with the given dependencies NewHandler creates a new handler with the given dependencies
func (h *Handler) AdminCreateUser() http.HandlerFunc func (h *Handler) AdminCreateUser() http.HandlerFunc
AdminCreateUser creates a new user AdminCreateUser godoc @Summary Create a new user @Description Create a
new user as an admin @Tags Admin @Security CookieAuth @ID adminCreateUser
@Accept json @Produce json @Param user body CreateUserRequest true
"User details" @Success 200 {object} models.User @Failure 400 {object}
ErrorResponse "Invalid request body" @Failure 400 {object} ErrorResponse
"Email, password, and role are required" @Failure 400 {object} ErrorResponse
"Password must be at least 8 characters" @Failure 409 {object} ErrorResponse
"Email already exists" @Failure 500 {object} ErrorResponse "Failed to
hash password" @Failure 500 {object} ErrorResponse "Failed to create user"
@Failure 500 {object} ErrorResponse "Failed to initialize user workspace"
@Router /admin/users [post]
func (h *Handler) AdminDeleteUser() http.HandlerFunc func (h *Handler) AdminDeleteUser() http.HandlerFunc
AdminDeleteUser deletes a specific user AdminDeleteUser godoc @Summary Delete a specific user @Description
Delete a specific user as an admin @Tags Admin @Security CookieAuth @ID
adminDeleteUser @Param userId path int true "User ID" @Success 204 "No
Content" @Failure 400 {object} ErrorResponse "Invalid user ID" @Failure
400 {object} ErrorResponse "Cannot delete your own account" @Failure 403
{object} ErrorResponse "Cannot delete other admin users" @Failure 404
{object} ErrorResponse "User not found" @Failure 500 {object} ErrorResponse
"Failed to delete user" @Router /admin/users/{userId} [delete]
func (h *Handler) AdminGetSystemStats() http.HandlerFunc func (h *Handler) AdminGetSystemStats() http.HandlerFunc
AdminGetSystemStats returns system-wide statistics for admins AdminGetSystemStats godoc @Summary Get system statistics @Description Get
system-wide statistics as an admin @Tags Admin @Security CookieAuth @ID
adminGetSystemStats @Produce json @Success 200 {object} SystemStats @Failure
500 {object} ErrorResponse "Failed to get user stats" @Failure 500 {object}
ErrorResponse "Failed to get file stats" @Router /admin/stats [get]
func (h *Handler) AdminGetUser() http.HandlerFunc func (h *Handler) AdminGetUser() http.HandlerFunc
AdminGetUser gets a specific user by ID AdminGetUser godoc @Summary Get a specific user @Description Get a specific
user as an admin @Tags Admin @Security CookieAuth @ID adminGetUser @Produce
json @Param userId path int true "User ID" @Success 200 {object} models.User
@Failure 400 {object} ErrorResponse "Invalid user ID" @Failure 404 {object}
ErrorResponse "User not found" @Router /admin/users/{userId} [get]
func (h *Handler) AdminListUsers() http.HandlerFunc func (h *Handler) AdminListUsers() http.HandlerFunc
AdminListUsers returns a list of all users AdminListUsers godoc @Summary List all users @Description Returns the list
of all users @Tags Admin @Security CookieAuth @ID adminListUsers @Produce
json @Success 200 {array} models.User @Failure 500 {object} ErrorResponse
"Failed to list users" @Router /admin/users [get]
func (h *Handler) AdminListWorkspaces() http.HandlerFunc func (h *Handler) AdminListWorkspaces() http.HandlerFunc
AdminListWorkspaces returns a list of all workspaces and their stats AdminListWorkspaces godoc @Summary List all workspaces @Description List
all workspaces and their stats as an admin @Tags Admin @Security CookieAuth
@ID adminListWorkspaces @Produce json @Success 200 {array} WorkspaceStats
@Failure 500 {object} ErrorResponse "Failed to list workspaces" @Failure
500 {object} ErrorResponse "Failed to get user" @Failure 500 {object}
ErrorResponse "Failed to get file stats" @Router /admin/workspaces [get]
func (h *Handler) AdminUpdateUser() http.HandlerFunc func (h *Handler) AdminUpdateUser() http.HandlerFunc
AdminUpdateUser updates a specific user AdminUpdateUser godoc @Summary Update a specific user @Description
Update a specific user as an admin @Tags Admin @Security CookieAuth @ID
adminUpdateUser @Accept json @Produce json @Param userId path int true
"User ID" @Param user body UpdateUserRequest true "User details" @Success
200 {object} models.User @Failure 400 {object} ErrorResponse "Invalid user
ID" @Failure 400 {object} ErrorResponse "Invalid request body" @Failure 404
{object} ErrorResponse "User not found" @Failure 500 {object} ErrorResponse
"Failed to hash password" @Failure 500 {object} ErrorResponse "Failed to
update user" @Router /admin/users/{userId} [put]
func (h *Handler) CreateWorkspace() http.HandlerFunc func (h *Handler) CreateWorkspace() http.HandlerFunc
CreateWorkspace creates a new workspace CreateWorkspace godoc @Summary Create workspace @Description Creates a new
workspace @Tags workspaces @ID createWorkspace @Security CookieAuth @Accept
json @Produce json @Param body body models.Workspace true "Workspace"
@Success 200 {object} models.Workspace @Failure 400 {object} ErrorResponse
"Invalid request body" @Failure 400 {object} ErrorResponse "Invalid
workspace" @Failure 500 {object} ErrorResponse "Failed to create workspace"
@Failure 500 {object} ErrorResponse "Failed to initialize workspace
directory" @Failure 500 {object} ErrorResponse "Failed to setup git repo"
@Router /workspaces [post]
func (h *Handler) DeleteAccount() http.HandlerFunc func (h *Handler) DeleteAccount() http.HandlerFunc
DeleteAccount handles user account deletion DeleteAccount godoc @Summary Delete account @Description Deletes the user's
account and all associated data @Tags users @ID deleteAccount @Security
CookieAuth @Accept json @Produce json @Param body body DeleteAccountRequest
true "Account deletion request" @Success 204 "No Content - Account deleted
successfully" @Failure 400 {object} ErrorResponse "Invalid request body"
@Failure 401 {object} ErrorResponse "Password is incorrect" @Failure 403
{object} ErrorResponse "Cannot delete the last admin account" @Failure 404
{object} ErrorResponse "User not found" @Failure 500 {object} ErrorResponse
"Failed to verify admin status" @Failure 500 {object} ErrorResponse "Failed
to delete account" @Router /profile [delete]
func (h *Handler) DeleteFile() http.HandlerFunc func (h *Handler) DeleteFile() http.HandlerFunc
DeleteFile deletes a file DeleteFile godoc @Summary Delete file @Description Deletes a file in
the user's workspace @Tags files @ID deleteFile @Security CookieAuth
@Param workspace_name path string true "Workspace name" @Param
file_path path string true "File path" @Success 204 "No Content
- File deleted successfully" @Failure 400 {object} ErrorResponse
"Invalid file path" @Failure 404 {object} ErrorResponse "File not
found" @Failure 500 {object} ErrorResponse "Failed to delete file"
@Failure 500 {object} ErrorResponse "Failed to write response" @Router
/workspaces/{workspace_name}/files/{file_path} [delete]
func (h *Handler) DeleteWorkspace() http.HandlerFunc func (h *Handler) DeleteWorkspace() http.HandlerFunc
DeleteWorkspace deletes the current workspace DeleteWorkspace godoc @Summary Delete workspace @Description Deletes
the current workspace @Tags workspaces @ID deleteWorkspace @Security
CookieAuth @Produce json @Param workspace_name path string true "Workspace
name" @Success 200 {object} DeleteWorkspaceResponse @Failure 400 {object}
ErrorResponse "Cannot delete the last workspace" @Failure 500 {object}
ErrorResponse "Failed to get workspaces" @Failure 500 {object} ErrorResponse
"Failed to start transaction" @Failure 500 {object} ErrorResponse "Failed
to update last workspace" @Failure 500 {object} ErrorResponse "Failed to
delete workspace" @Failure 500 {object} ErrorResponse "Failed to rollback
transaction" @Failure 500 {object} ErrorResponse "Failed to commit
transaction" @Router /workspaces/{workspace_name} [delete]
func (h *Handler) GetCurrentUser() http.HandlerFunc func (h *Handler) GetCurrentUser() http.HandlerFunc
GetCurrentUser returns the currently authenticated user GetCurrentUser godoc @Summary Get current user @Description Returns
the current authenticated user @Tags auth @ID getCurrentUser @Security
CookieAuth @Produce json @Success 200 {object} models.User @Failure 404
{object} ErrorResponse "User not found" @Router /auth/me [get]
func (h *Handler) GetFileContent() http.HandlerFunc func (h *Handler) GetFileContent() http.HandlerFunc
GetFileContent returns the content of a file GetFileContent godoc @Summary Get file content @Description Returns the
content of a file in the user's workspace @Tags files @ID getFileContent
@Security CookieAuth @Produce plain @Param workspace_name path string
true "Workspace name" @Param file_path path string true "File path"
@Success 200 {string} string "Raw file content" @Failure 400 {object}
ErrorResponse "Invalid file path" @Failure 404 {object} ErrorResponse
"File not found" @Failure 500 {object} ErrorResponse "Failed to read file"
@Failure 500 {object} ErrorResponse "Failed to write response" @Router
/workspaces/{workspace_name}/files/{file_path} [get]
func (h *Handler) GetLastOpenedFile() http.HandlerFunc func (h *Handler) GetLastOpenedFile() http.HandlerFunc
GetLastOpenedFile returns the last opened file in the workspace GetLastOpenedFile godoc @Summary Get last opened file @Description
Returns the path of the last opened file in the user's workspace @Tags
files @ID getLastOpenedFile @Security CookieAuth @Produce json @Param
workspace_name path string true "Workspace name" @Success 200 {object}
LastOpenedFileResponse @Failure 400 {object} ErrorResponse "Invalid file
path" @Failure 500 {object} ErrorResponse "Failed to get last opened file"
@Router /workspaces/{workspace_name}/files/last [get]
func (h *Handler) GetLastWorkspaceName() http.HandlerFunc func (h *Handler) GetLastWorkspaceName() http.HandlerFunc
GetLastWorkspaceName returns the name of the last opened workspace GetLastWorkspaceName godoc @Summary Get last workspace name @Description
Returns the name of the last opened workspace @Tags workspaces @ID
getLastWorkspaceName @Security CookieAuth @Produce json @Success 200
{object} LastWorkspaceNameResponse @Failure 500 {object} ErrorResponse
"Failed to get last workspace" @Router /workspaces/last [get]
func (h *Handler) GetWorkspace() http.HandlerFunc func (h *Handler) GetWorkspace() http.HandlerFunc
GetWorkspace returns the current workspace GetWorkspace godoc @Summary Get workspace @Description Returns the current
workspace @Tags workspaces @ID getWorkspace @Security CookieAuth @Produce
json @Param workspace_name path string true "Workspace name" @Success 200
{object} models.Workspace @Failure 500 {object} ErrorResponse "Internal
server error" @Router /workspaces/{workspace_name} [get]
func (h *Handler) ListFiles() http.HandlerFunc func (h *Handler) ListFiles() http.HandlerFunc
ListFiles returns a list of all files in the workspace ListFiles godoc @Summary List files @Description Lists all files in the
user's workspace @Tags files @ID listFiles @Security CookieAuth @Produce
json @Param workspace_name path string true "Workspace name" @Success 200
{array} storage.FileNode @Failure 500 {object} ErrorResponse "Failed to list
files" @Router /workspaces/{workspace_name}/files [get]
func (h *Handler) ListWorkspaces() http.HandlerFunc func (h *Handler) ListWorkspaces() http.HandlerFunc
ListWorkspaces returns a list of all workspaces for the current user ListWorkspaces godoc @Summary List workspaces @Description Lists all
workspaces for the current user @Tags workspaces @ID listWorkspaces
@Security CookieAuth @Produce json @Success 200 {array} models.Workspace
@Failure 500 {object} ErrorResponse "Failed to list workspaces" @Router
/workspaces [get]
func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc func (h *Handler) Login(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc
Login handles user authentication and returns JWT tokens Login godoc @Summary Login @Description Logs in a user and returns a
session with access and refresh tokens @Tags auth @Accept json @Produce
json @Param body body LoginRequest true "Login request" @Success 200
{object} LoginResponse @Header 200 {string} X-CSRF-Token "CSRF token for
future requests" @Failure 400 {object} ErrorResponse "Invalid request
body" @Failure 400 {object} ErrorResponse "Email and password are required"
@Failure 401 {object} ErrorResponse "Invalid credentials" @Failure 500
{object} ErrorResponse "Failed to create session" @Failure 500 {object}
ErrorResponse "Failed to generate CSRF token" @Router /auth/login [post]
func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc func (h *Handler) Logout(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc
Logout invalidates the user's session Logout godoc @Summary Logout @Description Log out invalidates the user's
session @Tags auth @ID logout @Success 204 "No Content" @Failure 400
{object} ErrorResponse "Session ID required" @Failure 500 {object}
ErrorResponse "Failed to logout" @Router /auth/logout [post]
func (h *Handler) LookupFileByName() http.HandlerFunc func (h *Handler) LookupFileByName() http.HandlerFunc
LookupFileByName returns the paths of files with the given name LookupFileByName godoc @Summary Lookup file by name @Description Returns the
paths of files with the given name in the user's workspace @Tags files @ID
lookupFileByName @Security CookieAuth @Produce json @Param workspace_name
path string true "Workspace name" @Param filename query string true
"File name" @Success 200 {object} LookupResponse @Failure 400 {object}
ErrorResponse "Filename is required" @Failure 404 {object} ErrorResponse
"File not found" @Router /workspaces/{workspace_name}/files/lookup [get]
func (h *Handler) PullChanges() http.HandlerFunc func (h *Handler) PullChanges() http.HandlerFunc
PullChanges pulls changes from the remote repository PullChanges godoc @Summary Pull changes from remote @Description Pulls
changes from the remote repository @Tags git @ID pullChanges @Security
CookieAuth @Produce json @Param workspace_name path string true "Workspace
name" @Success 200 {object} PullResponse @Failure 500 {object} ErrorResponse
"Failed to pull changes" @Router /workspaces/{workspace_name}/git/pull
[post]
func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc func (h *Handler) RefreshToken(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc
RefreshToken generates a new access token using a refresh token RefreshToken godoc @Summary Refresh token @Description Refreshes the access
token using the refresh token @Tags auth @ID refreshToken @Accept json
@Produce json @Success 200 @Header 200 {string} X-CSRF-Token "New CSRF
token" @Failure 400 {object} ErrorResponse "Refresh token required" @Failure
401 {object} ErrorResponse "Invalid refresh token" @Failure 500 {object}
ErrorResponse "Failed to generate CSRF token" @Router /auth/refresh [post]
func (h *Handler) SaveFile() http.HandlerFunc func (h *Handler) SaveFile() http.HandlerFunc
SaveFile saves the content of a file SaveFile godoc @Summary Save file @Description Saves the content of a file
in the user's workspace @Tags files @ID saveFile @Security CookieAuth
@Accept plain @Produce json @Param workspace_name path string true
"Workspace name" @Param file_path path string true "File path" @Success
200 {object} SaveFileResponse @Failure 400 {object} ErrorResponse "Failed
to read request body" @Failure 400 {object} ErrorResponse "Invalid file
path" @Failure 500 {object} ErrorResponse "Failed to save file" @Router
/workspaces/{workspace_name}/files/{file_path} [post]
func (h *Handler) StageCommitAndPush() http.HandlerFunc func (h *Handler) StageCommitAndPush() http.HandlerFunc
StageCommitAndPush stages, commits, and pushes changes to the remote StageCommitAndPush godoc @Summary Stage, commit, and push changes
repository @Description Stages, commits, and pushes changes to the remote repository
@Tags git @ID stageCommitAndPush @Security CookieAuth @Produce json
@Param workspace_name path string true "Workspace name" @Param body body
CommitRequest true "Commit request" @Success 200 {object} CommitResponse
@Failure 400 {object} ErrorResponse "Invalid request body" @Failure
400 {object} ErrorResponse "Commit message is required" @Failure 500
{object} ErrorResponse "Failed to stage, commit, and push changes" @Router
/workspaces/{workspace_name}/git/commit [post]
func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc
UpdateLastOpenedFile updates the last opened file in the workspace UpdateLastOpenedFile godoc @Summary Update last opened file @Description
Updates the last opened file in the user's workspace @Tags files @ID
updateLastOpenedFile @Security CookieAuth @Accept json @Produce json
@Param workspace_name path string true "Workspace name" @Param body
body UpdateLastOpenedFileRequest true "Update last opened file request"
@Success 204 "No Content - Last opened file updated successfully" @Failure
400 {object} ErrorResponse "Invalid request body" @Failure 400 {object}
ErrorResponse "Invalid file path" @Failure 404 {object} ErrorResponse "File
not found" @Failure 500 {object} ErrorResponse "Failed to update file"
@Router /workspaces/{workspace_name}/files/last [put]
func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc
UpdateLastWorkspaceName updates the name of the last opened workspace UpdateLastWorkspaceName godoc @Summary Update last workspace name
@Description Updates the name of the last opened workspace @Tags workspaces
@ID updateLastWorkspaceName @Security CookieAuth @Accept json @Produce json
@Success 204 "No Content - Last workspace updated successfully" @Failure
400 {object} ErrorResponse "Invalid request body" @Failure 500 {object}
ErrorResponse "Failed to update last workspace" @Router /workspaces/last
[put]
func (h *Handler) UpdateProfile() http.HandlerFunc func (h *Handler) UpdateProfile() http.HandlerFunc
UpdateProfile updates the current user's profile UpdateProfile godoc @Summary Update profile @Description Updates the
user's profile @Tags users @ID updateProfile @Security CookieAuth @Accept
json @Produce json @Param body body UpdateProfileRequest true "Profile
update request" @Success 200 {object} models.User @Failure 400 {object}
ErrorResponse "Invalid request body" @Failure 400 {object} ErrorResponse
"Current password is required to change password" @Failure 400 {object}
ErrorResponse "New password must be at least 8 characters long" @Failure
400 {object} ErrorResponse "Current password is required to change email"
@Failure 401 {object} ErrorResponse "Current password is incorrect"
@Failure 404 {object} ErrorResponse "User not found" @Failure 409 {object}
ErrorResponse "Email already in use" @Failure 500 {object} ErrorResponse
"Failed to process new password" @Failure 500 {object} ErrorResponse "Failed
to update profile" @Router /profile [put]
func (h *Handler) UpdateWorkspace() http.HandlerFunc func (h *Handler) UpdateWorkspace() http.HandlerFunc
UpdateWorkspace updates the current workspace UpdateWorkspace godoc @Summary Update workspace @Description Updates
the current workspace @Tags workspaces @ID updateWorkspace @Security
CookieAuth @Accept json @Produce json @Param workspace_name path string
true "Workspace name" @Param body body models.Workspace true "Workspace"
@Success 200 {object} models.Workspace @Failure 400 {object} ErrorResponse
"Invalid request body" @Failure 500 {object} ErrorResponse "Failed to update
workspace" @Failure 500 {object} ErrorResponse "Failed to setup git repo"
@Router /workspaces/{workspace_name} [put]
type LastOpenedFileResponse struct {
LastOpenedFilePath string `json:"lastOpenedFilePath"`
}
LastOpenedFileResponse represents a response to a last opened file request
type LastWorkspaceNameResponse struct {
LastWorkspaceName string `json:"lastWorkspaceName"`
}
LastWorkspaceNameResponse contains the name of the last opened workspace
type LoginRequest struct { type LoginRequest struct {
Email string `json:"email"` Email string `json:"email"`
@@ -497,22 +747,28 @@ type LoginRequest struct {
LoginRequest represents a user login request LoginRequest represents a user login request
type LoginResponse struct { type LoginResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
User *models.User `json:"user"` User *models.User `json:"user"`
Session *models.Session `json:"session"` SessionID string `json:"sessionId,omitempty"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
} }
LoginResponse represents a user login response LoginResponse represents a user login response
type RefreshRequest struct { type LookupResponse struct {
RefreshToken string `json:"refreshToken"` Paths []string `json:"paths"`
} }
RefreshRequest represents a refresh token request LookupResponse represents a response to a file lookup request
type RefreshResponse struct { type PullResponse struct {
AccessToken string `json:"accessToken"` Message string `json:"message" example:"Pulled changes from remote"`
} }
RefreshResponse represents a refresh token response PullResponse represents a response to a pull http request
type SaveFileResponse struct {
FilePath string `json:"filePath"`
Size int64 `json:"size"`
UpdatedAt time.Time `json:"updatedAt"`
}
SaveFileResponse represents a response to a save file request
type StaticHandler struct { type StaticHandler struct {
// Has unexported fields. // Has unexported fields.
@@ -532,6 +788,12 @@ type SystemStats struct {
} }
SystemStats holds system-wide statistics SystemStats holds system-wide statistics
type UpdateLastOpenedFileRequest struct {
FilePath string `json:"filePath"`
}
UpdateLastOpenedFileRequest represents a request to update the last opened
file
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Email string `json:"email"` Email string `json:"email"`
@@ -733,7 +995,7 @@ func (e *PathValidationError) Error() string
type RepositoryManager interface { type RepositoryManager interface {
SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error
DisableGitRepo(userID, workspaceID int) DisableGitRepo(userID, workspaceID int)
StageCommitAndPush(userID, workspaceID int, message string) error StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error)
Pull(userID, workspaceID int) error Pull(userID, workspaceID int) error
} }
RepositoryManager defines the interface for managing Git repositories. RepositoryManager defines the interface for managing Git repositories.
@@ -809,7 +1071,7 @@ func (s *Service) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToke
The repository is cloned from the given gitURL using the given gitUser and The repository is cloned from the given gitURL using the given gitUser and
gitToken. gitToken.
func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) error func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error)
StageCommitAndPush stages, commit with the message, and pushes the changes StageCommitAndPush stages, commit with the message, and pushes the changes
to the Git repository. The git repository belongs to the given userID and is to the Git repository. The git repository belongs to the given userID and is
associated with the given workspaceID. associated with the given workspaceID.

View File

@@ -15,7 +15,8 @@ type Config struct {
WorkDir string WorkDir string
StaticPath string StaticPath string
Port string Port string
AppURL string RootURL string
Domain string
CORSOrigins []string CORSOrigins []string
AdminEmail string AdminEmail string
AdminPassword string AdminPassword string
@@ -77,8 +78,12 @@ func LoadConfig() (*Config, error) {
config.Port = port config.Port = port
} }
if appURL := os.Getenv("NOVAMD_APP_URL"); appURL != "" { if rootURL := os.Getenv("NOVAMD_ROOT_URL"); rootURL != "" {
config.AppURL = appURL config.RootURL = rootURL
}
if domain := os.Getenv("NOVAMD_DOMAIN"); domain != "" {
config.Domain = domain
} }
if corsOrigins := os.Getenv("NOVAMD_CORS_ORIGINS"); corsOrigins != "" { if corsOrigins := os.Getenv("NOVAMD_CORS_ORIGINS"); corsOrigins != "" {

View File

@@ -49,7 +49,8 @@ func TestLoad(t *testing.T) {
"NOVAMD_WORKDIR", "NOVAMD_WORKDIR",
"NOVAMD_STATIC_PATH", "NOVAMD_STATIC_PATH",
"NOVAMD_PORT", "NOVAMD_PORT",
"NOVAMD_APP_URL", "NOVAMD_ROOT_URL",
"NOVAMD_DOMAIN",
"NOVAMD_CORS_ORIGINS", "NOVAMD_CORS_ORIGINS",
"NOVAMD_ADMIN_EMAIL", "NOVAMD_ADMIN_EMAIL",
"NOVAMD_ADMIN_PASSWORD", "NOVAMD_ADMIN_PASSWORD",
@@ -95,7 +96,7 @@ func TestLoad(t *testing.T) {
"NOVAMD_WORKDIR": "/custom/work/dir", "NOVAMD_WORKDIR": "/custom/work/dir",
"NOVAMD_STATIC_PATH": "/custom/static/path", "NOVAMD_STATIC_PATH": "/custom/static/path",
"NOVAMD_PORT": "3000", "NOVAMD_PORT": "3000",
"NOVAMD_APP_URL": "http://localhost:3000", "NOVAMD_ROOT_URL": "http://localhost:3000",
"NOVAMD_CORS_ORIGINS": "http://localhost:3000,http://localhost:3001", "NOVAMD_CORS_ORIGINS": "http://localhost:3000,http://localhost:3001",
"NOVAMD_ADMIN_EMAIL": "admin@example.com", "NOVAMD_ADMIN_EMAIL": "admin@example.com",
"NOVAMD_ADMIN_PASSWORD": "password123", "NOVAMD_ADMIN_PASSWORD": "password123",
@@ -124,7 +125,7 @@ func TestLoad(t *testing.T) {
{"WorkDir", cfg.WorkDir, "/custom/work/dir"}, {"WorkDir", cfg.WorkDir, "/custom/work/dir"},
{"StaticPath", cfg.StaticPath, "/custom/static/path"}, {"StaticPath", cfg.StaticPath, "/custom/static/path"},
{"Port", cfg.Port, "3000"}, {"Port", cfg.Port, "3000"},
{"AppURL", cfg.AppURL, "http://localhost:3000"}, {"AppURL", cfg.RootURL, "http://localhost:3000"},
{"AdminEmail", cfg.AdminEmail, "admin@example.com"}, {"AdminEmail", cfg.AdminEmail, "admin@example.com"},
{"AdminPassword", cfg.AdminPassword, "password123"}, {"AdminPassword", cfg.AdminPassword, "password123"},
{"JWTSigningKey", cfg.JWTSigningKey, "secret-key"}, {"JWTSigningKey", cfg.JWTSigningKey, "secret-key"},

View File

@@ -40,14 +40,14 @@ func initDatabase(cfg *Config, secretsService secrets.Service) (db.Database, err
} }
// initAuth initializes JWT and session services // initAuth initializes JWT and session services
func initAuth(cfg *Config, database db.Database) (auth.JWTManager, *auth.SessionService, error) { func initAuth(cfg *Config, database db.Database) (auth.JWTManager, auth.SessionManager, auth.CookieManager, error) {
// Get or generate JWT signing key // Get or generate JWT signing key
signingKey := cfg.JWTSigningKey signingKey := cfg.JWTSigningKey
if signingKey == "" { if signingKey == "" {
var err error var err error
signingKey, err = database.EnsureJWTSecret() signingKey, err = database.EnsureJWTSecret()
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err) return nil, nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err)
} }
} }
@@ -58,13 +58,16 @@ func initAuth(cfg *Config, database db.Database) (auth.JWTManager, *auth.Session
RefreshTokenExpiry: 7 * 24 * time.Hour, RefreshTokenExpiry: 7 * 24 * time.Hour,
}) })
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to initialize JWT service: %w", err) return nil, nil, nil, fmt.Errorf("failed to initialize JWT service: %w", err)
} }
// Initialize session service // Initialize session service
sessionService := auth.NewSessionService(database, jwtManager) sessionManager := auth.NewSessionService(database, jwtManager)
return jwtManager, sessionService, nil // Cookie service
cookieService := auth.NewCookieService(cfg.IsDevelopment, cfg.Domain)
return jwtManager, sessionManager, cookieService, nil
} }
// setupAdminUser creates the admin user if it doesn't exist // setupAdminUser creates the admin user if it doesn't exist

View File

@@ -12,7 +12,8 @@ type Options struct {
Database db.Database Database db.Database
Storage storage.Manager Storage storage.Manager
JWTManager auth.JWTManager JWTManager auth.JWTManager
SessionService *auth.SessionService SessionManager auth.SessionManager
CookieService auth.CookieManager
} }
// DefaultOptions creates server options with default configuration // DefaultOptions creates server options with default configuration
@@ -33,7 +34,7 @@ func DefaultOptions(cfg *Config) (*Options, error) {
storageManager := storage.NewService(cfg.WorkDir) storageManager := storage.NewService(cfg.WorkDir)
// Initialize auth services // Initialize auth services
jwtManager, sessionService, err := initAuth(cfg, database) jwtManager, sessionService, cookieService, err := initAuth(cfg, database)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -48,6 +49,7 @@ func DefaultOptions(cfg *Config) (*Options, error) {
Database: database, Database: database,
Storage: storageManager, Storage: storageManager,
JWTManager: jwtManager, JWTManager: jwtManager,
SessionService: sessionService, SessionManager: sessionService,
CookieService: cookieService,
}, nil }, nil
} }

View File

@@ -40,14 +40,15 @@ func setupRouter(o Options) *chi.Mux {
r.Use(cors.Handler(cors.Options{ r.Use(cors.Handler(cors.Options{
AllowedOrigins: o.Config.CORSOrigins, AllowedOrigins: o.Config.CORSOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"}, AllowedHeaders: []string{"Accept", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"X-CSRF-Token"},
AllowCredentials: true, AllowCredentials: true,
MaxAge: 300, MaxAge: 300,
})) }))
} }
// Initialize auth middleware and handler // Initialize auth middleware and handler
authMiddleware := auth.NewMiddleware(o.JWTManager) authMiddleware := auth.NewMiddleware(o.JWTManager, o.SessionManager, o.CookieService)
handler := &handlers.Handler{ handler := &handlers.Handler{
DB: o.Database, DB: o.Database,
Storage: o.Storage, Storage: o.Storage,
@@ -71,8 +72,8 @@ func setupRouter(o Options) *chi.Mux {
// Public routes (no authentication required) // Public routes (no authentication required)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Post("/auth/login", handler.Login(o.SessionService)) r.Post("/auth/login", handler.Login(o.SessionManager, o.CookieService))
r.Post("/auth/refresh", handler.RefreshToken(o.SessionService)) r.Post("/auth/refresh", handler.RefreshToken(o.SessionManager, o.CookieService))
}) })
// Protected routes (authentication required) // Protected routes (authentication required)
@@ -81,7 +82,7 @@ func setupRouter(o Options) *chi.Mux {
r.Use(context.WithUserContextMiddleware) r.Use(context.WithUserContextMiddleware)
// Auth routes // Auth routes
r.Post("/auth/logout", handler.Logout(o.SessionService)) r.Post("/auth/logout", handler.Logout(o.SessionManager, o.CookieService))
r.Get("/auth/me", handler.GetCurrentUser()) r.Get("/auth/me", handler.GetCurrentUser())
// User profile routes // User profile routes

View File

@@ -0,0 +1,91 @@
// Package auth provides JWT token generation and validation
package auth
import (
"net/http"
)
// CookieManager interface defines methods for generating cookies
type CookieManager interface {
GenerateAccessTokenCookie(token string) *http.Cookie
GenerateRefreshTokenCookie(token string) *http.Cookie
GenerateCSRFCookie(token string) *http.Cookie
InvalidateCookie(cookieType string) *http.Cookie
}
// CookieService
type cookieManager struct {
Domain string
Secure bool
SameSite http.SameSite
}
// NewCookieService creates a new cookie service
func NewCookieService(isDevelopment bool, domain string) CookieManager {
secure := !isDevelopment
var sameSite http.SameSite
if isDevelopment {
sameSite = http.SameSiteLaxMode
} else {
sameSite = http.SameSiteStrictMode
}
return &cookieManager{
Domain: domain,
Secure: secure,
SameSite: sameSite,
}
}
// GenerateAccessTokenCookie creates a new cookie for the access token
func (c *cookieManager) GenerateAccessTokenCookie(token string) *http.Cookie {
return &http.Cookie{
Name: "access_token",
Value: token,
HttpOnly: true,
Secure: c.Secure,
SameSite: c.SameSite,
Path: "/",
MaxAge: 900, // 15 minutes
}
}
// GenerateRefreshTokenCookie creates a new cookie for the refresh token
func (c *cookieManager) GenerateRefreshTokenCookie(token string) *http.Cookie {
return &http.Cookie{
Name: "refresh_token",
Value: token,
HttpOnly: true,
Secure: c.Secure,
SameSite: c.SameSite,
Path: "/",
MaxAge: 604800, // 7 days
}
}
// GenerateCSRFCookie creates a new cookie for the CSRF token
func (c *cookieManager) GenerateCSRFCookie(token string) *http.Cookie {
return &http.Cookie{
Name: "csrf_token",
Value: token,
HttpOnly: false, // Frontend needs to read this
Secure: c.Secure,
SameSite: c.SameSite,
Path: "/",
MaxAge: 900,
}
}
// InvalidateCookie creates a new cookie with a MaxAge of -1 to invalidate the cookie
func (c *cookieManager) InvalidateCookie(cookieType string) *http.Cookie {
return &http.Cookie{
Name: cookieType,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: c.Secure,
SameSite: c.SameSite,
}
}

View File

@@ -3,7 +3,6 @@ package auth
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex"
"fmt" "fmt"
"time" "time"
@@ -35,10 +34,9 @@ type JWTConfig struct {
// JWTManager defines the interface for managing JWT tokens // JWTManager defines the interface for managing JWT tokens
type JWTManager interface { type JWTManager interface {
GenerateAccessToken(userID int, role string) (string, error) GenerateAccessToken(userID int, role string, sessionID string) (string, error)
GenerateRefreshToken(userID int, role string) (string, error) GenerateRefreshToken(userID int, role string, sessionID string) (string, error)
ValidateToken(tokenString string) (*Claims, error) ValidateToken(tokenString string) (*Claims, error)
RefreshAccessToken(refreshToken string) (string, error)
} }
// jwtService handles JWT token generation and validation // jwtService handles JWT token generation and validation
@@ -63,17 +61,17 @@ func NewJWTService(config JWTConfig) (JWTManager, error) {
} }
// GenerateAccessToken creates a new access token for a user with the given userID and role // GenerateAccessToken creates a new access token for a user with the given userID and role
func (s *jwtService) GenerateAccessToken(userID int, role string) (string, error) { func (s *jwtService) GenerateAccessToken(userID int, role, sessionID string) (string, error) {
return s.generateToken(userID, role, AccessToken, s.config.AccessTokenExpiry) return s.generateToken(userID, role, sessionID, AccessToken, s.config.AccessTokenExpiry)
} }
// GenerateRefreshToken creates a new refresh token for a user with the given userID and role // GenerateRefreshToken creates a new refresh token for a user with the given userID and role
func (s *jwtService) GenerateRefreshToken(userID int, role string) (string, error) { func (s *jwtService) GenerateRefreshToken(userID int, role, sessionID string) (string, error) {
return s.generateToken(userID, role, RefreshToken, s.config.RefreshTokenExpiry) return s.generateToken(userID, role, sessionID, RefreshToken, s.config.RefreshTokenExpiry)
} }
// generateToken is an internal helper function that creates a new JWT token // generateToken is an internal helper function that creates a new JWT token
func (s *jwtService) generateToken(userID int, role string, tokenType TokenType, expiry time.Duration) (string, error) { func (s *jwtService) generateToken(userID int, role string, sessionID string, tokenType TokenType, expiry time.Duration) (string, error) {
now := time.Now() now := time.Now()
// Add a random nonce to ensure uniqueness // Add a random nonce to ensure uniqueness
@@ -87,7 +85,7 @@ func (s *jwtService) generateToken(userID int, role string, tokenType TokenType,
ExpiresAt: jwt.NewNumericDate(now.Add(expiry)), ExpiresAt: jwt.NewNumericDate(now.Add(expiry)),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now),
ID: hex.EncodeToString(nonce), ID: sessionID,
}, },
UserID: userID, UserID: userID,
Role: role, Role: role,
@@ -118,17 +116,3 @@ func (s *jwtService) ValidateToken(tokenString string) (*Claims, error) {
return nil, fmt.Errorf("invalid token claims") return nil, fmt.Errorf("invalid token claims")
} }
// RefreshAccessToken creates a new access token using a refreshToken
func (s *jwtService) RefreshAccessToken(refreshToken string) (string, error) {
claims, err := s.ValidateToken(refreshToken)
if err != nil {
return "", fmt.Errorf("invalid refresh token: %w", err)
}
if claims.Type != RefreshToken {
return "", fmt.Errorf("invalid token type: expected refresh token")
}
return s.GenerateAccessToken(claims.UserID, claims.Role)
}

View File

@@ -1,3 +1,4 @@
// Package auth_test provides tests for the auth package
package auth_test package auth_test
import ( import (
@@ -5,8 +6,6 @@ import (
"time" "time"
"novamd/internal/auth" "novamd/internal/auth"
"github.com/golang-jwt/jwt/v5"
) )
// jwt_test.go tests // jwt_test.go tests
@@ -100,9 +99,9 @@ func TestGenerateAndValidateToken(t *testing.T) {
// Generate token based on type // Generate token based on type
if tc.tokenType == auth.AccessToken { if tc.tokenType == auth.AccessToken {
token, err = service.GenerateAccessToken(tc.userID, tc.role) token, err = service.GenerateAccessToken(tc.userID, tc.role, "")
} else { } else {
token, err = service.GenerateRefreshToken(tc.userID, tc.role) token, err = service.GenerateRefreshToken(tc.userID, tc.role, "")
} }
if err != nil { if err != nil {
@@ -136,86 +135,3 @@ func TestGenerateAndValidateToken(t *testing.T) {
}) })
} }
} }
func TestRefreshAccessToken(t *testing.T) {
config := auth.JWTConfig{
SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
}
service, _ := auth.NewJWTService(config)
testCases := []struct {
name string
userID int
role string
wantErr bool
setupFunc func() string // Added setup function to handle custom token creation
}{
{
name: "valid refresh token",
userID: 1,
role: "admin",
wantErr: false,
setupFunc: func() string {
token, _ := service.GenerateRefreshToken(1, "admin")
return token
},
},
{
name: "expired refresh token",
userID: 1,
role: "admin",
wantErr: true,
setupFunc: func() string {
// Create a token that's already expired
claims := &auth.Claims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // Expired 1 hour ago
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
},
UserID: 1,
Role: "admin",
Type: auth.RefreshToken,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, _ := token.SignedString([]byte(config.SigningKey))
return tokenString
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
refreshToken := tc.setupFunc()
newAccessToken, err := service.RefreshAccessToken(refreshToken)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
claims, err := service.ValidateToken(newAccessToken)
if err != nil {
t.Fatalf("failed to validate new access token: %v", err)
}
if claims.UserID != tc.userID {
t.Errorf("userID = %v, want %v", claims.UserID, tc.userID)
}
if claims.Role != tc.role {
t.Errorf("role = %v, want %v", claims.Role, tc.role)
}
if claims.Type != auth.AccessToken {
t.Errorf("token type = %v, want %v", claims.Type, auth.AccessToken)
}
})
}
}

View File

@@ -1,8 +1,8 @@
package auth package auth
import ( import (
"crypto/subtle"
"net/http" "net/http"
"strings"
"novamd/internal/context" "novamd/internal/context"
) )
@@ -10,12 +10,16 @@ import (
// Middleware handles JWT authentication for protected routes // Middleware handles JWT authentication for protected routes
type Middleware struct { type Middleware struct {
jwtManager JWTManager jwtManager JWTManager
sessionManager SessionManager
cookieManager CookieManager
} }
// NewMiddleware creates a new authentication middleware // NewMiddleware creates a new authentication middleware
func NewMiddleware(jwtManager JWTManager) *Middleware { func NewMiddleware(jwtManager JWTManager, sessionManager SessionManager, cookieManager CookieManager) *Middleware {
return &Middleware{ return &Middleware{
jwtManager: jwtManager, jwtManager: jwtManager,
sessionManager: sessionManager,
cookieManager: cookieManager,
} }
} }
@@ -23,21 +27,14 @@ func NewMiddleware(jwtManager JWTManager) *Middleware {
func (m *Middleware) Authenticate(next http.Handler) http.Handler { func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract token from Authorization header // Extract token from Authorization header
authHeader := r.Header.Get("Authorization") cookie, err := r.Cookie("access_token")
if authHeader == "" { if err != nil {
http.Error(w, "Authorization header required", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Check Bearer token format
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization format", http.StatusUnauthorized)
return return
} }
// Validate token // Validate token
claims, err := m.jwtManager.ValidateToken(parts[1]) claims, err := m.jwtManager.ValidateToken(cookie.Value)
if err != nil { if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized) http.Error(w, "Invalid token", http.StatusUnauthorized)
return return
@@ -49,6 +46,36 @@ func (m *Middleware) Authenticate(next http.Handler) http.Handler {
return return
} }
// Check if session is still valid in database
session, err := m.sessionManager.ValidateSession(claims.ID)
if err != nil || session == nil {
m.cookieManager.InvalidateCookie("access_token")
m.cookieManager.InvalidateCookie("refresh_token")
m.cookieManager.InvalidateCookie("csrf_token")
http.Error(w, "Session invalid or expired", http.StatusUnauthorized)
return
}
// Add CSRF check for non-GET requests
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
csrfCookie, err := r.Cookie("csrf_token")
if err != nil {
http.Error(w, "CSRF cookie not found", http.StatusForbidden)
return
}
csrfHeader := r.Header.Get("X-CSRF-Token")
if csrfHeader == "" {
http.Error(w, "CSRF token header not found", http.StatusForbidden)
return
}
if subtle.ConstantTimeCompare([]byte(csrfCookie.Value), []byte(csrfHeader)) != 1 {
http.Error(w, "CSRF token mismatch", http.StatusForbidden)
return
}
}
// Create handler context with user information // Create handler context with user information
hctx := &context.HandlerContext{ hctx := &context.HandlerContext{
UserID: claims.UserID, UserID: claims.UserID,

View File

@@ -1,8 +1,10 @@
package auth_test package auth_test
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
@@ -11,6 +13,42 @@ import (
"novamd/internal/models" "novamd/internal/models"
) )
// Mock SessionManager
type mockSessionManager struct {
sessions map[string]*models.Session
}
func newMockSessionManager() *mockSessionManager {
return &mockSessionManager{
sessions: make(map[string]*models.Session),
}
}
func (m *mockSessionManager) CreateSession(_ int, _ string) (*models.Session, string, error) {
return nil, "", nil // Not needed for these tests
}
func (m *mockSessionManager) RefreshSession(_ string) (string, error) {
return "", nil // Not needed for these tests
}
func (m *mockSessionManager) ValidateSession(sessionID string) (*models.Session, error) {
session, exists := m.sessions[sessionID]
if !exists {
return nil, fmt.Errorf("session not found")
}
return session, nil
}
func (m *mockSessionManager) InvalidateSession(token string) error {
delete(m.sessions, token)
return nil
}
func (m *mockSessionManager) CleanExpiredSessions() error {
return nil
}
// Complete mockResponseWriter implementation // Complete mockResponseWriter implementation
type mockResponseWriter struct { type mockResponseWriter struct {
headers http.Header headers http.Header
@@ -44,53 +82,108 @@ func TestAuthenticateMiddleware(t *testing.T) {
RefreshTokenExpiry: 24 * time.Hour, RefreshTokenExpiry: 24 * time.Hour,
} }
jwtService, _ := auth.NewJWTService(config) jwtService, _ := auth.NewJWTService(config)
middleware := auth.NewMiddleware(jwtService) sessionManager := newMockSessionManager()
cookieManager := auth.NewCookieService(true, "localhost")
middleware := auth.NewMiddleware(jwtService, sessionManager, cookieManager)
testCases := []struct { testCases := []struct {
name string name string
setupAuth func() string setupRequest func(sessionID string) *http.Request
setupSession func(sessionID string)
method string
wantStatusCode int wantStatusCode int
}{ }{
{ {
name: "valid token", name: "valid token with valid session",
setupAuth: func() string { setupRequest: func(sessionID string) *http.Request {
token, _ := jwtService.GenerateAccessToken(1, "admin") req := httptest.NewRequest("GET", "/test", nil)
return token token, _ := jwtService.GenerateAccessToken(1, "admin", sessionID)
cookie := cookieManager.GenerateAccessTokenCookie(token)
req.AddCookie(cookie)
return req
}, },
setupSession: func(sessionID string) {
sessionManager.sessions[sessionID] = &models.Session{
ID: sessionID,
UserID: 1,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
},
method: "GET",
wantStatusCode: http.StatusOK, wantStatusCode: http.StatusOK,
}, },
{ {
name: "missing auth header", name: "valid token but invalid session",
setupAuth: func() string { setupRequest: func(sessionID string) *http.Request {
return "" req := httptest.NewRequest("GET", "/test", nil)
token, _ := jwtService.GenerateAccessToken(1, "admin", sessionID)
cookie := cookieManager.GenerateAccessTokenCookie(token)
req.AddCookie(cookie)
return req
}, },
setupSession: func(_ string) {}, // No session setup
method: "GET",
wantStatusCode: http.StatusUnauthorized, wantStatusCode: http.StatusUnauthorized,
}, },
{ {
name: "invalid auth format", name: "missing auth cookie",
setupAuth: func() string { setupRequest: func(_ string) *http.Request {
return "InvalidFormat token" return httptest.NewRequest("GET", "/test", nil)
}, },
setupSession: func(_ string) {},
method: "GET",
wantStatusCode: http.StatusUnauthorized, wantStatusCode: http.StatusUnauthorized,
}, },
{ {
name: "invalid token", name: "POST request without CSRF token",
setupAuth: func() string { setupRequest: func(sessionID string) *http.Request {
return "Bearer invalid.token.here" req := httptest.NewRequest("POST", "/test", nil)
token, _ := jwtService.GenerateAccessToken(1, "admin", sessionID)
cookie := cookieManager.GenerateAccessTokenCookie(token)
req.AddCookie(cookie)
return req
}, },
wantStatusCode: http.StatusUnauthorized, setupSession: func(sessionID string) {
sessionManager.sessions[sessionID] = &models.Session{
ID: sessionID,
UserID: 1,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
},
method: "POST",
wantStatusCode: http.StatusForbidden,
},
{
name: "POST request with valid CSRF token",
setupRequest: func(sessionID string) *http.Request {
req := httptest.NewRequest("POST", "/test", nil)
token, _ := jwtService.GenerateAccessToken(1, "admin", sessionID)
cookie := cookieManager.GenerateAccessTokenCookie(token)
req.AddCookie(cookie)
csrfToken := "test-csrf-token"
csrfCookie := cookieManager.GenerateCSRFCookie(csrfToken)
req.AddCookie(csrfCookie)
req.Header.Set("X-CSRF-Token", csrfToken)
return req
},
setupSession: func(sessionID string) {
sessionManager.sessions[sessionID] = &models.Session{
ID: sessionID,
UserID: 1,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
},
method: "POST",
wantStatusCode: http.StatusOK,
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Create test request sessionID := tc.name
req := httptest.NewRequest("GET", "/test", nil)
if token := tc.setupAuth(); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
// Create response recorder req := tc.setupRequest(sessionID)
w := newMockResponseWriter() w := newMockResponseWriter()
// Create test handler // Create test handler
@@ -100,6 +193,13 @@ func TestAuthenticateMiddleware(t *testing.T) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
// If we have a valid token, set up the session
if cookie, err := req.Cookie("access_token"); err == nil {
if claims, err := jwtService.ValidateToken(cookie.Value); err == nil {
tc.setupSession(claims.ID)
}
}
// Execute middleware // Execute middleware
middleware.Authenticate(next).ServeHTTP(w, req) middleware.Authenticate(next).ServeHTTP(w, req)
@@ -115,6 +215,15 @@ func TestAuthenticateMiddleware(t *testing.T) {
if tc.wantStatusCode != http.StatusOK && nextCalled { if tc.wantStatusCode != http.StatusOK && nextCalled {
t.Error("next handler was called when it shouldn't have been") t.Error("next handler was called when it shouldn't have been")
} }
// For unauthorized responses, check if cookies were invalidated
if w.statusCode == http.StatusUnauthorized {
for _, cookie := range w.Header()["Set-Cookie"] {
if strings.Contains(cookie, "Max-Age=0") {
t.Error("cookies were not properly invalidated")
}
}
}
}) })
} }
} }
@@ -126,7 +235,7 @@ func TestRequireRole(t *testing.T) {
RefreshTokenExpiry: 24 * time.Hour, RefreshTokenExpiry: 24 * time.Hour,
} }
jwtService, _ := auth.NewJWTService(config) jwtService, _ := auth.NewJWTService(config)
middleware := auth.NewMiddleware(jwtService) middleware := auth.NewMiddleware(jwtService, &mockSessionManager{}, auth.NewCookieService(true, "localhost"))
testCases := []struct { testCases := []struct {
name string name string
@@ -198,7 +307,7 @@ func TestRequireWorkspaceAccess(t *testing.T) {
SigningKey: "test-key", SigningKey: "test-key",
} }
jwtService, _ := auth.NewJWTService(config) jwtService, _ := auth.NewJWTService(config)
middleware := auth.NewMiddleware(jwtService) middleware := auth.NewMiddleware(jwtService, &mockSessionManager{}, auth.NewCookieService(true, "localhost"))
testCases := []struct { testCases := []struct {
name string name string

View File

@@ -9,29 +9,43 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
// SessionService manages user sessions in the database // SessionManager is an interface for managing user sessions
type SessionService struct { type SessionManager interface {
CreateSession(userID int, role string) (*models.Session, string, error)
RefreshSession(refreshToken string) (string, error)
ValidateSession(sessionID string) (*models.Session, error)
InvalidateSession(token string) error
CleanExpiredSessions() error
}
// sessionManager manages user sessions in the database
type sessionManager struct {
db db.SessionStore // Database store for sessions db db.SessionStore // Database store for sessions
jwtManager JWTManager // JWT Manager for token operations jwtManager JWTManager // JWT Manager for token operations
} }
// NewSessionService creates a new session service with the given database and JWT manager // NewSessionService creates a new session service with the given database and JWT manager
func NewSessionService(db db.SessionStore, jwtManager JWTManager) *SessionService { // revive:disable:unexported-return
return &SessionService{ func NewSessionService(db db.SessionStore, jwtManager JWTManager) *sessionManager {
return &sessionManager{
db: db, db: db,
jwtManager: jwtManager, jwtManager: jwtManager,
} }
} }
// CreateSession creates a new user session for a user with the given userID and role // CreateSession creates a new user session for a user with the given userID and role
func (s *SessionService) CreateSession(userID int, role string) (*models.Session, string, error) { func (s *sessionManager) CreateSession(userID int, role string) (*models.Session, string, error) {
// Generate a new session ID
sessionID := uuid.New().String()
// Generate both access and refresh tokens // Generate both access and refresh tokens
accessToken, err := s.jwtManager.GenerateAccessToken(userID, role) accessToken, err := s.jwtManager.GenerateAccessToken(userID, role, sessionID)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to generate access token: %w", err) return nil, "", fmt.Errorf("failed to generate access token: %w", err)
} }
refreshToken, err := s.jwtManager.GenerateRefreshToken(userID, role) refreshToken, err := s.jwtManager.GenerateRefreshToken(userID, role, sessionID)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to generate refresh token: %w", err) return nil, "", fmt.Errorf("failed to generate refresh token: %w", err)
} }
@@ -44,7 +58,7 @@ func (s *SessionService) CreateSession(userID int, role string) (*models.Session
// Create a new session record // Create a new session record
session := &models.Session{ session := &models.Session{
ID: uuid.New().String(), ID: sessionID,
UserID: userID, UserID: userID,
RefreshToken: refreshToken, RefreshToken: refreshToken,
ExpiresAt: claims.ExpiresAt.Time, ExpiresAt: claims.ExpiresAt.Time,
@@ -60,7 +74,7 @@ func (s *SessionService) CreateSession(userID int, role string) (*models.Session
} }
// RefreshSession creates a new access token using a refreshToken // RefreshSession creates a new access token using a refreshToken
func (s *SessionService) RefreshSession(refreshToken string) (string, error) { func (s *sessionManager) RefreshSession(refreshToken string) (string, error) {
// Get session from database first // Get session from database first
session, err := s.db.GetSessionByRefreshToken(refreshToken) session, err := s.db.GetSessionByRefreshToken(refreshToken)
if err != nil { if err != nil {
@@ -79,15 +93,33 @@ func (s *SessionService) RefreshSession(refreshToken string) (string, error) {
} }
// Generate a new access token // Generate a new access token
return s.jwtManager.GenerateAccessToken(claims.UserID, claims.Role) return s.jwtManager.GenerateAccessToken(claims.UserID, claims.Role, session.ID)
}
// ValidateSession checks if a session with the given sessionID is valid
func (s *sessionManager) ValidateSession(sessionID string) (*models.Session, error) {
// Get the session from the database
session, err := s.db.GetSessionByID(sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
return session, nil
} }
// InvalidateSession removes a session with the given sessionID from the database // InvalidateSession removes a session with the given sessionID from the database
func (s *SessionService) InvalidateSession(sessionID string) error { func (s *sessionManager) InvalidateSession(token string) error {
return s.db.DeleteSession(sessionID) // Parse the JWT to get the session info
claims, err := s.jwtManager.ValidateToken(token)
if err != nil {
return fmt.Errorf("invalid token: %w", err)
}
return s.db.DeleteSession(claims.ID)
} }
// CleanExpiredSessions removes all expired sessions from the database // CleanExpiredSessions removes all expired sessions from the database
func (s *SessionService) CleanExpiredSessions() error { func (s *sessionManager) CleanExpiredSessions() error {
return s.db.CleanExpiredSessions() return s.db.CleanExpiredSessions()
} }

View File

@@ -13,7 +13,7 @@ import (
// Mock SessionStore // Mock SessionStore
type mockSessionStore struct { type mockSessionStore struct {
sessions map[string]*models.Session sessions map[string]*models.Session
sessionsByToken map[string]*models.Session // Added index by refresh token sessionsByToken map[string]*models.Session
} }
func newMockSessionStore() *mockSessionStore { func newMockSessionStore() *mockSessionStore {
@@ -29,6 +29,17 @@ func (m *mockSessionStore) CreateSession(session *models.Session) error {
return nil return nil
} }
func (m *mockSessionStore) GetSessionByID(sessionID string) (*models.Session, error) {
session, exists := m.sessions[sessionID]
if !exists {
return nil, errors.New("session not found")
}
if session.ExpiresAt.Before(time.Now()) {
return nil, errors.New("session expired")
}
return session, nil
}
func (m *mockSessionStore) GetSessionByRefreshToken(refreshToken string) (*models.Session, error) { func (m *mockSessionStore) GetSessionByRefreshToken(refreshToken string) (*models.Session, error) {
session, exists := m.sessionsByToken[refreshToken] session, exists := m.sessionsByToken[refreshToken]
if !exists { if !exists {
@@ -111,9 +122,9 @@ func TestCreateSession(t *testing.T) {
} }
// Verify the session was stored // Verify the session was stored
storedSession, exists := mockDB.sessions[session.ID] storedSession, err := mockDB.GetSessionByID(session.ID)
if !exists { if err != nil {
t.Error("session was not stored in database") t.Errorf("failed to get stored session: %v", err)
} }
if storedSession.RefreshToken != session.RefreshToken { if storedSession.RefreshToken != session.RefreshToken {
t.Error("stored refresh token doesn't match") t.Error("stored refresh token doesn't match")
@@ -138,6 +149,97 @@ func TestCreateSession(t *testing.T) {
} }
} }
func TestValidateSession(t *testing.T) {
config := auth.JWTConfig{
SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute,
RefreshTokenExpiry: 24 * time.Hour,
}
jwtService, _ := auth.NewJWTService(config)
mockDB := newMockSessionStore()
sessionService := auth.NewSessionService(mockDB, jwtService)
testCases := []struct {
name string
setupSession func() string
wantErr bool
errorContains string
}{
{
name: "valid session",
setupSession: func() string {
session := &models.Session{
ID: "test-session-1",
UserID: 1,
ExpiresAt: time.Now().Add(24 * time.Hour),
CreatedAt: time.Now(),
}
if err := mockDB.CreateSession(session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
return session.ID
},
wantErr: false,
},
{
name: "expired session",
setupSession: func() string {
session := &models.Session{
ID: "test-session-2",
UserID: 1,
ExpiresAt: time.Now().Add(-1 * time.Hour),
CreatedAt: time.Now().Add(-2 * time.Hour),
}
if err := mockDB.CreateSession(session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
return session.ID
},
wantErr: true,
errorContains: "session expired",
},
{
name: "non-existent session",
setupSession: func() string {
return "non-existent-session-id"
},
wantErr: true,
errorContains: "session not found",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sessionID := tc.setupSession()
session, err := sessionService.ValidateSession(sessionID)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if tc.errorContains != "" && !strings.Contains(err.Error(), tc.errorContains) {
t.Errorf("error = %v, want error containing %v", err, tc.errorContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if session == nil {
t.Error("expected session, got nil")
return
}
if session.ID != sessionID {
t.Errorf("session ID = %v, want %v", session.ID, sessionID)
}
})
}
}
func TestRefreshSession(t *testing.T) { func TestRefreshSession(t *testing.T) {
config := auth.JWTConfig{ config := auth.JWTConfig{
SigningKey: "test-key", SigningKey: "test-key",
@@ -157,7 +259,7 @@ func TestRefreshSession(t *testing.T) {
{ {
name: "valid refresh token", name: "valid refresh token",
setupSession: func() string { setupSession: func() string {
token, _ := jwtService.GenerateRefreshToken(1, "admin") token, _ := jwtService.GenerateRefreshToken(1, "admin", "test-session-1")
session := &models.Session{ session := &models.Session{
ID: "test-session-1", ID: "test-session-1",
UserID: 1, UserID: 1,
@@ -175,12 +277,12 @@ func TestRefreshSession(t *testing.T) {
{ {
name: "expired refresh token", name: "expired refresh token",
setupSession: func() string { setupSession: func() string {
token, _ := jwtService.GenerateRefreshToken(1, "admin") token, _ := jwtService.GenerateRefreshToken(1, "admin", "test-session-2")
session := &models.Session{ session := &models.Session{
ID: "test-session-2", ID: "test-session-2",
UserID: 1, UserID: 1,
RefreshToken: token, RefreshToken: token,
ExpiresAt: time.Now().Add(-1 * time.Hour), // Expired ExpiresAt: time.Now().Add(-1 * time.Hour),
CreatedAt: time.Now().Add(-2 * time.Hour), CreatedAt: time.Now().Add(-2 * time.Hour),
} }
if err := mockDB.CreateSession(session); err != nil { if err := mockDB.CreateSession(session); err != nil {
@@ -233,7 +335,7 @@ func TestRefreshSession(t *testing.T) {
} }
} }
func TestInvalidateSession(t *testing.T) { func TestCleanExpiredSessions(t *testing.T) {
config := auth.JWTConfig{ config := auth.JWTConfig{
SigningKey: "test-key", SigningKey: "test-key",
AccessTokenExpiry: 15 * time.Minute, AccessTokenExpiry: 15 * time.Minute,
@@ -243,62 +345,40 @@ func TestInvalidateSession(t *testing.T) {
mockDB := newMockSessionStore() mockDB := newMockSessionStore()
sessionService := auth.NewSessionService(mockDB, jwtService) sessionService := auth.NewSessionService(mockDB, jwtService)
testCases := []struct { // Create test sessions
name string validSession := &models.Session{
setupSession func() string ID: "valid-session",
wantErr bool
errorContains string
}{
{
name: "valid session invalidation",
setupSession: func() string {
session := &models.Session{
ID: "test-session-1",
UserID: 1, UserID: 1,
RefreshToken: "valid-token",
ExpiresAt: time.Now().Add(24 * time.Hour), ExpiresAt: time.Now().Add(24 * time.Hour),
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if err := mockDB.CreateSession(session); err != nil { if err := mockDB.CreateSession(validSession); err != nil {
t.Fatalf("failed to create session: %v", err) t.Fatalf("failed to create valid session: %v", err)
}
return session.ID
},
wantErr: false,
},
{
name: "non-existent session",
setupSession: func() string {
return "non-existent-session-id"
},
wantErr: true,
errorContains: "session not found",
},
} }
for _, tc := range testCases { expiredSession := &models.Session{
t.Run(tc.name, func(t *testing.T) { ID: "expired-session",
sessionID := tc.setupSession() UserID: 2,
err := sessionService.InvalidateSession(sessionID) ExpiresAt: time.Now().Add(-1 * time.Hour),
CreatedAt: time.Now().Add(-2 * time.Hour),
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tc.errorContains) {
t.Errorf("error = %v, want error containing %v", err, tc.errorContains)
} }
return if err := mockDB.CreateSession(expiredSession); err != nil {
t.Fatalf("failed to create expired session: %v", err)
} }
// Clean expired sessions
err := sessionService.CleanExpiredSessions()
if err != nil { if err != nil {
t.Errorf("unexpected error: %v", err) t.Errorf("unexpected error cleaning sessions: %v", err)
return
} }
// Verify session was removed // Verify valid session still exists
if _, exists := mockDB.sessions[sessionID]; exists { if _, err := mockDB.GetSessionByID(validSession.ID); err != nil {
t.Error("session still exists after invalidation") t.Error("valid session was incorrectly removed")
} }
})
// Verify expired session was removed
if _, err := mockDB.GetSessionByID(expiredSession.ID); err == nil {
t.Error("expired session was not removed")
} }
} }

View File

@@ -53,6 +53,7 @@ type WorkspaceStore interface {
type SessionStore interface { type SessionStore interface {
CreateSession(session *models.Session) error CreateSession(session *models.Session) error
GetSessionByRefreshToken(refreshToken string) (*models.Session, error) GetSessionByRefreshToken(refreshToken string) (*models.Session, error)
GetSessionByID(sessionID string) (*models.Session, error)
DeleteSession(sessionID string) error DeleteSession(sessionID string) error
CleanExpiredSessions() error CleanExpiredSessions() error
} }

View File

@@ -41,6 +41,26 @@ func (db *database) GetSessionByRefreshToken(refreshToken string) (*models.Sessi
return session, nil return session, nil
} }
// GetSessionByID retrieves a session by its ID
func (db *database) GetSessionByID(sessionID string) (*models.Session, error) {
session := &models.Session{}
err := db.QueryRow(`
SELECT id, user_id, refresh_token, expires_at, created_at
FROM sessions
WHERE id = ? AND expires_at > ?`,
sessionID, time.Now(),
).Scan(&session.ID, &session.UserID, &session.RefreshToken, &session.ExpiresAt, &session.CreatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("session not found")
}
if err != nil {
return nil, fmt.Errorf("failed to fetch session: %w", err)
}
return session, nil
}
// DeleteSession removes a session from the database // DeleteSession removes a session from the database
func (db *database) DeleteSession(sessionID string) error { func (db *database) DeleteSession(sessionID string) error {
result, err := db.Exec("DELETE FROM sessions WHERE id = ?", sessionID) result, err := db.Exec("DELETE FROM sessions WHERE id = ?", sessionID)

View File

@@ -51,7 +51,7 @@ type SystemStats struct {
// @Summary List all users // @Summary List all users
// @Description Returns the list of all users // @Description Returns the list of all users
// @Tags Admin // @Tags Admin
// @Security BearerAuth // @Security CookieAuth
// @ID adminListUsers // @ID adminListUsers
// @Produce json // @Produce json
// @Success 200 {array} models.User // @Success 200 {array} models.User
@@ -73,7 +73,7 @@ func (h *Handler) AdminListUsers() http.HandlerFunc {
// @Summary Create a new user // @Summary Create a new user
// @Description Create a new user as an admin // @Description Create a new user as an admin
// @Tags Admin // @Tags Admin
// @Security BearerAuth // @Security CookieAuth
// @ID adminCreateUser // @ID adminCreateUser
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -149,7 +149,7 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
// @Summary Get a specific user // @Summary Get a specific user
// @Description Get a specific user as an admin // @Description Get a specific user as an admin
// @Tags Admin // @Tags Admin
// @Security BearerAuth // @Security CookieAuth
// @ID adminGetUser // @ID adminGetUser
// @Produce json // @Produce json
// @Param userId path int true "User ID" // @Param userId path int true "User ID"
@@ -179,7 +179,7 @@ func (h *Handler) AdminGetUser() http.HandlerFunc {
// @Summary Update a specific user // @Summary Update a specific user
// @Description Update a specific user as an admin // @Description Update a specific user as an admin
// @Tags Admin // @Tags Admin
// @Security BearerAuth // @Security CookieAuth
// @ID adminUpdateUser // @ID adminUpdateUser
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -245,7 +245,7 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc {
// @Summary Delete a specific user // @Summary Delete a specific user
// @Description Delete a specific user as an admin // @Description Delete a specific user as an admin
// @Tags Admin // @Tags Admin
// @Security BearerAuth // @Security CookieAuth
// @ID adminDeleteUser // @ID adminDeleteUser
// @Param userId path int true "User ID" // @Param userId path int true "User ID"
// @Success 204 "No Content" // @Success 204 "No Content"
@@ -300,7 +300,7 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc {
// @Summary List all workspaces // @Summary List all workspaces
// @Description List all workspaces and their stats as an admin // @Description List all workspaces and their stats as an admin
// @Tags Admin // @Tags Admin
// @Security BearerAuth // @Security CookieAuth
// @ID adminListWorkspaces // @ID adminListWorkspaces
// @Produce json // @Produce json
// @Success 200 {array} WorkspaceStats // @Success 200 {array} WorkspaceStats
@@ -353,7 +353,7 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc {
// @Summary Get system statistics // @Summary Get system statistics
// @Description Get system-wide statistics as an admin // @Description Get system-wide statistics as an admin
// @Tags Admin // @Tags Admin
// @Security BearerAuth // @Security CookieAuth
// @ID adminGetSystemStats // @ID adminGetSystemStats
// @Produce json // @Produce json
// @Success 200 {object} SystemStats // @Success 200 {object} SystemStats

View File

@@ -34,8 +34,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
t.Run("user management", func(t *testing.T) { t.Run("user management", func(t *testing.T) {
t.Run("list users", func(t *testing.T) { t.Run("list users", func(t *testing.T) {
// Test with admin token // Test with admin session
rr := h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var users []*models.User var users []*models.User
@@ -44,15 +44,15 @@ func TestAdminHandlers_Integration(t *testing.T) {
// Should have at least our admin and regular test users // Should have at least our admin and regular test users
assert.GreaterOrEqual(t, len(users), 2) assert.GreaterOrEqual(t, len(users), 2)
assert.True(t, containsUser(users, h.AdminUser), "Admin user not found in users list") assert.True(t, containsUser(users, h.AdminTestUser.userModel), "Admin user not found in users list")
assert.True(t, containsUser(users, h.RegularUser), "Regular user not found in users list") assert.True(t, containsUser(users, h.RegularTestUser.userModel), "Regular user not found in users list")
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
// Test without token // Test without session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, "", nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users", nil, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -64,8 +64,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
Role: models.RoleEditor, Role: models.RoleEditor,
} }
// Test with admin token // Test with admin session
rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var createdUser models.User var createdUser models.User
@@ -77,7 +77,7 @@ func TestAdminHandlers_Integration(t *testing.T) {
assert.NotZero(t, createdUser.LastWorkspaceID) assert.NotZero(t, createdUser.LastWorkspaceID)
// Test duplicate email // Test duplicate email
rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminTestUser)
assert.Equal(t, http.StatusConflict, rr.Code) assert.Equal(t, http.StatusConflict, rr.Code)
// Test invalid request (missing required fields) // Test invalid request (missing required fields)
@@ -85,44 +85,44 @@ func TestAdminHandlers_Integration(t *testing.T) {
Email: "invalid@test.com", Email: "invalid@test.com",
// Missing password and role // Missing password and role
} }
rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", invalidReq, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", invalidReq, h.AdminTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
t.Run("get user", func(t *testing.T) { t.Run("get user", func(t *testing.T) {
path := fmt.Sprintf("/api/v1/admin/users/%d", h.RegularUser.ID) path := fmt.Sprintf("/api/v1/admin/users/%d", h.RegularTestUser.session.UserID)
// Test with admin token // Test with admin session
rr := h.makeRequest(t, http.MethodGet, path, nil, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodGet, path, nil, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
err := json.NewDecoder(rr.Body).Decode(&user) err := json.NewDecoder(rr.Body).Decode(&user)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, h.RegularUser.ID, user.ID) assert.Equal(t, h.RegularTestUser.session.UserID, user.ID)
// Test non-existent user // Test non-existent user
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users/999999", nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/users/999999", nil, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodGet, path, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, path, nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
t.Run("update user", func(t *testing.T) { t.Run("update user", func(t *testing.T) {
path := fmt.Sprintf("/api/v1/admin/users/%d", h.RegularUser.ID) path := fmt.Sprintf("/api/v1/admin/users/%d", h.RegularTestUser.session.UserID)
updateReq := handlers.UpdateUserRequest{ updateReq := handlers.UpdateUserRequest{
DisplayName: "Updated Name", DisplayName: "Updated Name",
Role: models.RoleViewer, Role: models.RoleViewer,
} }
// Test with admin token // Test with admin session
rr := h.makeRequest(t, http.MethodPut, path, updateReq, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodPut, path, updateReq, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var updatedUser models.User var updatedUser models.User
@@ -131,8 +131,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
assert.Equal(t, updateReq.DisplayName, updatedUser.DisplayName) assert.Equal(t, updateReq.DisplayName, updatedUser.DisplayName)
assert.Equal(t, updateReq.Role, updatedUser.Role) assert.Equal(t, updateReq.Role, updatedUser.Role)
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodPut, path, updateReq, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPut, path, updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
@@ -145,7 +145,7 @@ func TestAdminHandlers_Integration(t *testing.T) {
Role: models.RoleEditor, Role: models.RoleEditor,
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var createdUser models.User var createdUser models.User
@@ -155,20 +155,20 @@ func TestAdminHandlers_Integration(t *testing.T) {
path := fmt.Sprintf("/api/v1/admin/users/%d", createdUser.ID) path := fmt.Sprintf("/api/v1/admin/users/%d", createdUser.ID)
// Test deleting own account (should fail) // Test deleting own account (should fail)
adminPath := fmt.Sprintf("/api/v1/admin/users/%d", h.AdminUser.ID) adminPath := fmt.Sprintf("/api/v1/admin/users/%d", h.AdminTestUser.session.UserID)
rr = h.makeRequest(t, http.MethodDelete, adminPath, nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodDelete, adminPath, nil, h.AdminTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
// Test with admin token // Test with admin session
rr = h.makeRequest(t, http.MethodDelete, path, nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodDelete, path, nil, h.AdminTestUser)
assert.Equal(t, http.StatusNoContent, rr.Code) assert.Equal(t, http.StatusNoContent, rr.Code)
// Verify user is deleted // Verify user is deleted
rr = h.makeRequest(t, http.MethodGet, path, nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodGet, path, nil, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodDelete, path, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodDelete, path, nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
}) })
@@ -177,15 +177,15 @@ func TestAdminHandlers_Integration(t *testing.T) {
t.Run("list workspaces", func(t *testing.T) { t.Run("list workspaces", func(t *testing.T) {
// Create a test workspace first // Create a test workspace first
workspace := &models.Workspace{ workspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "Test Workspace", Name: "Test Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Test with admin token // Test with admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/workspaces", nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/workspaces", nil, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var workspaces []*handlers.WorkspaceStats var workspaces []*handlers.WorkspaceStats
@@ -206,8 +206,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
assert.GreaterOrEqual(t, ws.TotalSize, int64(0)) assert.GreaterOrEqual(t, ws.TotalSize, int64(0))
} }
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/workspaces", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/workspaces", nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
}) })
@@ -215,14 +215,14 @@ func TestAdminHandlers_Integration(t *testing.T) {
t.Run("system stats", func(t *testing.T) { t.Run("system stats", func(t *testing.T) {
// Create some test data // Create some test data
workspace := &models.Workspace{ workspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "Stats Test Workspace", Name: "Stats Test Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Test with admin token // Test with admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/stats", nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/stats", nil, h.AdminTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var stats handlers.SystemStats var stats handlers.SystemStats
@@ -236,8 +236,8 @@ func TestAdminHandlers_Integration(t *testing.T) {
assert.GreaterOrEqual(t, stats.TotalFiles, 0) assert.GreaterOrEqual(t, stats.TotalFiles, 0)
assert.GreaterOrEqual(t, stats.TotalSize, int64(0)) assert.GreaterOrEqual(t, stats.TotalSize, int64(0))
// Test with non-admin token // Test with non-admin session
rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/stats", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/admin/stats", nil, h.RegularTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
} }

View File

@@ -1,11 +1,14 @@
package handlers package handlers
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"net/http" "net/http"
"novamd/internal/auth" "novamd/internal/auth"
"novamd/internal/context" "novamd/internal/context"
"novamd/internal/models" "novamd/internal/models"
"time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -18,37 +21,27 @@ type LoginRequest struct {
// LoginResponse represents a user login response // LoginResponse represents a user login response
type LoginResponse struct { type LoginResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
User *models.User `json:"user"` User *models.User `json:"user"`
Session *models.Session `json:"session"` SessionID string `json:"sessionId,omitempty"`
} ExpiresAt time.Time `json:"expiresAt,omitempty"`
// RefreshRequest represents a refresh token request
type RefreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
// RefreshResponse represents a refresh token response
type RefreshResponse struct {
AccessToken string `json:"accessToken"`
} }
// Login godoc // Login godoc
// @Summary Login // @Summary Login
// @Description Logs in a user // @Description Logs in a user and returns a session with access and refresh tokens
// @Tags auth // @Tags auth
// @ID login
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body LoginRequest true "Login request" // @Param body body LoginRequest true "Login request"
// @Success 200 {object} LoginResponse // @Success 200 {object} LoginResponse
// @Header 200 {string} X-CSRF-Token "CSRF token for future requests"
// @Failure 400 {object} ErrorResponse "Invalid request body" // @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Email and password are required" // @Failure 400 {object} ErrorResponse "Email and password are required"
// @Failure 401 {object} ErrorResponse "Invalid credentials" // @Failure 401 {object} ErrorResponse "Invalid credentials"
// @Failure 500 {object} ErrorResponse "Failed to create session" // @Failure 500 {object} ErrorResponse "Failed to create session"
// @Failure 500 {object} ErrorResponse "Failed to generate CSRF token"
// @Router /auth/login [post] // @Router /auth/login [post]
func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc { func (h *Handler) Login(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req LoginRequest var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -77,18 +70,33 @@ func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc {
} }
// Create session and generate tokens // Create session and generate tokens
session, accessToken, err := authService.CreateSession(user.ID, string(user.Role)) session, accessToken, err := authManager.CreateSession(user.ID, string(user.Role))
if err != nil { if err != nil {
respondError(w, "Failed to create session", http.StatusInternalServerError) respondError(w, "Failed to create session", http.StatusInternalServerError)
return return
} }
// Prepare response // Generate CSRF token
csrfToken := make([]byte, 32)
if _, err := rand.Read(csrfToken); err != nil {
respondError(w, "Failed to generate CSRF token", http.StatusInternalServerError)
return
}
csrfTokenString := hex.EncodeToString(csrfToken)
// Set cookies
http.SetCookie(w, cookieService.GenerateAccessTokenCookie(accessToken))
http.SetCookie(w, cookieService.GenerateRefreshTokenCookie(session.RefreshToken))
http.SetCookie(w, cookieService.GenerateCSRFCookie(csrfTokenString))
// Send CSRF token in header for initial setup
w.Header().Set("X-CSRF-Token", csrfTokenString)
// Only send user info in response, not tokens
response := LoginResponse{ response := LoginResponse{
AccessToken: accessToken,
RefreshToken: session.RefreshToken,
User: user, User: user,
Session: session, SessionID: session.ID,
ExpiresAt: session.ExpiresAt,
} }
respondJSON(w, response) respondJSON(w, response)
@@ -100,25 +108,30 @@ func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc {
// @Description Log out invalidates the user's session // @Description Log out invalidates the user's session
// @Tags auth // @Tags auth
// @ID logout // @ID logout
// @Security BearerAuth
// @Success 204 "No Content" // @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse "Session ID required" // @Failure 400 {object} ErrorResponse "Session ID required"
// @Failure 500 {object} ErrorResponse "Failed to logout" // @Failure 500 {object} ErrorResponse "Failed to logout"
// @Router /auth/logout [post] // @Router /auth/logout [post]
func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc { func (h *Handler) Logout(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
sessionID := r.Header.Get("X-Session-ID") // Get session ID from cookie
if sessionID == "" { sessionCookie, err := r.Cookie("access_token")
respondError(w, "Session ID required", http.StatusBadRequest) if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
err := authService.InvalidateSession(sessionID) // Invalidate the session in the database
if err != nil { if err := authManager.InvalidateSession(sessionCookie.Value); err != nil {
respondError(w, "Failed to logout", http.StatusInternalServerError) respondError(w, "Failed to invalidate session", http.StatusInternalServerError)
return return
} }
// Clear cookies
http.SetCookie(w, cookieService.InvalidateCookie("access_token"))
http.SetCookie(w, cookieService.InvalidateCookie("refresh_token"))
http.SetCookie(w, cookieService.InvalidateCookie("csrf_token"))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
@@ -130,37 +143,40 @@ func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc {
// @ID refreshToken // @ID refreshToken
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body RefreshRequest true "Refresh request" // @Success 200
// @Success 200 {object} RefreshResponse // @Header 200 {string} X-CSRF-Token "New CSRF token"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 400 {object} ErrorResponse "Refresh token required" // @Failure 400 {object} ErrorResponse "Refresh token required"
// @Failure 401 {object} ErrorResponse "Invalid refresh token" // @Failure 401 {object} ErrorResponse "Invalid refresh token"
// @Failure 500 {object} ErrorResponse "Failed to generate CSRF token"
// @Router /auth/refresh [post] // @Router /auth/refresh [post]
func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc { func (h *Handler) RefreshToken(authManager auth.SessionManager, cookieService auth.CookieManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req RefreshRequest refreshCookie, err := r.Cookie("refresh_token")
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err != nil {
respondError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.RefreshToken == "" {
respondError(w, "Refresh token required", http.StatusBadRequest) respondError(w, "Refresh token required", http.StatusBadRequest)
return return
} }
// Generate new access token // Generate new access token
accessToken, err := authService.RefreshSession(req.RefreshToken) accessToken, err := authManager.RefreshSession(refreshCookie.Value)
if err != nil { if err != nil {
respondError(w, "Invalid refresh token", http.StatusUnauthorized) respondError(w, "Invalid refresh token", http.StatusUnauthorized)
return return
} }
response := RefreshResponse{ // Generate new CSRF token
AccessToken: accessToken, csrfToken := make([]byte, 32)
if _, err := rand.Read(csrfToken); err != nil {
respondError(w, "Failed to generate CSRF token", http.StatusInternalServerError)
return
} }
csrfTokenString := hex.EncodeToString(csrfToken)
respondJSON(w, response) http.SetCookie(w, cookieService.GenerateAccessTokenCookie(accessToken))
http.SetCookie(w, cookieService.GenerateCSRFCookie(csrfTokenString))
w.Header().Set("X-CSRF-Token", csrfTokenString)
w.WriteHeader(http.StatusOK)
} }
} }
@@ -169,7 +185,7 @@ func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFun
// @Description Returns the current authenticated user // @Description Returns the current authenticated user
// @Tags auth // @Tags auth
// @ID getCurrentUser // @ID getCurrentUser
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Success 200 {object} models.User // @Success 200 {object} models.User
// @Failure 404 {object} ErrorResponse "User not found" // @Failure 404 {object} ErrorResponse "User not found"

View File

@@ -4,8 +4,12 @@ package handlers_test
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"time"
"novamd/internal/handlers" "novamd/internal/handlers"
"novamd/internal/models" "novamd/internal/models"
@@ -25,40 +29,58 @@ func TestAuthHandlers_Integration(t *testing.T) {
Password: "admin123", Password: "admin123",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, nil)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Verify all required cookies are present with correct attributes
cookies := rr.Result().Cookies()
var foundAccessToken, foundRefreshToken, foundCSRF bool
for _, cookie := range cookies {
switch cookie.Name {
case "access_token":
foundAccessToken = true
assert.True(t, cookie.HttpOnly, "access_token cookie must be HttpOnly")
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
assert.Equal(t, 900, cookie.MaxAge) // 15 minutes
case "refresh_token":
foundRefreshToken = true
assert.True(t, cookie.HttpOnly, "refresh_token cookie must be HttpOnly")
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
assert.Equal(t, 604800, cookie.MaxAge) // 7 days
case "csrf_token":
foundCSRF = true
assert.False(t, cookie.HttpOnly, "csrf_token cookie must not be HttpOnly")
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
assert.Equal(t, 900, cookie.MaxAge) // 15 minutes
}
}
assert.True(t, foundAccessToken, "access_token cookie not found")
assert.True(t, foundRefreshToken, "refresh_token cookie not found")
assert.True(t, foundCSRF, "csrf_token cookie not found")
// Verify CSRF token is in both cookie and header, and they match
var csrfCookie *http.Cookie
for _, cookie := range rr.Result().Cookies() {
if cookie.Name == "csrf_token" {
csrfCookie = cookie
break
}
}
require.NotNil(t, csrfCookie, "csrf_token cookie not found")
csrfHeader := rr.Header().Get("X-CSRF-Token")
assert.Equal(t, csrfCookie.Value, csrfHeader)
// Verify response body
var resp handlers.LoginResponse var resp handlers.LoginResponse
err := json.NewDecoder(rr.Body).Decode(&resp) err := json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, resp.SessionID)
assert.NotEmpty(t, resp.AccessToken) assert.False(t, resp.ExpiresAt.IsZero())
assert.NotEmpty(t, resp.RefreshToken)
assert.NotNil(t, resp.User) assert.NotNil(t, resp.User)
assert.Equal(t, loginReq.Email, resp.User.Email) assert.Equal(t, loginReq.Email, resp.User.Email)
assert.Equal(t, models.RoleAdmin, resp.User.Role) assert.Equal(t, models.RoleAdmin, resp.User.Role)
}) })
t.Run("successful login - regular user", func(t *testing.T) {
loginReq := handlers.LoginRequest{
Email: "user@test.com",
Password: "user123",
}
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil)
require.Equal(t, http.StatusOK, rr.Code)
var resp handlers.LoginResponse
err := json.NewDecoder(rr.Body).Decode(&resp)
require.NoError(t, err)
assert.NotEmpty(t, resp.AccessToken)
assert.NotEmpty(t, resp.RefreshToken)
assert.NotNil(t, resp.User)
assert.Equal(t, loginReq.Email, resp.User.Email)
assert.Equal(t, models.RoleEditor, resp.User.Role)
})
t.Run("login failures", func(t *testing.T) { t.Run("login failures", func(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -97,12 +119,26 @@ func TestAuthHandlers_Integration(t *testing.T) {
}, },
wantCode: http.StatusBadRequest, wantCode: http.StatusBadRequest,
}, },
{
name: "malformed JSON",
request: handlers.LoginRequest{}, // Will be overridden with bad JSON
wantCode: http.StatusBadRequest,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", tt.request, "", nil) var rr *httptest.ResponseRecorder
if tt.name == "malformed JSON" {
// Need lower level helper to send malformed JSON
req := h.newRequest(t, http.MethodPost, "/api/v1/auth/login", nil)
req.Body = io.NopCloser(strings.NewReader("{bad json"))
rr = h.executeRequest(req)
} else {
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", tt.request, nil)
}
assert.Equal(t, tt.wantCode, rr.Code) assert.Equal(t, tt.wantCode, rr.Code)
assert.Empty(t, rr.Result().Cookies(), "failed login should not set cookies")
}) })
} }
}) })
@@ -110,58 +146,81 @@ func TestAuthHandlers_Integration(t *testing.T) {
t.Run("refresh token", func(t *testing.T) { t.Run("refresh token", func(t *testing.T) {
t.Run("successful token refresh", func(t *testing.T) { t.Run("successful token refresh", func(t *testing.T) {
// First login to get refresh token // Need lower level helpers for precise cookie control
loginReq := handlers.LoginRequest{ req := h.newRequest(t, http.MethodPost, "/api/v1/auth/refresh", nil)
Email: "user@test.com", h.addAuthCookies(t, req, h.RegularTestUser) // Adds both tokens
Password: "user123", h.addCSRFCookie(t, req)
} rr := h.executeRequest(req)
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var loginResp handlers.LoginResponse // Verify new cookies
err := json.NewDecoder(rr.Body).Decode(&loginResp) cookies := rr.Result().Cookies()
require.NoError(t, err) var foundAccessToken, foundCSRF bool
for _, cookie := range cookies {
// Now try to refresh the token switch cookie.Name {
refreshReq := handlers.RefreshRequest{ case "access_token":
RefreshToken: loginResp.RefreshToken, foundAccessToken = true
assert.Equal(t, 900, cookie.MaxAge)
case "csrf_token":
foundCSRF = true
assert.Equal(t, 900, cookie.MaxAge)
case "refresh_token":
t.Error("refresh token should not be renewed")
} }
}
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/refresh", refreshReq, "", nil) assert.True(t, foundAccessToken, "new access_token cookie not found")
require.Equal(t, http.StatusOK, rr.Code) assert.True(t, foundCSRF, "new csrf_token cookie not found")
var refreshResp handlers.RefreshResponse
err = json.NewDecoder(rr.Body).Decode(&refreshResp)
require.NoError(t, err)
assert.NotEmpty(t, refreshResp.AccessToken)
}) })
t.Run("refresh failures", func(t *testing.T) { t.Run("refresh token edge cases", func(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
request handlers.RefreshRequest setup func(*http.Request)
wantCode int wantCode int
}{ }{
{ {
name: "invalid refresh token", name: "missing refresh token cookie",
request: handlers.RefreshRequest{ setup: func(req *http.Request) {
RefreshToken: "invalid-token", // Only add access token
req.AddCookie(h.CookieManager.GenerateAccessTokenCookie(h.RegularTestUser.accessToken))
},
wantCode: http.StatusBadRequest,
},
{
name: "expired refresh token",
setup: func(req *http.Request) {
expiredSession := &models.Session{
ID: "expired",
UserID: h.RegularTestUser.session.UserID,
RefreshToken: "expired-token",
ExpiresAt: time.Now().Add(-1 * time.Hour),
}
expiredSessionUser := &testUser{
userModel: h.RegularTestUser.userModel,
accessToken: h.RegularTestUser.accessToken,
session: expiredSession,
}
h.addAuthCookies(t, req, expiredSessionUser)
}, },
wantCode: http.StatusUnauthorized, wantCode: http.StatusUnauthorized,
}, },
{ {
name: "empty refresh token", name: "invalid refresh token format",
request: handlers.RefreshRequest{ setup: func(req *http.Request) {
RefreshToken: "", req.AddCookie(&http.Cookie{
Name: "refresh_token",
Value: "invalid-format",
})
}, },
wantCode: http.StatusBadRequest, wantCode: http.StatusUnauthorized,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/refresh", tt.request, "", nil) req := h.newRequest(t, http.MethodPost, "/api/v1/auth/refresh", nil)
tt.setup(req)
rr := h.executeRequest(req)
assert.Equal(t, tt.wantCode, rr.Code) assert.Equal(t, tt.wantCode, rr.Code)
}) })
} }
@@ -170,63 +229,156 @@ func TestAuthHandlers_Integration(t *testing.T) {
t.Run("logout", func(t *testing.T) { t.Run("logout", func(t *testing.T) {
t.Run("successful logout", func(t *testing.T) { t.Run("successful logout", func(t *testing.T) {
// First login to get session // Need CSRF token for POST request
loginReq := handlers.LoginRequest{ req := h.newRequest(t, http.MethodPost, "/api/v1/auth/logout", nil)
Email: "user@test.com", h.addAuthCookies(t, req, h.RegularTestUser)
Password: "user123", csrfToken := h.addCSRFCookie(t, req)
} req.Header.Set("X-CSRF-Token", csrfToken)
rr := h.executeRequest(req)
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil)
require.Equal(t, http.StatusOK, rr.Code)
var loginResp handlers.LoginResponse
err := json.NewDecoder(rr.Body).Decode(&loginResp)
require.NoError(t, err)
// Now logout using session ID from login response
headers := map[string]string{
"X-Session-ID": loginResp.Session.ID,
}
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/logout", nil, loginResp.AccessToken, headers)
require.Equal(t, http.StatusNoContent, rr.Code) require.Equal(t, http.StatusNoContent, rr.Code)
// Try to use the refresh token - should fail // Verify cookies are properly invalidated
refreshReq := handlers.RefreshRequest{ for _, cookie := range rr.Result().Cookies() {
RefreshToken: loginResp.RefreshToken, assert.True(t, cookie.MaxAge < 0, "cookie should be invalidated")
assert.True(t, cookie.Expires.Before(time.Now()), "cookie should be expired")
} }
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/refresh", refreshReq, "", nil) // Verify session is actually invalidated
rr = h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, h.RegularTestUser)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
t.Run("logout without session ID", func(t *testing.T) { t.Run("logout edge cases", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodPost, "/api/v1/auth/logout", nil, h.RegularToken, nil) tests := []struct {
assert.Equal(t, http.StatusBadRequest, rr.Code) name string
setup func(*http.Request, *testUser)
wantCode int
}{
{
name: "missing CSRF token",
setup: func(req *http.Request, tu *testUser) {
h.addAuthCookies(t, req, tu)
h.addCSRFCookie(t, req)
// Deliberately not setting X-CSRF-Token header
},
wantCode: http.StatusForbidden,
},
{
name: "mismatched CSRF token",
setup: func(req *http.Request, tu *testUser) {
h.addAuthCookies(t, req, tu)
h.addCSRFCookie(t, req)
req.Header.Set("X-CSRF-Token", "wrong-token")
},
wantCode: http.StatusForbidden,
},
{
name: "missing auth cookies",
setup: func(req *http.Request, tu *testUser) {
// No setup - testing completely unauthenticated request
},
wantCode: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a unique user for each test case
// Construct a unique email address from test name
uniqueUserEmail := strings.Replace(tt.name, " ", "", -1) + "@test.com"
logoutTestUser := h.createTestUser(t, uniqueUserEmail, "password123", models.RoleEditor)
req := h.newRequest(t, http.MethodPost, "/api/v1/auth/logout", nil)
tt.setup(req, logoutTestUser)
rr := h.executeRequest(req)
assert.Equal(t, tt.wantCode, rr.Code)
})
}
}) })
}) })
t.Run("get current user", func(t *testing.T) { t.Run("get current user", func(t *testing.T) {
getTestUser := h.createTestUser(t, "testgetuser@test.com", "password123", models.RoleEditor)
t.Run("successful get current user", func(t *testing.T) { t.Run("successful get current user", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, getTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
err := json.NewDecoder(rr.Body).Decode(&user) err := json.NewDecoder(rr.Body).Decode(&user)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, getTestUser.userModel.Email, user.Email)
assert.Equal(t, h.RegularUser.ID, user.ID)
assert.Equal(t, h.RegularUser.Email, user.Email)
assert.Equal(t, h.RegularUser.Role, user.Role)
}) })
t.Run("get current user without token", func(t *testing.T) { t.Run("auth edge cases", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, "", nil) tests := []struct {
assert.Equal(t, http.StatusUnauthorized, rr.Code) name string
setup func(*http.Request)
wantCode int
}{
{
name: "missing auth cookie",
setup: func(req *http.Request) {
// No setup - testing unauthenticated request
},
wantCode: http.StatusUnauthorized,
},
{
name: "invalid session ID",
setup: func(req *http.Request) {
invalidSession := &models.Session{
ID: "invalid",
UserID: 999,
RefreshToken: "invalid",
ExpiresAt: time.Now().Add(time.Hour),
}
invalidSessionUser := &testUser{
userModel: h.RegularTestUser.userModel,
accessToken: h.RegularTestUser.accessToken,
session: invalidSession,
}
h.addAuthCookies(t, req, invalidSessionUser)
},
wantCode: http.StatusUnauthorized,
},
{
name: "expired session",
setup: func(req *http.Request) {
expiredSession := &models.Session{
ID: "expired",
UserID: h.RegularTestUser.session.UserID,
RefreshToken: "expired-token",
ExpiresAt: time.Now().Add(-1 * time.Hour),
}
expiredSessionUser := &testUser{
userModel: h.RegularTestUser.userModel,
accessToken: h.RegularTestUser.accessToken,
session: expiredSession,
}
h.addAuthCookies(t, req, expiredSessionUser)
},
wantCode: http.StatusUnauthorized,
},
{
name: "malformed access token",
setup: func(req *http.Request) {
req.AddCookie(&http.Cookie{
Name: "access_token",
Value: "malformed-token",
}) })
},
wantCode: http.StatusUnauthorized,
},
}
t.Run("get current user with invalid token", func(t *testing.T) { for _, tt := range tests {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, "invalid-token", nil) t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rr.Code) req := h.newRequest(t, http.MethodGet, "/api/v1/auth/me", nil)
tt.setup(req)
rr := h.executeRequest(req)
assert.Equal(t, tt.wantCode, rr.Code)
})
}
}) })
}) })
} }

View File

@@ -40,7 +40,7 @@ type UpdateLastOpenedFileRequest struct {
// @Description Lists all files in the user's workspace // @Description Lists all files in the user's workspace
// @Tags files // @Tags files
// @ID listFiles // @ID listFiles
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
// @Success 200 {array} storage.FileNode // @Success 200 {array} storage.FileNode
@@ -68,7 +68,7 @@ func (h *Handler) ListFiles() http.HandlerFunc {
// @Description Returns the paths of files with the given name in the user's workspace // @Description Returns the paths of files with the given name in the user's workspace
// @Tags files // @Tags files
// @ID lookupFileByName // @ID lookupFileByName
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
// @Param filename query string true "File name" // @Param filename query string true "File name"
@@ -104,7 +104,7 @@ func (h *Handler) LookupFileByName() http.HandlerFunc {
// @Description Returns the content of a file in the user's workspace // @Description Returns the content of a file in the user's workspace
// @Tags files // @Tags files
// @ID getFileContent // @ID getFileContent
// @Security BearerAuth // @Security CookieAuth
// @Produce plain // @Produce plain
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
// @Param file_path path string true "File path" // @Param file_path path string true "File path"
@@ -153,7 +153,7 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
// @Description Saves the content of a file in the user's workspace // @Description Saves the content of a file in the user's workspace
// @Tags files // @Tags files
// @ID saveFile // @ID saveFile
// @Security BearerAuth // @Security CookieAuth
// @Accept plain // @Accept plain
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
@@ -204,7 +204,7 @@ func (h *Handler) SaveFile() http.HandlerFunc {
// @Description Deletes a file in the user's workspace // @Description Deletes a file in the user's workspace
// @Tags files // @Tags files
// @ID deleteFile // @ID deleteFile
// @Security BearerAuth // @Security CookieAuth
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
// @Param file_path path string true "File path" // @Param file_path path string true "File path"
// @Success 204 "No Content - File deleted successfully" // @Success 204 "No Content - File deleted successfully"
@@ -246,7 +246,7 @@ func (h *Handler) DeleteFile() http.HandlerFunc {
// @Description Returns the path of the last opened file in the user's workspace // @Description Returns the path of the last opened file in the user's workspace
// @Tags files // @Tags files
// @ID getLastOpenedFile // @ID getLastOpenedFile
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
// @Success 200 {object} LastOpenedFileResponse // @Success 200 {object} LastOpenedFileResponse
@@ -280,7 +280,7 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc {
// @Description Updates the last opened file in the user's workspace // @Description Updates the last opened file in the user's workspace
// @Tags files // @Tags files
// @ID updateLastOpenedFile // @ID updateLastOpenedFile
// @Security BearerAuth // @Security CookieAuth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"

View File

@@ -24,10 +24,10 @@ func TestFileHandlers_Integration(t *testing.T) {
t.Run("file operations", func(t *testing.T) { t.Run("file operations", func(t *testing.T) {
// Setup: Create a workspace first // Setup: Create a workspace first
workspace := &models.Workspace{ workspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "File Test Workspace", Name: "File Test Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err := json.NewDecoder(rr.Body).Decode(workspace) err := json.NewDecoder(rr.Body).Decode(workspace)
@@ -37,7 +37,7 @@ func TestFileHandlers_Integration(t *testing.T) {
baseURL := fmt.Sprintf("/api/v1/workspaces/%s/files", url.PathEscape(workspace.Name)) baseURL := fmt.Sprintf("/api/v1/workspaces/%s/files", url.PathEscape(workspace.Name))
t.Run("list empty directory", func(t *testing.T) { t.Run("list empty directory", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var files []storage.FileNode var files []storage.FileNode
@@ -51,16 +51,16 @@ func TestFileHandlers_Integration(t *testing.T) {
filePath := "test.md" filePath := "test.md"
// Save file // Save file
rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"/"+filePath, strings.NewReader(content), h.RegularToken, nil) rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"/"+filePath, strings.NewReader(content), h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Get file content // Get file content
rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, content, rr.Body.String()) assert.Equal(t, content, rr.Body.String())
// List directory should now show the file // List directory should now show the file
rr = h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var files []storage.FileNode var files []storage.FileNode
@@ -80,12 +80,12 @@ func TestFileHandlers_Integration(t *testing.T) {
// Create all files // Create all files
for path, content := range files { for path, content := range files {
rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+path, content, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+path, content, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
} }
// List all files // List all files
rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var fileNodes []storage.FileNode var fileNodes []storage.FileNode
@@ -116,11 +116,11 @@ func TestFileHandlers_Integration(t *testing.T) {
// Look up a file that exists in multiple locations // Look up a file that exists in multiple locations
filename := "readme.md" filename := "readme.md"
dupContent := "Another readme" dupContent := "Another readme"
rr := h.makeRequest(t, http.MethodPost, baseURL+"/projects/"+filename, dupContent, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/projects/"+filename, dupContent, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Search for the file // Search for the file
rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename="+filename, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename="+filename, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -131,7 +131,7 @@ func TestFileHandlers_Integration(t *testing.T) {
assert.Len(t, response.Paths, 2) assert.Len(t, response.Paths, 2)
// Search for non-existent file // Search for non-existent file
rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename=nonexistent.md", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/lookup?filename=nonexistent.md", nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
@@ -140,21 +140,21 @@ func TestFileHandlers_Integration(t *testing.T) {
content := "This file will be deleted" content := "This file will be deleted"
// Create file // Create file
rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+filePath, content, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/"+filePath, content, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Delete file // Delete file
rr = h.makeRequest(t, http.MethodDelete, baseURL+"/"+filePath, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodDelete, baseURL+"/"+filePath, nil, h.RegularTestUser)
require.Equal(t, http.StatusNoContent, rr.Code) require.Equal(t, http.StatusNoContent, rr.Code)
// Verify file is gone // Verify file is gone
rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
t.Run("last opened file", func(t *testing.T) { t.Run("last opened file", func(t *testing.T) {
// Initially should be empty // Initially should be empty
rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -170,11 +170,11 @@ func TestFileHandlers_Integration(t *testing.T) {
}{ }{
FilePath: "docs/readme.md", FilePath: "docs/readme.md",
} }
rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularTestUser)
require.Equal(t, http.StatusNoContent, rr.Code) require.Equal(t, http.StatusNoContent, rr.Code)
// Verify update // Verify update
rr = h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err = json.NewDecoder(rr.Body).Decode(&response) err = json.NewDecoder(rr.Body).Decode(&response)
@@ -183,7 +183,7 @@ func TestFileHandlers_Integration(t *testing.T) {
// Test invalid file path // Test invalid file path
updateReq.FilePath = "nonexistent.md" updateReq.FilePath = "nonexistent.md"
rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
@@ -204,12 +204,12 @@ func TestFileHandlers_Integration(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Test without token // Test without session
rr := h.makeRequest(t, tc.method, tc.path, tc.body, "", nil) rr := h.makeRequest(t, tc.method, tc.path, tc.body, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
// Test with wrong user's token // Test with wrong user's session
rr = h.makeRequest(t, tc.method, tc.path, tc.body, h.AdminToken, nil) rr = h.makeRequest(t, tc.method, tc.path, tc.body, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
} }
@@ -226,11 +226,11 @@ func TestFileHandlers_Integration(t *testing.T) {
for _, path := range maliciousPaths { for _, path := range maliciousPaths {
t.Run(path, func(t *testing.T) { t.Run(path, func(t *testing.T) {
// Try to read // Try to read
rr := h.makeRequest(t, http.MethodGet, baseURL+"/"+path, nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL+"/"+path, nil, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
// Try to write // Try to write
rr = h.makeRequest(t, http.MethodPost, baseURL+"/"+path, "malicious content", h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, baseURL+"/"+path, "malicious content", h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
} }

View File

@@ -27,7 +27,7 @@ type PullResponse struct {
// @Description Stages, commits, and pushes changes to the remote repository // @Description Stages, commits, and pushes changes to the remote repository
// @Tags git // @Tags git
// @ID stageCommitAndPush // @ID stageCommitAndPush
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
// @Param body body CommitRequest true "Commit request" // @Param body body CommitRequest true "Commit request"
@@ -70,7 +70,7 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc {
// @Description Pulls changes from the remote repository // @Description Pulls changes from the remote repository
// @Tags git // @Tags git
// @ID pullChanges // @ID pullChanges
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
// @Success 200 {object} PullResponse // @Success 200 {object} PullResponse

View File

@@ -22,7 +22,7 @@ func TestGitHandlers_Integration(t *testing.T) {
t.Run("git operations", func(t *testing.T) { t.Run("git operations", func(t *testing.T) {
// Setup: Create a workspace with Git enabled // Setup: Create a workspace with Git enabled
workspace := &models.Workspace{ workspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "Git Test Workspace", Name: "Git Test Workspace",
GitEnabled: true, GitEnabled: true,
GitURL: "https://github.com/test/repo.git", GitURL: "https://github.com/test/repo.git",
@@ -32,7 +32,7 @@ func TestGitHandlers_Integration(t *testing.T) {
GitCommitMsgTemplate: "Update: {{message}}", GitCommitMsgTemplate: "Update: {{message}}",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err := json.NewDecoder(rr.Body).Decode(workspace) err := json.NewDecoder(rr.Body).Decode(workspace)
@@ -50,7 +50,7 @@ func TestGitHandlers_Integration(t *testing.T) {
"message": commitMsg, "message": commitMsg,
} }
rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response map[string]string var response map[string]string
@@ -70,7 +70,7 @@ func TestGitHandlers_Integration(t *testing.T) {
"message": "", "message": "",
} }
rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Equal(t, 0, h.MockGit.GetCommitCount(), "Commit should not be called") assert.Equal(t, 0, h.MockGit.GetCommitCount(), "Commit should not be called")
}) })
@@ -83,7 +83,7 @@ func TestGitHandlers_Integration(t *testing.T) {
"message": "Test message", "message": "Test message",
} }
rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/commit", requestBody, h.RegularTestUser)
assert.Equal(t, http.StatusInternalServerError, rr.Code) assert.Equal(t, http.StatusInternalServerError, rr.Code)
h.MockGit.SetError(nil) // Reset error state h.MockGit.SetError(nil) // Reset error state
@@ -94,7 +94,7 @@ func TestGitHandlers_Integration(t *testing.T) {
h.MockGit.Reset() h.MockGit.Reset()
t.Run("successful pull", func(t *testing.T) { t.Run("successful pull", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodPost, baseURL+"/pull", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/pull", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response map[string]string var response map[string]string
@@ -109,7 +109,7 @@ func TestGitHandlers_Integration(t *testing.T) {
h.MockGit.Reset() h.MockGit.Reset()
h.MockGit.SetError(fmt.Errorf("mock git error")) h.MockGit.SetError(fmt.Errorf("mock git error"))
rr := h.makeRequest(t, http.MethodPost, baseURL+"/pull", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, baseURL+"/pull", nil, h.RegularTestUser)
assert.Equal(t, http.StatusInternalServerError, rr.Code) assert.Equal(t, http.StatusInternalServerError, rr.Code)
h.MockGit.SetError(nil) // Reset error state h.MockGit.SetError(nil) // Reset error state
@@ -140,12 +140,12 @@ func TestGitHandlers_Integration(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Test without token // Test without session
rr := h.makeRequest(t, tc.method, tc.path, tc.body, "", nil) rr := h.makeRequest(t, tc.method, tc.path, tc.body, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
// Test with wrong user's token // Test with wrong user's session
rr = h.makeRequest(t, tc.method, tc.path, tc.body, h.AdminToken, nil) rr = h.makeRequest(t, tc.method, tc.path, tc.body, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
} }
@@ -156,11 +156,11 @@ func TestGitHandlers_Integration(t *testing.T) {
// Create a workspace without Git enabled // Create a workspace without Git enabled
nonGitWorkspace := &models.Workspace{ nonGitWorkspace := &models.Workspace{
UserID: h.RegularUser.ID, UserID: h.RegularTestUser.session.UserID,
Name: "Non-Git Workspace", Name: "Non-Git Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", nonGitWorkspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", nonGitWorkspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err := json.NewDecoder(rr.Body).Decode(nonGitWorkspace) err := json.NewDecoder(rr.Body).Decode(nonGitWorkspace)
@@ -170,11 +170,11 @@ func TestGitHandlers_Integration(t *testing.T) {
// Try to commit // Try to commit
commitMsg := map[string]string{"message": "test"} commitMsg := map[string]string{"message": "test"}
rr = h.makeRequest(t, http.MethodPost, nonGitBaseURL+"/commit", commitMsg, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, nonGitBaseURL+"/commit", commitMsg, h.RegularTestUser)
assert.Equal(t, http.StatusInternalServerError, rr.Code) assert.Equal(t, http.StatusInternalServerError, rr.Code)
// Try to pull // Try to pull
rr = h.makeRequest(t, http.MethodPost, nonGitBaseURL+"/pull", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, nonGitBaseURL+"/pull", nil, h.RegularTestUser)
assert.Equal(t, http.StatusInternalServerError, rr.Code) assert.Equal(t, http.StatusInternalServerError, rr.Code)
}) })
}) })

View File

@@ -6,6 +6,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"io" "io"
"net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"testing" "testing"
@@ -28,15 +29,20 @@ type testHarness struct {
DB db.TestDatabase DB db.TestDatabase
Storage storage.Manager Storage storage.Manager
JWTManager auth.JWTManager JWTManager auth.JWTManager
SessionSvc *auth.SessionService SessionManager auth.SessionManager
AdminUser *models.User CookieManager auth.CookieManager
AdminToken string AdminTestUser *testUser
RegularUser *models.User RegularTestUser *testUser
RegularToken string
TempDirectory string TempDirectory string
MockGit *MockGitClient MockGit *MockGitClient
} }
type testUser struct {
userModel *models.User
accessToken string
session *models.Session
}
// setupTestHarness creates a new test environment // setupTestHarness creates a new test environment
func setupTestHarness(t *testing.T) *testHarness { func setupTestHarness(t *testing.T) *testHarness {
t.Helper() t.Helper()
@@ -86,6 +92,9 @@ func setupTestHarness(t *testing.T) *testHarness {
// Initialize session service // Initialize session service
sessionSvc := auth.NewSessionService(database, jwtSvc) sessionSvc := auth.NewSessionService(database, jwtSvc)
// Initialize cookie service
cookieSvc := auth.NewCookieService(true, "localhost")
// Create test config // Create test config
testConfig := &app.Config{ testConfig := &app.Config{
DBPath: ":memory:", DBPath: ":memory:",
@@ -104,7 +113,8 @@ func setupTestHarness(t *testing.T) *testHarness {
Database: database, Database: database,
Storage: storageSvc, Storage: storageSvc,
JWTManager: jwtSvc, JWTManager: jwtSvc,
SessionService: sessionSvc, SessionManager: sessionSvc,
CookieService: cookieSvc,
} }
// Create server // Create server
@@ -115,19 +125,18 @@ func setupTestHarness(t *testing.T) *testHarness {
DB: database, DB: database,
Storage: storageSvc, Storage: storageSvc,
JWTManager: jwtSvc, JWTManager: jwtSvc,
SessionSvc: sessionSvc, SessionManager: sessionSvc,
CookieManager: cookieSvc,
TempDirectory: tempDir, TempDirectory: tempDir,
MockGit: mockGit, MockGit: mockGit,
} }
// Create test users // Create test users
adminUser, adminToken := h.createTestUser(t, "admin@test.com", "admin123", models.RoleAdmin) adminTestUser := h.createTestUser(t, "admin@test.com", "admin123", models.RoleAdmin)
regularUser, regularToken := h.createTestUser(t, "user@test.com", "user123", models.RoleEditor) regularTestUser := h.createTestUser(t, "user@test.com", "user123", models.RoleEditor)
h.AdminUser = adminUser h.AdminTestUser = adminTestUser
h.AdminToken = adminToken h.RegularTestUser = regularTestUser
h.RegularUser = regularUser
h.RegularToken = regularToken
return h return h
} }
@@ -146,7 +155,7 @@ func (h *testHarness) teardown(t *testing.T) {
} }
// createTestUser creates a test user and returns the user and access token // createTestUser creates a test user and returns the user and access token
func (h *testHarness) createTestUser(t *testing.T, email, password string, role models.UserRole) (*models.User, string) { func (h *testHarness) createTestUser(t *testing.T, email, password string, role models.UserRole) *testUser {
t.Helper() t.Helper()
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@@ -172,25 +181,23 @@ func (h *testHarness) createTestUser(t *testing.T, email, password string, role
t.Fatalf("Failed to initialize user workspace: %v", err) t.Fatalf("Failed to initialize user workspace: %v", err)
} }
session, accessToken, err := h.SessionSvc.CreateSession(user.ID, string(user.Role)) session, accessToken, err := h.SessionManager.CreateSession(user.ID, string(user.Role))
if err != nil { if err != nil {
t.Fatalf("Failed to create session: %v", err) t.Fatalf("Failed to create session: %v", err)
} }
if session == nil || accessToken == "" { return &testUser{
t.Fatal("Failed to get valid session or token") userModel: user,
accessToken: accessToken,
session: session,
}
} }
return user, accessToken func (h *testHarness) newRequest(t *testing.T, method, path string, body interface{}) *http.Request {
}
// makeRequest is a helper function to make HTTP requests in tests
func (h *testHarness) makeRequest(t *testing.T, method, path string, body interface{}, token string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
var reqBody []byte var reqBody []byte
var err error var err error
if body != nil { if body != nil {
reqBody, err = json.Marshal(body) reqBody, err = json.Marshal(body)
if err != nil { if err != nil {
@@ -199,38 +206,71 @@ func (h *testHarness) makeRequest(t *testing.T, method, path string, body interf
} }
req := httptest.NewRequest(method, path, bytes.NewBuffer(reqBody)) req := httptest.NewRequest(method, path, bytes.NewBuffer(reqBody))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
return req
// Add any additional headers
for k, v := range headers {
req.Header.Set(k, v)
} }
// newRequestRaw creates a new request with raw body
func (h *testHarness) newRequestRaw(t *testing.T, method, path string, body io.Reader) *http.Request {
t.Helper()
return httptest.NewRequest(method, path, body)
}
// executeRequest executes the request and returns response recorder
func (h *testHarness) executeRequest(req *http.Request) *httptest.ResponseRecorder {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
h.Server.Router().ServeHTTP(rr, req) h.Server.Router().ServeHTTP(rr, req)
return rr return rr
} }
// makeRequestRaw is a helper function to make HTTP requests with raw body content // addAuthCookies adds authentication cookies to request
func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io.Reader, token string, headers map[string]string) *httptest.ResponseRecorder { func (h *testHarness) addAuthCookies(t *testing.T, req *http.Request, testUser *testUser) {
t.Helper() t.Helper()
req := httptest.NewRequest(method, path, body) if testUser == nil || testUser.session == nil {
if token != "" { return
req.Header.Set("Authorization", "Bearer "+token)
} }
// Add any additional headers req.AddCookie(h.CookieManager.GenerateAccessTokenCookie(testUser.accessToken))
for k, v := range headers { req.AddCookie(h.CookieManager.GenerateRefreshTokenCookie(testUser.session.RefreshToken))
req.Header.Set(k, v)
} }
rr := httptest.NewRecorder() func (h *testHarness) addCSRFCookie(t *testing.T, req *http.Request) string {
h.Server.Router().ServeHTTP(rr, req) t.Helper()
return rr csrfToken := "test-csrf-token"
req.AddCookie(h.CookieManager.GenerateCSRFCookie(csrfToken))
return csrfToken
}
// makeRequest is the main helper for making JSON requests
func (h *testHarness) makeRequest(t *testing.T, method, path string, body interface{}, testUser *testUser) *httptest.ResponseRecorder {
t.Helper()
req := h.newRequest(t, method, path, body)
h.addAuthCookies(t, req, testUser)
needsCSRF := method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions
if needsCSRF {
csrfToken := h.addCSRFCookie(t, req)
req.Header.Set("X-CSRF-Token", csrfToken)
}
return h.executeRequest(req)
}
// makeRequestRawWithHeaders adds support for custom headers with raw body
func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io.Reader, testUser *testUser) *httptest.ResponseRecorder {
t.Helper()
req := h.newRequestRaw(t, method, path, body)
h.addAuthCookies(t, req, testUser)
needsCSRF := method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions
if needsCSRF {
csrfToken := h.addCSRFCookie(t, req)
req.Header.Set("X-CSRF-Token", csrfToken)
}
return h.executeRequest(req)
} }

View File

@@ -27,7 +27,7 @@ type DeleteAccountRequest struct {
// @Description Updates the user's profile // @Description Updates the user's profile
// @Tags users // @Tags users
// @ID updateProfile // @ID updateProfile
// @Security BearerAuth // @Security CookieAuth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body UpdateProfileRequest true "Profile update request" // @Param body body UpdateProfileRequest true "Profile update request"
@@ -137,7 +137,7 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
// @Description Deletes the user's account and all associated data // @Description Deletes the user's account and all associated data
// @Tags users // @Tags users
// @ID deleteAccount // @ID deleteAccount
// @Security BearerAuth // @Security CookieAuth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body DeleteAccountRequest true "Account deletion request" // @Param body body DeleteAccountRequest true "Account deletion request"

View File

@@ -18,27 +18,27 @@ func TestUserHandlers_Integration(t *testing.T) {
h := setupTestHarness(t) h := setupTestHarness(t)
defer h.teardown(t) defer h.teardown(t)
currentEmail := h.RegularUser.Email currentEmail := h.RegularTestUser.userModel.Email
currentPassword := "user123" currentPassword := "user123"
t.Run("get current user", func(t *testing.T) { t.Run("get current user", func(t *testing.T) {
t.Run("successful get", func(t *testing.T) { t.Run("successful get", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
err := json.NewDecoder(rr.Body).Decode(&user) err := json.NewDecoder(rr.Body).Decode(&user)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, h.RegularUser.ID, user.ID) assert.Equal(t, h.RegularTestUser.userModel.ID, user.ID)
assert.Equal(t, h.RegularUser.Email, user.Email) assert.Equal(t, h.RegularTestUser.userModel.Email, user.Email)
assert.Equal(t, h.RegularUser.DisplayName, user.DisplayName) assert.Equal(t, h.RegularTestUser.userModel.DisplayName, user.DisplayName)
assert.Equal(t, h.RegularUser.Role, user.Role) assert.Equal(t, h.RegularTestUser.userModel.Role, user.Role)
assert.Empty(t, user.PasswordHash, "Password hash should not be included in response") assert.Empty(t, user.PasswordHash, "Password hash should not be included in response")
}) })
t.Run("unauthorized", func(t *testing.T) { t.Run("unauthorized", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, "", nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/auth/me", nil, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
}) })
@@ -49,7 +49,7 @@ func TestUserHandlers_Integration(t *testing.T) {
DisplayName: "Updated Name", DisplayName: "Updated Name",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
@@ -64,7 +64,7 @@ func TestUserHandlers_Integration(t *testing.T) {
CurrentPassword: currentPassword, CurrentPassword: currentPassword,
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var user models.User var user models.User
@@ -80,7 +80,7 @@ func TestUserHandlers_Integration(t *testing.T) {
Email: "anotheremail@test.com", Email: "anotheremail@test.com",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
@@ -90,7 +90,7 @@ func TestUserHandlers_Integration(t *testing.T) {
CurrentPassword: "wrongpassword", CurrentPassword: "wrongpassword",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -100,7 +100,7 @@ func TestUserHandlers_Integration(t *testing.T) {
NewPassword: "newpassword123", NewPassword: "newpassword123",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Verify can login with new password // Verify can login with new password
@@ -109,7 +109,7 @@ func TestUserHandlers_Integration(t *testing.T) {
Password: "newpassword123", Password: "newpassword123",
} }
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, nil)
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
currentPassword = updateReq.NewPassword currentPassword = updateReq.NewPassword
@@ -120,7 +120,7 @@ func TestUserHandlers_Integration(t *testing.T) {
NewPassword: "newpass123", NewPassword: "newpass123",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
@@ -130,7 +130,7 @@ func TestUserHandlers_Integration(t *testing.T) {
NewPassword: "newpass123", NewPassword: "newpass123",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -140,61 +140,40 @@ func TestUserHandlers_Integration(t *testing.T) {
NewPassword: "short", NewPassword: "short",
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
t.Run("duplicate email", func(t *testing.T) { t.Run("duplicate email", func(t *testing.T) {
updateReq := handlers.UpdateProfileRequest{ updateReq := handlers.UpdateProfileRequest{
Email: h.AdminUser.Email, Email: h.AdminTestUser.userModel.Email,
CurrentPassword: currentPassword, CurrentPassword: currentPassword,
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/profile", updateReq, h.RegularTestUser)
assert.Equal(t, http.StatusConflict, rr.Code) assert.Equal(t, http.StatusConflict, rr.Code)
}) })
}) })
t.Run("delete account", func(t *testing.T) { t.Run("delete account", func(t *testing.T) {
// Create a new user that we can delete
createReq := handlers.CreateUserRequest{
Email: "todelete@test.com",
DisplayName: "To Delete",
Password: "password123",
Role: models.RoleEditor,
}
rr := h.makeRequest(t, http.MethodPost, "/api/v1/admin/users", createReq, h.AdminToken, nil) deleteUserPassword := "password123"
require.Equal(t, http.StatusOK, rr.Code) testDeleteUser := h.createTestUser(t, "todelete@test.com", deleteUserPassword, models.RoleEditor)
var newUser models.User
err := json.NewDecoder(rr.Body).Decode(&newUser)
require.NoError(t, err)
// Get token for new user
loginReq := handlers.LoginRequest{
Email: createReq.Email,
Password: createReq.Password,
}
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil)
require.Equal(t, http.StatusOK, rr.Code)
var loginResp handlers.LoginResponse
err = json.NewDecoder(rr.Body).Decode(&loginResp)
require.NoError(t, err)
userToken := loginResp.AccessToken
t.Run("successful delete", func(t *testing.T) { t.Run("successful delete", func(t *testing.T) {
deleteReq := handlers.DeleteAccountRequest{ deleteReq := handlers.DeleteAccountRequest{
Password: createReq.Password, Password: deleteUserPassword,
} }
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, userToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, testDeleteUser)
require.Equal(t, http.StatusNoContent, rr.Code) require.Equal(t, http.StatusNoContent, rr.Code)
// Verify user is deleted // Verify user is deleted
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil) loginReq := handlers.LoginRequest{
Email: testDeleteUser.userModel.Email,
Password: deleteUserPassword,
}
rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, testDeleteUser)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -203,7 +182,7 @@ func TestUserHandlers_Integration(t *testing.T) {
Password: "wrongpassword", Password: "wrongpassword",
} }
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, h.RegularTestUser)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
@@ -212,7 +191,7 @@ func TestUserHandlers_Integration(t *testing.T) {
Password: "admin123", // Admin password from test harness Password: "admin123", // Admin password from test harness
} }
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, h.AdminTestUser)
assert.Equal(t, http.StatusForbidden, rr.Code) assert.Equal(t, http.StatusForbidden, rr.Code)
}) })
}) })

View File

@@ -24,7 +24,7 @@ type LastWorkspaceNameResponse struct {
// @Description Lists all workspaces for the current user // @Description Lists all workspaces for the current user
// @Tags workspaces // @Tags workspaces
// @ID listWorkspaces // @ID listWorkspaces
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Success 200 {array} models.Workspace // @Success 200 {array} models.Workspace
// @Failure 500 {object} ErrorResponse "Failed to list workspaces" // @Failure 500 {object} ErrorResponse "Failed to list workspaces"
@@ -51,7 +51,7 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc {
// @Description Creates a new workspace // @Description Creates a new workspace
// @Tags workspaces // @Tags workspaces
// @ID createWorkspace // @ID createWorkspace
// @Security BearerAuth // @Security CookieAuth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body models.Workspace true "Workspace" // @Param body body models.Workspace true "Workspace"
@@ -115,7 +115,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
// @Description Returns the current workspace // @Description Returns the current workspace
// @Tags workspaces // @Tags workspaces
// @ID getWorkspace // @ID getWorkspace
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
// @Success 200 {object} models.Workspace // @Success 200 {object} models.Workspace
@@ -155,7 +155,7 @@ func gitSettingsChanged(new, old *models.Workspace) bool {
// @Description Updates the current workspace // @Description Updates the current workspace
// @Tags workspaces // @Tags workspaces
// @ID updateWorkspace // @ID updateWorkspace
// @Security BearerAuth // @Security CookieAuth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
@@ -223,7 +223,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc {
// @Description Deletes the current workspace // @Description Deletes the current workspace
// @Tags workspaces // @Tags workspaces
// @ID deleteWorkspace // @ID deleteWorkspace
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Param workspace_name path string true "Workspace name" // @Param workspace_name path string true "Workspace name"
// @Success 200 {object} DeleteWorkspaceResponse // @Success 200 {object} DeleteWorkspaceResponse
@@ -307,7 +307,7 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc {
// @Description Returns the name of the last opened workspace // @Description Returns the name of the last opened workspace
// @Tags workspaces // @Tags workspaces
// @ID getLastWorkspaceName // @ID getLastWorkspaceName
// @Security BearerAuth // @Security CookieAuth
// @Produce json // @Produce json
// @Success 200 {object} LastWorkspaceNameResponse // @Success 200 {object} LastWorkspaceNameResponse
// @Failure 500 {object} ErrorResponse "Failed to get last workspace" // @Failure 500 {object} ErrorResponse "Failed to get last workspace"
@@ -334,7 +334,7 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
// @Description Updates the name of the last opened workspace // @Description Updates the name of the last opened workspace
// @Tags workspaces // @Tags workspaces
// @ID updateLastWorkspaceName // @ID updateLastWorkspaceName
// @Security BearerAuth // @Security CookieAuth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 204 "No Content - Last workspace updated successfully" // @Success 204 "No Content - Last workspace updated successfully"

View File

@@ -20,7 +20,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
t.Run("list workspaces", func(t *testing.T) { t.Run("list workspaces", func(t *testing.T) {
t.Run("successful list", func(t *testing.T) { t.Run("successful list", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var workspaces []*models.Workspace var workspaces []*models.Workspace
@@ -30,7 +30,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
}) })
t.Run("unauthorized", func(t *testing.T) { t.Run("unauthorized", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, "", nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, nil)
assert.Equal(t, http.StatusUnauthorized, rr.Code) assert.Equal(t, http.StatusUnauthorized, rr.Code)
}) })
}) })
@@ -41,14 +41,14 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
Name: "Test Workspace", Name: "Test Workspace",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var created models.Workspace var created models.Workspace
err := json.NewDecoder(rr.Body).Decode(&created) err := json.NewDecoder(rr.Body).Decode(&created)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, workspace.Name, created.Name) assert.Equal(t, workspace.Name, created.Name)
assert.Equal(t, h.RegularUser.ID, created.UserID) assert.Equal(t, h.RegularTestUser.session.UserID, created.UserID)
assert.NotZero(t, created.ID) assert.NotZero(t, created.ID)
}) })
@@ -64,7 +64,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
GitCommitEmail: "test@example.com", GitCommitEmail: "test@example.com",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var created models.Workspace var created models.Workspace
@@ -86,7 +86,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
// Missing required Git settings // Missing required Git settings
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
}) })
@@ -95,7 +95,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
workspace := &models.Workspace{ workspace := &models.Workspace{
Name: "Test Workspace Operations", Name: "Test Workspace Operations",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err := json.NewDecoder(rr.Body).Decode(workspace) err := json.NewDecoder(rr.Body).Decode(workspace)
require.NoError(t, err) require.NoError(t, err)
@@ -105,7 +105,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
t.Run("get workspace", func(t *testing.T) { t.Run("get workspace", func(t *testing.T) {
t.Run("successful get", func(t *testing.T) { t.Run("successful get", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var got models.Workspace var got models.Workspace
@@ -116,13 +116,13 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
}) })
t.Run("nonexistent workspace", func(t *testing.T) { t.Run("nonexistent workspace", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/nonexistent", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/nonexistent", nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
t.Run("unauthorized access", func(t *testing.T) { t.Run("unauthorized access", func(t *testing.T) {
// Try accessing with another user's token // Try accessing with another user's token
rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.AdminToken, nil) rr := h.makeRequest(t, http.MethodGet, baseURL, nil, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
}) })
@@ -131,7 +131,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
t.Run("update name", func(t *testing.T) { t.Run("update name", func(t *testing.T) {
workspace.Name = "Updated Workspace" workspace.Name = "Updated Workspace"
rr := h.makeRequest(t, http.MethodPut, baseURL, workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, baseURL, workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var updated models.Workspace var updated models.Workspace
@@ -152,7 +152,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
ShowHiddenFiles: true, ShowHiddenFiles: true,
} }
rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var updated models.Workspace var updated models.Workspace
@@ -176,7 +176,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
GitCommitEmail: "test@example.com", GitCommitEmail: "test@example.com",
} }
rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var updated models.Workspace var updated models.Workspace
@@ -200,14 +200,14 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
// Missing required Git settings // Missing required Git settings
} }
rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, baseURL, update, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
}) })
t.Run("last workspace", func(t *testing.T) { t.Run("last workspace", func(t *testing.T) {
t.Run("get last workspace", func(t *testing.T) { t.Run("get last workspace", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -225,11 +225,11 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
WorkspaceName: workspace.Name, WorkspaceName: workspace.Name,
} }
rr := h.makeRequest(t, http.MethodPut, "/api/v1/workspaces/last", req, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPut, "/api/v1/workspaces/last", req, h.RegularTestUser)
require.Equal(t, http.StatusNoContent, rr.Code) require.Equal(t, http.StatusNoContent, rr.Code)
// Verify the update // Verify the update
rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -243,7 +243,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
t.Run("delete workspace", func(t *testing.T) { t.Run("delete workspace", func(t *testing.T) {
// Get current workspaces to know how many we have // Get current workspaces to know how many we have
rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodGet, "/api/v1/workspaces", nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var existingWorkspaces []*models.Workspace var existingWorkspaces []*models.Workspace
@@ -254,13 +254,13 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
newWorkspace := &models.Workspace{ newWorkspace := &models.Workspace{
Name: "Workspace To Delete", Name: "Workspace To Delete",
} }
rr = h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", newWorkspace, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", newWorkspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
err = json.NewDecoder(rr.Body).Decode(newWorkspace) err = json.NewDecoder(rr.Body).Decode(newWorkspace)
require.NoError(t, err) require.NoError(t, err)
t.Run("successful delete", func(t *testing.T) { t.Run("successful delete", func(t *testing.T) {
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(newWorkspace.Name), nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(newWorkspace.Name), nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
var response struct { var response struct {
@@ -271,7 +271,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
assert.NotEmpty(t, response.NextWorkspaceName) assert.NotEmpty(t, response.NextWorkspaceName)
// Verify workspace is deleted // Verify workspace is deleted
rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/"+url.PathEscape(newWorkspace.Name), nil, h.RegularToken, nil) rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/"+url.PathEscape(newWorkspace.Name), nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
@@ -279,13 +279,13 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
// Delete all but one workspace // Delete all but one workspace
for i := 0; i < len(existingWorkspaces)-1; i++ { for i := 0; i < len(existingWorkspaces)-1; i++ {
ws := existingWorkspaces[i] ws := existingWorkspaces[i]
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(ws.Name), nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(ws.Name), nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
} }
// Try to delete the last remaining workspace // Try to delete the last remaining workspace
lastWs := existingWorkspaces[len(existingWorkspaces)-1] lastWs := existingWorkspaces[len(existingWorkspaces)-1]
rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(lastWs.Name), nil, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(lastWs.Name), nil, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
@@ -294,11 +294,11 @@ func TestWorkspaceHandlers_Integration(t *testing.T) {
workspace := &models.Workspace{ workspace := &models.Workspace{
Name: "Unauthorized Delete Test", Name: "Unauthorized Delete Test",
} }
rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularToken, nil) rr := h.makeRequest(t, http.MethodPost, "/api/v1/workspaces", workspace, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, http.StatusOK, rr.Code)
// Try to delete with wrong user's token // Try to delete with wrong user's token
rr = h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(workspace.Name), nil, h.AdminToken, nil) rr = h.makeRequest(t, http.MethodDelete, "/api/v1/workspaces/"+url.PathEscape(workspace.Name), nil, h.AdminTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
}) })