diff --git a/packages/git-proxy-cli/index.ts b/packages/git-proxy-cli/index.ts index 31ebc8a4c..10f13af57 100644 --- a/packages/git-proxy-cli/index.ts +++ b/packages/git-proxy-cli/index.ts @@ -230,8 +230,9 @@ async function authoriseGitPush(id: string) { /** * Reject git push by ID * @param {string} id The ID of the git push to reject + * @param {string} reason The reason for rejecting the push */ -async function rejectGitPush(id: string) { +async function rejectGitPush(id: string, reason: string) { if (!fs.existsSync(GIT_PROXY_COOKIE_FILE)) { console.error('Error: Reject: Authentication required'); process.exitCode = 1; @@ -247,7 +248,11 @@ async function rejectGitPush(id: string) { await axios.post( `${baseUrl}/api/v1/push/${id}/reject`, - {}, + { + params: { + reason, + }, + }, { headers: { Cookie: cookies }, }, @@ -261,13 +266,17 @@ async function rejectGitPush(id: string) { if (error.response) { switch (error.response.status) { + case 400: + errorMessage = `Error: Reject: ${error.response.data.message}`; + process.exitCode = 3; + break; case 401: errorMessage = 'Error: Reject: Authentication required'; - process.exitCode = 3; + process.exitCode = 4; break; case 404: errorMessage = `Error: Reject: ID: '${id}': Not Found`; - process.exitCode = 4; + process.exitCode = 5; } } console.error(errorMessage); @@ -553,9 +562,14 @@ yargs(hideBin(process.argv)) // eslint-disable-line @typescript-eslint/no-unused demandOption: true, type: 'string', }, + reason: { + describe: 'Reason for rejecting the push', + demandOption: true, + type: 'string', + }, }, handler(argv) { - rejectGitPush(argv.id); + rejectGitPush(argv.id, argv.reason); }, }) .command({ diff --git a/packages/git-proxy-cli/test/testCli.test.ts b/packages/git-proxy-cli/test/testCli.test.ts index 3e5545d1f..31361ed23 100644 --- a/packages/git-proxy-cli/test/testCli.test.ts +++ b/packages/git-proxy-cli/test/testCli.test.ts @@ -441,7 +441,7 @@ describe('test git-proxy-cli', function () { } const id = GHOST_PUSH_ID; - const cli = `${CLI_PATH} reject --id ${id}`; + const cli = `${CLI_PATH} reject --id ${id} --reason "Test rejection"`; const expectedExitCode = 2; const expectedMessages = null; const expectedErrorMessages = ['Error: Reject:']; @@ -452,7 +452,7 @@ describe('test git-proxy-cli', function () { await helper.removeCookiesFile(); const id = GHOST_PUSH_ID; - const cli = `${CLI_PATH} reject --id ${id}`; + const cli = `${CLI_PATH} reject --id ${id} --reason "Test rejection"`; const expectedExitCode = 1; const expectedMessages = null; const expectedErrorMessages = ['Error: Reject: Authentication required']; @@ -464,8 +464,8 @@ describe('test git-proxy-cli', function () { await helper.createCookiesFileWithExpiredCookie(); await helper.startServer(); const id = pushId; - const cli = `${CLI_PATH} reject --id ${id}`; - const expectedExitCode = 3; + const cli = `${CLI_PATH} reject --id ${id} --reason "Test rejection"`; + const expectedExitCode = 4; const expectedMessages = null; const expectedErrorMessages = ['Error: Reject: Authentication required']; await helper.runCli(cli, expectedExitCode, expectedMessages, expectedErrorMessages); @@ -480,8 +480,8 @@ describe('test git-proxy-cli', function () { await helper.runCli(`${CLI_PATH} login --username admin --password admin`); const id = GHOST_PUSH_ID; - const cli = `${CLI_PATH} reject --id ${id}`; - const expectedExitCode = 4; + const cli = `${CLI_PATH} reject --id ${id} --reason "Test rejection"`; + const expectedExitCode = 5; const expectedMessages = null; const expectedErrorMessages = [`Error: Reject: ID: '${id}': Not Found`]; await helper.runCli(cli, expectedExitCode, expectedMessages, expectedErrorMessages); @@ -757,7 +757,7 @@ describe('test git-proxy-cli', function () { let expectedErrorMessages = null; await helper.runCli(cli, expectedExitCode, expectedMessages, expectedErrorMessages); - cli = `${CLI_PATH} reject --id ${pushId}`; + cli = `${CLI_PATH} reject --id ${pushId} --reason "Test rejection"`; expectedExitCode = 0; expectedMessages = [`Reject: ID: '${pushId}': OK`]; expectedErrorMessages = null; diff --git a/src/db/file/pushes.ts b/src/db/file/pushes.ts index 2875b87f1..4398fc587 100644 --- a/src/db/file/pushes.ts +++ b/src/db/file/pushes.ts @@ -112,7 +112,7 @@ export const authorise = async (id: string, attestation: any): Promise<{ message return { message: `authorised ${id}` }; }; -export const reject = async (id: string, attestation: any): Promise<{ message: string }> => { +export const reject = async (id: string, rejection: any): Promise<{ message: string }> => { const action = await getPush(id); if (!action) { throw new Error(`push ${id} not found`); @@ -121,7 +121,7 @@ export const reject = async (id: string, attestation: any): Promise<{ message: s action.authorised = false; action.canceled = false; action.rejected = true; - action.attestation = attestation; + action.rejection = rejection; await writeAudit(action); return { message: `reject ${id}` }; }; diff --git a/src/db/index.ts b/src/db/index.ts index d44b79f3c..ecd5bd111 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -149,8 +149,8 @@ export const deletePush = (id: string): Promise => sink.deletePush(id); export const authorise = (id: string, attestation: any): Promise<{ message: string }> => sink.authorise(id, attestation); export const cancel = (id: string): Promise<{ message: string }> => sink.cancel(id); -export const reject = (id: string, attestation: any): Promise<{ message: string }> => - sink.reject(id, attestation); +export const reject = (id: string, rejection: any): Promise<{ message: string }> => + sink.reject(id, rejection); export const getRepos = (query?: Partial): Promise => sink.getRepos(query); export const getRepo = (name: string): Promise => sink.getRepo(name); export const getRepoByUrl = (url: string): Promise => sink.getRepoByUrl(url); diff --git a/src/db/mongo/pushes.ts b/src/db/mongo/pushes.ts index 968b2858a..eedd87e5a 100644 --- a/src/db/mongo/pushes.ts +++ b/src/db/mongo/pushes.ts @@ -63,7 +63,7 @@ export const writeAudit = async (action: Action): Promise => { await collection.updateOne({ id: data.id }, { $set: data }, options); }; -export const authorise = async (id: string, attestation: any): Promise<{ message: string }> => { +export const authorise = async (id: string, rejection: any): Promise<{ message: string }> => { const action = await getPush(id); if (!action) { throw new Error(`push ${id} not found`); @@ -72,7 +72,7 @@ export const authorise = async (id: string, attestation: any): Promise<{ message action.authorised = true; action.canceled = false; action.rejected = false; - action.attestation = attestation; + action.rejection = rejection; await writeAudit(action); return { message: `authorised ${id}` }; }; diff --git a/src/db/types.ts b/src/db/types.ts index e4ae2eab5..e43aff295 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -98,7 +98,7 @@ export interface Sink { deletePush: (id: string) => Promise; authorise: (id: string, attestation: any) => Promise<{ message: string }>; cancel: (id: string) => Promise<{ message: string }>; - reject: (id: string, attestation: any) => Promise<{ message: string }>; + reject: (id: string, rejection: any) => Promise<{ message: string }>; getRepos: (query?: Partial) => Promise; getRepo: (name: string) => Promise; getRepoByUrl: (url: string) => Promise; diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index d9ea96feb..8c6303e9c 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -1,6 +1,6 @@ import { processGitURLForNameAndOrg, processUrlPath } from '../routes/helper'; import { Step } from './Step'; -import { Attestation, CommitData } from '../processors/types'; +import { Attestation, CommitData, Rejection } from '../processors/types'; /** * Class representing a Push. @@ -34,6 +34,7 @@ class Action { user?: string; userEmail?: string; attestation?: Attestation; + rejection?: Rejection; lastStep?: Step; proxyGitPath?: string; newIdxFiles?: string[]; diff --git a/src/proxy/processors/types.ts b/src/proxy/processors/types.ts index c4c447b5d..868dd9928 100644 --- a/src/proxy/processors/types.ts +++ b/src/proxy/processors/types.ts @@ -19,6 +19,14 @@ export type Attestation = { questions: Question[]; }; +export type Rejection = { + reviewer: { + username: string; + }; + timestamp: string | Date; + reason: string; +}; + export type CommitContent = { item: number; type: number; diff --git a/src/routes.tsx b/src/routes.tsx index 580c39b70..d7370ea79 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -19,7 +19,7 @@ import React from 'react'; import RouteGuard from './ui/components/RouteGuard/RouteGuard'; import Person from '@material-ui/icons/Person'; -import OpenPushRequests from './ui/views/OpenPushRequests/OpenPushRequests'; +import PushList from './ui/views/Dashboard/Dashboard'; import PushDetails from './ui/views/PushDetails/PushDetails'; import User from './ui/views/User/UserProfile'; import UserList from './ui/views/UserList/UserList'; @@ -55,9 +55,7 @@ const dashboardRoutes: Route[] = [ path: '/push', name: 'Dashboard', icon: Dashboard, - component: (props) => ( - - ), + component: (props) => , layout: '/dashboard', visible: true, }, diff --git a/src/service/routes/push.ts b/src/service/routes/push.ts index d1c2fae2c..2e658fd4a 100644 --- a/src/service/routes/push.ts +++ b/src/service/routes/push.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from 'express'; import * as db from '../../db'; import { PushQuery } from '../../db/types'; +import { convertIgnorePatternToMinimatch } from '@eslint/compat'; const router = express.Router(); @@ -43,6 +44,14 @@ router.post('/:id/reject', async (req: Request, res: Response) => { return; } + const reason = req.body.params?.reason; + if (!reason || reason.trim().length === 0) { + res.status(400).send({ + message: 'Rejection reason is required', + }); + return; + } + const id = req.params.id; const { username } = req.user as { username: string }; @@ -71,8 +80,27 @@ router.post('/:id/reject', async (req: Request, res: Response) => { const isAllowed = await db.canUserApproveRejectPush(id, username); if (isAllowed) { - const result = await db.reject(id, null); - console.log(`user ${username} rejected push request for ${id}`); + console.log(`user ${username} rejected push request for ${id} with reason: ${reason}`); + + const reviewerList = await db.getUsers({ username }); + const reviewerEmail = reviewerList[0].email; + + if (!reviewerEmail) { + res.status(401).send({ + message: `There was no registered email address for the reviewer: ${username}`, + }); + return; + } + + const rejection = { + reason, + timestamp: new Date(), + reviewer: { + username, + reviewerEmail, + }, + }; + const result = await db.reject(id, rejection); res.send(result); } else { res.status(401).send({ diff --git a/src/ui/components/CustomTabs/CustomTabs.tsx b/src/ui/components/CustomTabs/CustomTabs.tsx index 6a9211ecf..2c9c00aa0 100644 --- a/src/ui/components/CustomTabs/CustomTabs.tsx +++ b/src/ui/components/CustomTabs/CustomTabs.tsx @@ -16,7 +16,7 @@ type HeaderColor = 'warning' | 'success' | 'danger' | 'info' | 'primary' | 'rose export type TabItem = { tabName: string; tabIcon?: React.ComponentType; - tabContent: React.ReactNode; + tabContent?: React.ReactNode; }; interface CustomTabsProps { @@ -25,6 +25,9 @@ interface CustomTabsProps { tabs: TabItem[]; rtlActive?: boolean; plainTabs?: boolean; + defaultTab?: number; + value?: number; + onChange?: (value: number) => void; } const CustomTabs: React.FC = ({ @@ -33,12 +36,20 @@ const CustomTabs: React.FC = ({ tabs, title, rtlActive = false, + defaultTab = 0, + value: controlledValue, + onChange: controlledOnChange, }) => { - const [value, setValue] = useState(0); + const [internalValue, setInternalValue] = useState(defaultTab); const classes = useStyles(); + const value = controlledValue !== undefined ? controlledValue : internalValue; const handleChange = (event: React.ChangeEvent, newValue: number) => { - setValue(newValue); + if (controlledOnChange) { + controlledOnChange(newValue); + } else { + setInternalValue(newValue); + } }; const cardTitle = clsx({ @@ -80,7 +91,7 @@ const CustomTabs: React.FC = ({ {tabs.map((prop, key) => ( -
+
{prop.tabContent}
))} diff --git a/src/ui/services/git-push.ts b/src/ui/services/git-push.ts index 3de0dac4d..c70633134 100644 --- a/src/ui/services/git-push.ts +++ b/src/ui/services/git-push.ts @@ -30,86 +30,54 @@ const getPush = async ( }; const getPushes = async ( - setIsLoading: (isLoading: boolean) => void, - setPushes: (pushes: PushActionView[]) => void, - setAuth: (auth: boolean) => void, - setIsError: (isError: boolean) => void, - setErrorMessage: (errorMessage: string) => void, query = { blocked: true, canceled: false, authorised: false, rejected: false, }, -): Promise => { +): Promise => { const url = new URL(`${API_V1_BASE}/push`); url.search = new URLSearchParams(query as any).toString(); - setIsLoading(true); - - try { - const response = await axios(url.toString(), getAxiosConfig()); - setPushes(response.data as PushActionView[]); - } catch (error: any) { - setIsError(true); - - if (error.response?.status === 401) { - setAuth(false); - setErrorMessage(processAuthError(error)); - } else { - const message = error.response?.data?.message || error.message; - setErrorMessage(`Error fetching pushes: ${message}`); - } - } finally { - setIsLoading(false); - } + const response = await axios(url.toString(), getAxiosConfig()); + return response.data as PushActionView[]; }; const authorisePush = async ( id: string, setMessage: (message: string) => void, - setUserAllowedToApprove: (userAllowedToApprove: boolean) => void, attestation: Array<{ label: string; checked: boolean }>, -): Promise => { +): Promise => { const url = `${API_V1_BASE}/push/${id}/authorise`; let errorMsg = ''; - let isUserAllowedToApprove = true; - await axios - .post( - url, - { - params: { - attestation, - }, - }, - getAxiosConfig(), - ) - .catch((error: any) => { - if (error.response && error.response.status === 401) { - errorMsg = 'You are not authorised to approve...'; - isUserAllowedToApprove = false; - } - }); + let success = true; + await axios.post(url, { params: { attestation } }, getAxiosConfig()).catch((error: any) => { + if (error.response && error.response.status === 401) { + errorMsg = 'You are not authorised to approve...'; + success = false; + } + }); setMessage(errorMsg); - setUserAllowedToApprove(isUserAllowedToApprove); + return success; }; const rejectPush = async ( id: string, setMessage: (message: string) => void, - setUserAllowedToReject: (userAllowedToReject: boolean) => void, -): Promise => { + rejection: { reason: string }, +): Promise => { const url = `${API_V1_BASE}/push/${id}/reject`; let errorMsg = ''; - let isUserAllowedToReject = true; - await axios.post(url, {}, getAxiosConfig()).catch((error: any) => { + let success = true; + await axios.post(url, { params: rejection }, getAxiosConfig()).catch((error: any) => { if (error.response && error.response.status === 401) { errorMsg = 'You are not authorised to reject...'; - isUserAllowedToReject = false; + success = false; } }); setMessage(errorMsg); - setUserAllowedToReject(isUserAllowedToReject); + return success; }; const cancelPush = async ( diff --git a/src/ui/views/Dashboard/Dashboard.tsx b/src/ui/views/Dashboard/Dashboard.tsx new file mode 100644 index 000000000..cd05992d4 --- /dev/null +++ b/src/ui/views/Dashboard/Dashboard.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Block, Cancel, CheckCircle, Error, List, Visibility } from '@material-ui/icons'; +import GridItem from '../../components/Grid/GridItem'; +import GridContainer from '../../components/Grid/GridContainer'; +import CustomTabs, { TabItem } from '../../components/CustomTabs/CustomTabs'; +import Danger from '../../components/Typography/Danger'; +import Search from '../../components/Search/Search'; +import { PushActionView } from '../../types'; +import { getPushes } from '../../services/git-push'; +import PushesTable from './components/PushesTable'; + +const PENDING_TAB = 1; +const tabs: TabItem[] = [ + { + tabName: 'All', + tabIcon: List, + }, + { + tabName: 'Pending', + tabIcon: Visibility, + }, + { + tabName: 'Approved', + tabIcon: CheckCircle, + }, + { + tabName: 'Canceled', + tabIcon: Cancel, + }, + { + tabName: 'Rejected', + tabIcon: Block, + }, + { + tabName: 'Error', + tabIcon: Error, + }, +]; + +const getQueryForTab = (tabName: string): any => { + switch (tabName) { + case 'All': + return {}; + case 'Pending': + return { blocked: true, authorised: false, rejected: false, canceled: false }; + case 'Approved': + return { authorised: true }; + case 'Canceled': + return { authorized: false, rejected: false, canceled: true }; + case 'Rejected': + return { authorised: false, rejected: true, canceled: false }; + case 'Error': + return { error: true }; + default: + return {}; + } +}; + +const Dashboard: React.FC = () => { + const [currentTab, setCurrentTab] = useState(PENDING_TAB); + const [errorMessage, setErrorMessage] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [pushes, setPushes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const loadPushes = async () => { + setIsLoading(true); + try { + const query = getQueryForTab(tabs[currentTab].tabName); + setPushes(await getPushes(query)); + } finally { + setIsLoading(false); + } + }; + loadPushes(); + }, [currentTab]); + + const filteredPushes = useMemo(() => { + if (!searchTerm) return pushes; + const lowerCaseTerm = searchTerm.toLowerCase(); + return pushes.filter( + (item) => + item.repo.toLowerCase().includes(lowerCaseTerm) || + item.commitTo?.toLowerCase().includes(lowerCaseTerm) || + item.commitData?.[0]?.message.toLowerCase().includes(lowerCaseTerm), + ); + }, [pushes, searchTerm]); + + const onTab = (newTab: number) => { + setCurrentTab(newTab); + }; + + const onSearch = (term: string) => setSearchTerm(term.trim()); + + if (isLoading) { + return
Loading...
; + } + + return ( +
+ {errorMessage && {errorMessage}} + {!errorMessage && ( + + + + + + + + )} +
+ ); +}; + +export default Dashboard; diff --git a/src/ui/views/OpenPushRequests/components/PushesTable.tsx b/src/ui/views/Dashboard/components/PushesTable.tsx similarity index 74% rename from src/ui/views/OpenPushRequests/components/PushesTable.tsx rename to src/ui/views/Dashboard/components/PushesTable.tsx index 83cc90be9..d947bd0df 100644 --- a/src/ui/views/OpenPushRequests/components/PushesTable.tsx +++ b/src/ui/views/Dashboard/components/PushesTable.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { makeStyles } from '@material-ui/core/styles'; import moment from 'moment'; import { useNavigate } from 'react-router-dom'; @@ -11,63 +11,24 @@ import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; import Paper from '@material-ui/core/Paper'; import styles from '../../../assets/jss/material-dashboard-react/views/dashboardStyle'; -import { getPushes } from '../../../services/git-push'; import { KeyboardArrowRight } from '@material-ui/icons'; -import Search from '../../../components/Search/Search'; import Pagination from '../../../components/Pagination/Pagination'; import { PushActionView } from '../../../types'; import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../../../db/helper'; import { generateAuthorLinks, generateEmailLink } from '../../../utils'; interface PushesTableProps { - [key: string]: any; + pushes: PushActionView[]; } const useStyles = makeStyles(styles as any); -const PushesTable: React.FC = (props) => { +const PushesTable: React.FC = ({ pushes }) => { const classes = useStyles(); - const [pushes, setPushes] = useState([]); - const [filteredData, setFilteredData] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [, setIsError] = useState(false); const navigate = useNavigate(); - const [, setAuth] = useState(true); const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 5; - const [searchTerm, setSearchTerm] = useState(''); - const openPush = (pushId: string) => navigate(`/dashboard/push/${pushId}`, { replace: true }); - - useEffect(() => { - const query = { - blocked: props.blocked ?? false, - canceled: props.canceled ?? false, - authorised: props.authorised ?? false, - rejected: props.rejected ?? false, - }; - getPushes(setIsLoading, setPushes, setAuth, setIsError, props.handleError, query); - }, [props]); - - useEffect(() => { - setFilteredData(pushes); - }, [pushes]); - - useEffect(() => { - const lowerCaseTerm = searchTerm.toLowerCase(); - const filtered = searchTerm - ? pushes.filter( - (item) => - item.repo.toLowerCase().includes(lowerCaseTerm) || - item.commitTo?.toLowerCase().includes(lowerCaseTerm) || - item.commitData?.[0]?.message.toLowerCase().includes(lowerCaseTerm), - ) - : pushes; - setFilteredData(filtered); - setCurrentPage(1); - }, [searchTerm, pushes]); - - const handleSearch = (term: string) => setSearchTerm(term.trim()); + const itemsPerPage = 11; const handlePageChange = (page: number) => { setCurrentPage(page); @@ -75,13 +36,10 @@ const PushesTable: React.FC = (props) => { const indexOfLastItem = currentPage * itemsPerPage; const indexOfFirstItem = indexOfLastItem - itemsPerPage; - const currentItems = filteredData.slice(indexOfFirstItem, indexOfLastItem); - - if (isLoading) return
Loading...
; + const currentItems = pushes.slice(indexOfFirstItem, indexOfLastItem); return (
- @@ -133,8 +91,8 @@ const PushesTable: React.FC = (props) => { - {/* render github/gitlab profile links in future - {getUserProfileLink(row.commitData[0].committerEmail, gitProvider, hostname)} + {/* render github/gitlab profile links in future + {getUserProfileLink(row.commitData[0].committerEmail, gitProvider, hostname)} */} {generateEmailLink( row.commitData?.[0]?.committer ?? '', @@ -142,8 +100,8 @@ const PushesTable: React.FC = (props) => { )} - {/* render github/gitlab profile links in future - {getUserProfileLink(row.commitData[0].authorEmail, gitProvider, hostname)} + {/* render github/gitlab profile links in future + {getUserProfileLink(row.commitData[0].authorEmail, gitProvider, hostname)} */} {generateAuthorLinks(row.commitData ?? [])} @@ -162,7 +120,7 @@ const PushesTable: React.FC = (props) => { diff --git a/src/ui/views/OpenPushRequests/OpenPushRequests.tsx b/src/ui/views/OpenPushRequests/OpenPushRequests.tsx deleted file mode 100644 index 41c2672a8..000000000 --- a/src/ui/views/OpenPushRequests/OpenPushRequests.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useState } from 'react'; -import GridItem from '../../components/Grid/GridItem'; -import GridContainer from '../../components/Grid/GridContainer'; -import PushesTable from './components/PushesTable'; -import CustomTabs from '../../components/CustomTabs/CustomTabs'; -import Danger from '../../components/Typography/Danger'; -import { Visibility, CheckCircle, Cancel, Block } from '@material-ui/icons'; -import { TabItem } from '../../components/CustomTabs/CustomTabs'; - -const Dashboard: React.FC = () => { - const [errorMessage, setErrorMessage] = useState(null); - - const handlePushTableError = (errorMessage: string) => { - setErrorMessage(errorMessage); - }; - - const tabs: TabItem[] = [ - { - tabName: 'Pending', - tabIcon: Visibility, - tabContent: ( - - ), - }, - { - tabName: 'Approved', - tabIcon: CheckCircle, - tabContent: , - }, - { - tabName: 'Canceled', - tabIcon: Cancel, - tabContent: ( - - ), - }, - { - tabName: 'Rejected', - tabIcon: Block, - tabContent: ( - - ), - }, - ]; - - return ( -
- {errorMessage && {errorMessage}} - {!errorMessage && ( - - - - - - )} -
- ); -}; - -export default Dashboard; diff --git a/src/ui/views/PushDetails/PushDetails.tsx b/src/ui/views/PushDetails/PushDetails.tsx index aec01fa20..355d33e45 100644 --- a/src/ui/views/PushDetails/PushDetails.tsx +++ b/src/ui/views/PushDetails/PushDetails.tsx @@ -1,31 +1,24 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import moment from 'moment'; import { useNavigate, useParams } from 'react-router-dom'; -import Icon from '@material-ui/core/Icon'; import GridItem from '../../components/Grid/GridItem'; import GridContainer from '../../components/Grid/GridContainer'; import Card from '../../components/Card/Card'; -import CardIcon from '../../components/Card/CardIcon'; import CardBody from '../../components/Card/CardBody'; import CardHeader, { CardHeaderColor } from '../../components/Card/CardHeader'; import CardFooter from '../../components/Card/CardFooter'; -import Button from '../../components/CustomButtons/Button'; import Diff from './components/Diff'; -import Attestation from './components/Attestation'; -import AttestationView from './components/AttestationView'; import Table from '@material-ui/core/Table'; import TableBody from '@material-ui/core/TableBody'; import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; import TableCell from '@material-ui/core/TableCell'; import { getPush, authorisePush, rejectPush, cancelPush } from '../../services/git-push'; -import { CheckCircle, Visibility, Cancel, Block } from '@material-ui/icons'; import Snackbar from '@material-ui/core/Snackbar'; -import Tooltip from '@material-ui/core/Tooltip'; -import { AttestationFormData, PushActionView } from '../../types'; +import { PushActionView } from '../../types'; import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../../db/helper'; -import { generateEmailLink, getGitProvider } from '../../utils'; -import UserLink from '../../components/UserLink/UserLink'; +import { generateEmailLink } from '../../utils'; +import PushStatusHeader from './components/PushStatusHeader'; const Dashboard: React.FC = () => { const { id } = useParams<{ id: string }>(); @@ -34,20 +27,8 @@ const Dashboard: React.FC = () => { const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); const [message, setMessage] = useState(''); - const [attestation, setAttestation] = useState(false); const navigate = useNavigate(); - let isUserAllowedToApprove = true; - let isUserAllowedToReject = true; - - const setUserAllowedToApprove = (userAllowedToApprove: boolean) => { - isUserAllowedToApprove = userAllowedToApprove; - }; - - const setUserAllowedToReject = (userAllowedToReject: boolean) => { - isUserAllowedToReject = userAllowedToReject; - }; - useEffect(() => { if (id) { getPush(id, setIsLoading, setPush, setAuth, setIsError); @@ -56,16 +37,14 @@ const Dashboard: React.FC = () => { const authorise = async (attestationData: Array<{ label: string; checked: boolean }>) => { if (!id) return; - await authorisePush(id, setMessage, setUserAllowedToApprove, attestationData); - if (isUserAllowedToApprove) { + if (await authorisePush(id, setMessage, attestationData)) { navigate('/dashboard/push/'); } }; - const reject = async () => { + const reject = async (rejectionData: { reason: string }) => { if (!id) return; - await rejectPush(id, setMessage, setUserAllowedToReject); - if (isUserAllowedToReject) { + if (await rejectPush(id, setMessage, rejectionData)) { navigate('/dashboard/push/'); } }; @@ -110,23 +89,6 @@ const Dashboard: React.FC = () => { const repoBranch = trimPrefixRefsHeads(push.branch ?? ''); const repoUrl = push.url; const repoWebUrl = trimTrailingDotGit(repoUrl); - const gitProvider = getGitProvider(repoUrl); - const isGitHub = gitProvider == 'github'; - - const generateIcon = (title: string) => { - switch (title) { - case 'Approved': - return ; - case 'Pending': - return ; - case 'Canceled': - return ; - case 'Rejected': - return ; - default: - return ; - } - }; return (
@@ -143,102 +105,12 @@ const Dashboard: React.FC = () => { - - - {generateIcon(headerData.title)} -

{headerData.title}

-
- {!(push.canceled || push.rejected || push.authorised) && ( -
- - - -
- )} - {push.attestation && push.authorised && ( -
- - { - if (!push.autoApproved) { - setAttestation(true); - } - }} - htmlColor='green' - /> - - - {push.autoApproved ? ( -
-

- Auto-approved by system -

-
- ) : ( - <> - {isGitHub && ( - - - - )} -
-

- {isGitHub && ( - - {push.attestation.reviewer.gitAccount} - - )} - {!isGitHub && }{' '} - approved this contribution -

-
- - )} - - - - {moment(push.attestation.timestamp).fromNow()} - - - - {!push.autoApproved && ( - - )} -
- )} -
+ diff --git a/src/ui/views/PushDetails/components/ApprovalBadge.tsx b/src/ui/views/PushDetails/components/ApprovalBadge.tsx new file mode 100644 index 000000000..24b13ffcc --- /dev/null +++ b/src/ui/views/PushDetails/components/ApprovalBadge.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import moment from 'moment/moment'; +import { CheckCircle } from '@material-ui/icons'; +import Tooltip from '@material-ui/core/Tooltip'; +import AttestationView from './AttestationView'; +import { AttestationFormData } from '../../../types'; +import { Attestation } from '../../../../proxy/processors/types'; +import UserLink from '../../../components/UserLink/UserLink'; + +interface ApprovalBadgeProps { + attestation: Attestation; + autoApproved?: boolean; +} + +const ApprovalBadge: React.FC = ({ attestation, autoApproved }) => { + const [attestationOpen, setAttestationOpen] = React.useState(false); + + return ( +
+ + { + if (!autoApproved) { + setAttestationOpen(true); + } + }} + htmlColor='green' + /> + + + {autoApproved ? ( +
+

+ Auto-approved by system +

+
+ ) : ( +
+

+ approved this contribution +

+
+ )} + + + + {moment(attestation.timestamp).fromNow()} + + + + {!autoApproved && ( + + )} +
+ ); +}; + +export default ApprovalBadge; diff --git a/src/ui/views/PushDetails/components/PushStatusHeader.tsx b/src/ui/views/PushDetails/components/PushStatusHeader.tsx new file mode 100644 index 000000000..324aee729 --- /dev/null +++ b/src/ui/views/PushDetails/components/PushStatusHeader.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import CardHeader, { CardHeaderColor } from '../../../components/Card/CardHeader'; +import { StatusIcon } from './StatusIcon'; +import Button from '../../../components/CustomButtons/Button'; +import Attestation from './Attestation'; +import ApprovalBadge from './ApprovalBadge'; +import { PushActionView } from '../../../types'; +import Rejection from './Rejection'; +import RejectionBadge from './RejectionBadge'; + +const getStatusInfo = (data: PushActionView): { title: string; color: CardHeaderColor } => { + if (data.authorised) { + return { title: 'Approved', color: 'success' }; + } + if (data.rejected) { + return { title: 'Rejected', color: 'danger' }; + } + if (data.canceled) { + return { title: 'Canceled', color: 'warning' }; + } + if (data.error) { + return { title: 'Error', color: 'danger' }; + } + return { title: 'Pending', color: 'warning' }; +}; + +interface PushStatusHeaderProps { + data: PushActionView; + onCancel: () => void; + onReject: (rejectionData: { reason: string }) => void; + onAuthorise: (attestationData: Array<{ label: string; checked: boolean }>) => void; +} + +const PushStatusHeader: React.FC = ({ + data, + onCancel, + onReject, + onAuthorise, +}) => { + const headerData = getStatusInfo(data); + const isPending = !data.canceled && !data.rejected && !data.authorised && !data.error; + + return ( + + + {isPending && ( +
+ + + +
+ )} + {data.attestation && data.authorised && ( + + )} + {data.rejection && data.rejected && } +
+ ); +}; + +export default PushStatusHeader; diff --git a/src/ui/views/PushDetails/components/Rejection.tsx b/src/ui/views/PushDetails/components/Rejection.tsx new file mode 100644 index 000000000..035229f0d --- /dev/null +++ b/src/ui/views/PushDetails/components/Rejection.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useState } from 'react'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogActions from '@material-ui/core/DialogActions'; +import TextField from '@material-ui/core/TextField'; +import { makeStyles } from '@material-ui/core/styles'; +import { Block, ErrorOutline } from '@material-ui/icons'; +import Button from '../../../components/CustomButtons/Button'; +import { setEmailContactData } from '../../../services/config'; + +const useStyles = makeStyles({ + warningBox: { + background: '#ffebee', + borderRadius: '10px', + padding: '15px 15px 5px', + color: '#c62828', + display: 'block', + }, + warningHeader: { + display: 'flex', + flexDirection: 'row', + }, + warningTitle: { + fontSize: '16px', + paddingLeft: '10px', + fontWeight: 'bold', + }, + warningText: { + fontSize: '15px', + paddingLeft: '34px', + color: '#c62828', + }, + warningLink: { + color: '#c62828', + }, +}); + +interface RejectionProps { + rejectFn: (rejectionData: { reason: string }) => void; +} + +const Rejection: React.FC = ({ rejectFn }) => { + const classes = useStyles(); + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(''); + const [contactEmail, setContactEmail] = useState(''); + + useEffect(() => { + if (open && !contactEmail) { + setEmailContactData(setContactEmail); + } + }, [open, contactEmail]); + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setReason(''); + setOpen(false); + }; + + const handleReject = () => { + rejectFn({ reason: reason.trim() }); + }; + + const isFormValid = (): boolean => reason.trim().length > 0; + + return ( +
+ + + + +
+ + + You are about to reject this contribution + +
+

+ This action will prevent this contribution from being published. +
+ Please provide a clear reason so the contributor understands why their changes were + rejected. +

+ {contactEmail && ( +

+ For assistance,{' '} + + contact the Open Source Program Office + + . +

+ )} +
+

Rejection Reason (Required)

+ setReason(e.target.value)} + required + helperText={reason.trim().length === 0 ? 'Rejection reason is required.' : ' '} + /> + + + + +
+
+
+ ); +}; + +export default Rejection; diff --git a/src/ui/views/PushDetails/components/RejectionBadge.tsx b/src/ui/views/PushDetails/components/RejectionBadge.tsx new file mode 100644 index 000000000..d9dad4945 --- /dev/null +++ b/src/ui/views/PushDetails/components/RejectionBadge.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import moment from 'moment'; +import { Block } from '@material-ui/icons'; +import Tooltip from '@material-ui/core/Tooltip'; +import { Rejection } from '../../../../proxy/processors/types'; +import UserLink from '../../../components/UserLink/UserLink'; + +interface RejectionBadgeProps { + rejection: Rejection; +} + +const RejectionBadge: React.FC = ({ rejection }) => { + return ( +
+ + + + +
+

+ rejected this contribution. +

+

+ Reason: {rejection.reason} +

+ + + + {moment(rejection.timestamp).fromNow()} + + +
+
+ ); +}; + +export default RejectionBadge; diff --git a/src/ui/views/PushDetails/components/StatusIcon.tsx b/src/ui/views/PushDetails/components/StatusIcon.tsx new file mode 100644 index 000000000..d93b1f3d8 --- /dev/null +++ b/src/ui/views/PushDetails/components/StatusIcon.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import Icon from '@material-ui/core/Icon'; +import { Block, Cancel, Error, CheckCircle, Visibility } from '@material-ui/icons'; +import CardIcon from '../../../components/Card/CardIcon'; +import { CardHeaderColor } from '../../../components/Card/CardHeader'; + +interface StatusIconProps { + status: string; + color: CardHeaderColor; +} + +export const StatusIcon: React.FC = ({ status, color }) => { + const renderIcon = () => { + switch (status) { + case 'Approved': + return ; + case 'Pending': + return ; + case 'Cancelled': + return ; + case 'Rejected': + return ; + case 'Error': + return ; + default: + return ; + } + }; + + return ( + + {renderIcon()} +

{status}

+
+ ); +}; diff --git a/test/services/routes/push.test.ts b/test/services/routes/push.test.ts new file mode 100644 index 000000000..eb2281db8 --- /dev/null +++ b/test/services/routes/push.test.ts @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import express, { Express } from 'express'; +import request from 'supertest'; +import pushRouter from '../../../src/service/routes/push'; +import * as db from '../../../src/db'; + +describe('Push API', () => { + let app: Express; + + const mockPush = { + id: 'push-id-123', + type: 'push', + url: 'https://github.com/test/repo.git', + userEmail: 'committer@example.com', + user: 'testcommitter', + cancelled: false, + rejected: false, + authorised: false, + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createApp = (username: string | undefined) => { + const app = express(); + app.use(express.json()); + // app.use(express.urlencoded({ extended: true })); + + if (username) { + app.use((req, res, next) => { + req.user = { username }; + next(); + }); + } + + app.use('/push', pushRouter); + return app; + }; + + describe('POST /:id/reject', () => { + it('should return 401 if user is not logged in', async () => { + const app = createApp(undefined); + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: 'test' } }); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ message: 'not logged in' }); + }); + + it('should return 400 if rejection reason is missing', async () => { + app = createApp('testuser'); + + const res = await request(app).post('/push/test-push-id-123/reject').send({}); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ message: 'Rejection reason is required' }); + }); + + it('should return 400 if rejection reason is empty string', async () => { + app = createApp('testuser'); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: '' } }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ message: 'Rejection reason is required' }); + }); + + it('should return 400 if rejection reason is only whitespace', async () => { + app = createApp('testuser'); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: ' ' } }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ message: 'Rejection reason is required' }); + }); + + it('should return 404 if push does not exist', async () => { + app = createApp('testuser'); + // dbStub.getPush.resolves(null); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: 'Test reason' } }); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ message: 'Push request not found' }); + }); + + it('should return 400 if push has not userEmail', async () => { + app = createApp('testuser'); + + const pushWithoutEmail = { ...mockPush, userEmail: null }; + vi.spyOn(db, 'getPush').mockResolvedValue(pushWithoutEmail as any); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: 'Test reason' } }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ message: 'Push request has no user email' }); + }); + + it('should return 401 if no registered registered user', async () => { + app = createApp('testuser'); + + vi.spyOn(db, 'getPush').mockResolvedValue(mockPush as any); + vi.spyOn(db, 'getUsers').mockResolvedValue([] as any); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: 'Test reason' } }); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ + message: + "There was no registered user with the committer's email address: committer@example.com", + }); + }); + + it('should return 401 if user tries to reject their own push', async () => { + app = createApp('testcommitter'); + + vi.spyOn(db, 'getPush').mockResolvedValue(mockPush as any); + vi.spyOn(db, 'getUsers').mockResolvedValue([ + { + username: 'testcommitter', + email: 'committer@example.com', + admin: false, + }, + ] as any); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: 'Test reason' } }); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ message: 'Cannot reject your own changes' }); + }); + + it('should allow admin to reject their own push', async () => { + app = createApp('adminuser'); + + vi.spyOn(db, 'getPush').mockResolvedValue({ + ...mockPush, + userEmail: 'admin@example.com', + } as any); + + vi.spyOn(db, 'getUsers') + .mockResolvedValueOnce([ + { + username: 'adminuser', + email: 'admin@example.com', + admin: true, + }, + ] as any) + .mockResolvedValueOnce([ + { + username: 'adminuser', + email: 'admin@example.com', + admin: true, + }, + ] as any); + + vi.spyOn(db, 'canUserApproveRejectPush').mockResolvedValue(true); + const reject = vi.spyOn(db, 'reject').mockResolvedValue({ message: 'reject test-push-123' }); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: 'Admin rejection' } }); + + expect(res.status).toBe(200); + expect(reject).toHaveBeenCalledOnce(); + }); + + it('should return 401 if user is not authorised to reject', async () => { + app = createApp('unauthorizeduser'); + + vi.spyOn(db, 'getPush').mockResolvedValue(mockPush as any); + vi.spyOn(db, 'getUsers').mockResolvedValue([ + { + username: 'testcommitter', + email: 'committer@example.com', + admin: false, + }, + ] as any); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: 'Test reason' } }); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ message: 'User is not authorised to reject changes' }); + }); + + it('should return 401 if reviewer has no email address', async () => { + app = createApp('reviewer'); + + vi.spyOn(db, 'getPush').mockResolvedValue(mockPush as any); + vi.spyOn(db, 'getUsers') + .mockResolvedValueOnce([ + { + username: 'testcommitter', + email: 'committer@example.com', + admin: false, + }, + ] as any) + .mockResolvedValueOnce([ + { + username: 'reviewer', + admin: false, + }, + ] as any); + vi.spyOn(db, 'canUserApproveRejectPush').mockResolvedValue(true); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: 'Test reason' } }); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ + message: 'There was no registered email address for the reviewer: reviewer', + }); + }); + + it('should successfully reject a push with a valid reason', async () => { + app = createApp('reviewer'); + vi.spyOn(db, 'getPush').mockResolvedValue(mockPush as any); + vi.spyOn(db, 'getUsers') + .mockResolvedValueOnce([ + { + username: 'testcommitter', + email: 'committer@example.com', + admin: false, + }, + ] as any) + .mockResolvedValueOnce([ + { + username: 'reviewer', + email: 'reviewer@example.com', + }, + ] as any); + + vi.spyOn(db, 'canUserApproveRejectPush').mockResolvedValue(true); + const reject = vi.spyOn(db, 'reject').mockResolvedValue({ message: 'reject test-push-123' }); + + const res = await request(app) + .post('/push/test-push-id-123/reject') + .send({ params: { reason: 'Test reason' } }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'reject test-push-123' }); + expect(reject).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/test/testPush.test.ts b/test/testPush.test.ts index 8e605ac60..4b36eb27a 100644 --- a/test/testPush.test.ts +++ b/test/testPush.test.ts @@ -1,5 +1,5 @@ import request from 'supertest'; -import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import * as db from '../src/db'; import service from '../src/service'; import Proxy from '../src/proxy'; @@ -268,7 +268,13 @@ describe('Push API', () => { await loginAsApprover(); const res = await request(app) .post(`/api/v1/push/${TEST_PUSH.id}/reject`) - .set('Cookie', `${cookie}`); + .set('Cookie', `${cookie}`) + .send({ + params: { + reason: 'tests did not pass', + }, + }); + expect(res.status).toBe(200); }); @@ -281,7 +287,12 @@ describe('Push API', () => { await loginAsApprover(); const res = await request(app) .post(`/api/v1/push/${TEST_PUSH.id}/reject`) - .set('Cookie', `${cookie}`); + .set('Cookie', `${cookie}`) + .send({ + params: { + reason: 'tests did not pass', + }, + }); expect(res.status).toBe(401); }); @@ -290,7 +301,13 @@ describe('Push API', () => { await loginAsCommitter(); const res = await request(app) .post(`/api/v1/push/${TEST_PUSH.id}/reject`) - .set('Cookie', `${cookie}`); + .set('Cookie', `${cookie}`) + .send({ + params: { + reason: 'tests did not pass', + }, + }); + expect(res.status).toBe(401); }); diff --git a/test/ui/services/git-push.test.ts b/test/ui/services/git-push.test.ts new file mode 100644 index 000000000..9b049aeef --- /dev/null +++ b/test/ui/services/git-push.test.ts @@ -0,0 +1,101 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; + +describe('git-push service', () => { + const originalLocation = globalThis.location; + + beforeAll(() => { + globalThis.location = { origin: 'https://lovely-git-proxy.com' } as any; + globalThis.localStorage = { + getItem: vi.fn().mockResolvedValue(null), + } as any; + globalThis.document = { + cookie: '', + } as any; + }); + + afterAll(() => { + globalThis.location = originalLocation; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('rejectPush', () => { + it('should return true when successfully rejected a push', async () => { + const axiosPostSpy = vi.spyOn(axios, 'post').mockResolvedValue({ status: 200 }); + + const { rejectPush } = await import('../../../src/ui/services/git-push'); + const setMessageSpy = vi.fn(); + + const result = await rejectPush('test-push-id-123', setMessageSpy, { + reason: 'tests do not pass', + }); + + expect(result).toBe(true); + expect(setMessageSpy).toHaveBeenCalledExactlyOnceWith(''); + expect(axiosPostSpy).toHaveBeenCalledWith( + 'https://lovely-git-proxy.com/api/v1/push/test-push-id-123/reject', + { params: { reason: 'tests do not pass' } }, + expect.any(Object), + ); + }); + + it('should return false when returns 401', async () => { + const error: any = new Error('Unauthorized'); + error.response = { status: 401 }; + const axiosPostSpy = vi.spyOn(axios, 'post').mockRejectedValue(error); + + const { rejectPush } = await import('../../../src/ui/services/git-push'); + const setMessageSpy = vi.fn(); + + const result = await rejectPush('test-push-id-456', setMessageSpy, { + reason: 'tests do not pass', + }); + + expect(result).toBe(false); + expect(setMessageSpy).toHaveBeenCalledExactlyOnceWith('You are not authorised to reject...'); + expect(axiosPostSpy).toHaveBeenCalledOnce(); + }); + }); + + describe('authorisePush', () => { + it('should return true when authorised a push', async () => { + const axiosPostSpy = vi.spyOn(axios, 'post').mockResolvedValue({ status: 200 }); + + const { authorisePush } = await import('../../../src/ui/services/git-push'); + const setMessageSpy = vi.fn(); + const attestation = [ + { label: 'Reviewed code', checked: true }, + { label: 'Verified tests', checked: true }, + ]; + + const result = await authorisePush('test-push-id-789', setMessageSpy, attestation); + + expect(result).toBe(true); + expect(setMessageSpy).toHaveBeenCalledExactlyOnceWith(''); + expect(axiosPostSpy).toHaveBeenCalledWith( + 'https://lovely-git-proxy.com/api/v1/push/test-push-id-789/authorise', + { params: { attestation } }, + expect.any(Object), + ); + }); + + it('should return false when returned 401', async () => { + const error: any = new Error('Unauthorized'); + error.response = { status: 401 }; + const axiosPostSpy = vi.spyOn(axios, 'post').mockRejectedValue(error); + + const { authorisePush } = await import('../../../src/ui/services/git-push'); + const setMessageSpy = vi.fn(); + const attestation = [{ label: 'Reviewed code', checked: true }]; + + const result = await authorisePush('test-push-id-101', setMessageSpy, attestation); + + expect(result).toBe(false); + expect(setMessageSpy).toHaveBeenCalledExactlyOnceWith('You are not authorised to approve...'); + expect(axiosPostSpy).toHaveBeenCalledOnce(); + }); + }); +});