Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# Application Settings
# ==================================
PORT=3000
NEXT_PUBLIC_APP_ENV=production
APP_HOSTNAME=voting.nthusa.tw
NEXT_PUBLIC_APP_ENV=production # Change to 'development' for local testing
APP_HOSTNAME=voting.nthusa.tw # Change to 'localhost:3000' for local testing

# ==================================
# Database Configuration
Expand All @@ -19,6 +19,7 @@ MONGO_PORT=27017
# ==================================
# Generate a strong secret: openssl rand -base64 32
TOKEN_SECRET=your_jwt_secret_here
ROOT_ADMIN=your_root_admin_student_id

# ==================================
# OAuth Configuration (NTHU CCXP)
Expand All @@ -37,4 +38,4 @@ OAUTH_SCOPE=userid name inschool uuid
OAUTH_AUTHORIZE=https://oauth.ccxp.nthu.edu.tw/v1.1/authorize.php
OAUTH_TOKEN_URL=https://oauth.ccxp.nthu.edu.tw/v1.1/token.php
OAUTH_RESOURCE_URL=https://oauth.ccxp.nthu.edu.tw/v1.1/resource.php
OAUTH_CALLBACK_URL=https://voting.nthusa.tw/callback
OAUTH_CALLBACK_URL=https://voting.nthusa.tw/callback
43 changes: 12 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ MONGO_INITDB_DATABASE=voting_sa

# Security
TOKEN_SECRET=your-strong-random-secret-here
ROOT_ADMIN=your-root-admin-student-id
# Use openssl rand -base64 32 to generate

# OAuth (CCXP Production)
Expand Down Expand Up @@ -122,34 +123,16 @@ openssl rand -base64 32

### Eligible Voters

Copy Example file to create voter list:

```bash
cp data/voterList.csv.example data/voterList.csv
```

Edit `data/voterList.csv`:

```csv
student_id
110000001
110000002
```
Each activity now has its own voter list in MongoDB.
Upload CSV from admin activity page (`/admin/activities/:id`) to overwrite that activity's eligible voter roster and update `eligible_voters_count`.

### Administrators

Copy Example file to create admin list:
Administrators are stored in MongoDB (`admins` collection).

```bash
cp data/adminList.csv.example data/adminList.csv
```

Edit `data/adminList.csv`:

```csv
student_id
110000114
```
- `ROOT_ADMIN` is configured via environment variable.
- Only `ROOT_ADMIN` can access `/admin/settings` to manage other admins.
- If `ROOT_ADMIN` is missing, root-only admin management is unavailable.

## Architecture

Expand Down Expand Up @@ -209,6 +192,7 @@ student_id

- `POST /api/votes` - Submit vote (authenticated, eligible)
- `GET /api/votes` - List votes (admin, anonymized)
- `GET /api/verify/:uuid` - Public UUID verification

### Statistics

Expand All @@ -231,9 +215,6 @@ student_id
│ └── db.ts # Database connection
│ ...
├── components/ # React components
├── data/ # CSV configuration files
│ ├── voterList.csv # Eligible voters
│ └── adminList.csv # Admin list
└── middleware.ts # Auth middleware
```

Expand Down Expand Up @@ -277,7 +258,7 @@ For local development, the system uses Mock OAuth:

- ✅ JWT authentication with HttpOnly cookies
- ✅ UUID-based vote anonymization
- ✅ Admin role verification via CSV
- ✅ Admin role verification via MongoDB + ROOT_ADMIN
- ✅ Voter eligibility validation
- ✅ Time-window enforcement
- ✅ Duplicate vote prevention
Expand All @@ -300,8 +281,8 @@ Before deploying to production:
- [ ] Configure production OAuth credentials (CCXP)
- [ ] Set up HTTPS/SSL certificates
- [ ] Configure MongoDB with authentication
- [ ] Update `data/voterList.csv` with current student roster
- [ ] Update `data/adminList.csv` with admin student IDs
- [ ] Set `ROOT_ADMIN` in environment
- [ ] Prepare activity voter roster CSV for each election and upload in admin UI
- [ ] Set `NODE_ENV=production` in environment
- [ ] Enable MongoDB backup automation
- [ ] Configure firewall rules
Expand All @@ -326,7 +307,7 @@ Before deploying to production:

**Vote submission fails**

- Verify student is in `data/voterList.csv`
- Verify student is in the activity's uploaded voter roster
- Check activity time window is valid
- Confirm student hasn't already voted

Expand Down
122 changes: 122 additions & 0 deletions __tests__/api.admins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/** @jest-environment node */

import { NextRequest, NextResponse } from "next/server";
import { GET, POST, DELETE } from "@/app/api/admins/route";
import { API_CONSTANTS } from "@/lib/constants";

jest.mock("@/lib/db", () => jest.fn());
jest.mock("@/lib/middleware", () => ({
requireAuth: jest.fn(),
createErrorResponse: (message: string, status = 400) =>
NextResponse.json({ success: false, error: message }, { status }),
createSuccessResponse: (data: unknown, status = 200) =>
NextResponse.json({ success: true, data }, { status }),
createInternalErrorResponse: (error: unknown, fallbackMessage: string) =>
NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : fallbackMessage,
},
{ status: 500 },
),
}));
jest.mock("@/lib/models/Admin", () => ({
Admin: {
find: jest.fn(),
findOneAndUpdate: jest.fn(),
findOneAndDelete: jest.fn(),
},
}));
jest.mock("@/lib/auth", () => ({
isRootAdmin: jest.fn(),
getRootAdminStudentId: jest.fn(),
}));

const middlewareMock = jest.requireMock("@/lib/middleware") as {
requireAuth: jest.Mock;
};
const adminModelMock = (
jest.requireMock("@/lib/models/Admin") as {
Admin: {
find: jest.Mock;
findOneAndUpdate: jest.Mock;
findOneAndDelete: jest.Mock;
};
}
).Admin;
const authMock = jest.requireMock("@/lib/auth") as {
isRootAdmin: jest.Mock;
getRootAdminStudentId: jest.Mock;
};

describe("/api/admins route", () => {
beforeEach(() => {
jest.clearAllMocks();
middlewareMock.requireAuth.mockResolvedValue({ student_id: "111000001" });
authMock.isRootAdmin.mockImplementation((studentId: string) =>
studentId === "111000001",
);
authMock.getRootAdminStudentId.mockReturnValue("111000001");
});

it("denies access for non-root admin user", async () => {
authMock.isRootAdmin.mockReturnValue(false);

const request = new NextRequest("http://localhost:3000/api/admins");
const response = await GET(request);
const body = await response.json();

expect(response.status).toBe(403);
expect(body.error).toBe(API_CONSTANTS.ERRORS.ADMIN_REQUIRED);
});

it("returns admin list for root admin", async () => {
adminModelMock.find.mockReturnValueOnce({
sort: () => ({
lean: async () => [{ student_id: "111000002" }],
}),
});

const request = new NextRequest("http://localhost:3000/api/admins");
const response = await GET(request);
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.root_admin).toBe("111000001");
expect(body.data.admins).toEqual([{ student_id: "111000002" }]);
});

it("upserts admin on POST", async () => {
adminModelMock.findOneAndUpdate.mockResolvedValueOnce({
student_id: "111000002",
name: "Test Admin",
});

const request = new NextRequest("http://localhost:3000/api/admins", {
method: "POST",
body: JSON.stringify({ student_id: "111000002", name: "Test Admin" }),
headers: { "content-type": "application/json" },
});
const response = await POST(request);
const body = await response.json();

expect(response.status).toBe(201);
expect(body.success).toBe(true);
expect(adminModelMock.findOneAndUpdate).toHaveBeenCalled();
});

it("returns 404 when deleting a non-existent admin", async () => {
adminModelMock.findOneAndDelete.mockResolvedValueOnce(null);

const request = new NextRequest(
"http://localhost:3000/api/admins?student_id=111000009",
{ method: "DELETE" },
);
const response = await DELETE(request);
const body = await response.json();

expect(response.status).toBe(404);
expect(body.error).toBe("Admin not found");
});
});
127 changes: 127 additions & 0 deletions __tests__/api.verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/** @jest-environment node */

import { NextRequest, NextResponse } from "next/server";
import { GET } from "@/app/api/verify/[token]/route";

jest.mock("@/lib/db", () => jest.fn());
jest.mock("@/lib/models/Vote", () => ({
Vote: {
findOne: jest.fn(),
},
}));
jest.mock("@/lib/models/Activity", () => ({
Activity: {
findById: jest.fn(),
},
}));
jest.mock("@/lib/models/Option", () => ({
Option: {
find: jest.fn(),
},
}));
jest.mock("@/lib/middleware", () => ({
createErrorResponse: (message: string, status = 400) =>
NextResponse.json({ success: false, error: message }, { status }),
createSuccessResponse: (data: unknown, status = 200) =>
NextResponse.json({ success: true, data }, { status }),
createInternalErrorResponse: (error: unknown, fallbackMessage: string) =>
NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : fallbackMessage,
},
{ status: 500 },
),
}));

const voteModelMock = (
jest.requireMock("@/lib/models/Vote") as {
Vote: {
findOne: jest.Mock;
};
}
).Vote;

const activityModelMock = (
jest.requireMock("@/lib/models/Activity") as {
Activity: {
findById: jest.Mock;
};
}
).Activity;

const optionModelMock = (
jest.requireMock("@/lib/models/Option") as {
Option: {
find: jest.Mock;
};
}
).Option;

describe("/api/verify/[token] route", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("returns 400 when UUID is missing", async () => {
const request = new NextRequest("http://localhost:3000/api/verify/%20");
const response = await GET(request, { params: Promise.resolve({ token: " " }) });
const body = await response.json();

expect(response.status).toBe(400);
expect(body.error).toBe("UUID 為必填欄位");
});

it("returns 404 when vote does not exist", async () => {
voteModelMock.findOne.mockReturnValueOnce({
lean: async () => null,
});

const request = new NextRequest("http://localhost:3000/api/verify/abc");
const response = await GET(request, {
params: Promise.resolve({ token: "abc" }),
});
const body = await response.json();

expect(response.status).toBe(404);
expect(body.error).toBe("找不到此 UUID 的投票記錄");
});

it("returns vote verification details for choose_one", async () => {
voteModelMock.findOne.mockReturnValueOnce({
lean: async () => ({
token: "vote-token",
activity_id: "507f1f77bcf86cd799439011",
rule: "choose_one",
choose_one: "507f1f77bcf86cd799439012",
created_at: new Date("2026-01-01T00:00:00.000Z"),
}),
});
activityModelMock.findById.mockReturnValueOnce({
select: () => ({
lean: async () => ({ name: "測試活動" }),
}),
});
optionModelMock.find.mockReturnValueOnce({
select: () => ({
lean: async () => [
{
_id: "507f1f77bcf86cd799439012",
label: "候選人 A",
},
],
}),
});

const request = new NextRequest("http://localhost:3000/api/verify/vote-token");
const response = await GET(request, {
params: Promise.resolve({ token: "vote-token" }),
});
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.activity_name).toBe("測試活動");
expect(body.data.selections).toEqual(["候選人 A"]);
});
});
Loading