- Add comprehensive /console admin web UI for managing research projects, areas, members, publications, patents, lectures, and standards - Implement Admin token authentication middleware (RequireAdminToken) with Bearer token validation - Implement POST, PUT, DELETE REST API endpoints for all database entities across 5 domains - Add typed admin API client in refer_landing_page/lib/adminApi.ts with localStorage session persistence - Enhance E2E test runner to manage backend server lifecycle during automated tests - Pass all 12 quality gates cleanly with unanimous reviewer PASS verdicts
442 lines
15 KiB
Go
442 lines
15 KiB
Go
package router_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"git.godopu.com/lab/landing_page/backend/internal/db"
|
|
"git.godopu.com/lab/landing_page/backend/internal/models"
|
|
"git.godopu.com/lab/landing_page/backend/internal/router"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
const testAdminToken = "test-secret-token"
|
|
|
|
func setupTestDB(t *testing.T) *sql.DB {
|
|
tmpDB := filepath.Join(t.TempDir(), "test.db")
|
|
database, err := db.Connect(tmpDB)
|
|
require.NoError(t, err)
|
|
|
|
err = db.RunMigrations(database)
|
|
require.NoError(t, err)
|
|
|
|
seedDir := filepath.Join("..", "..", "seed", "data")
|
|
seedDatabase(t, database, seedDir)
|
|
|
|
return database
|
|
}
|
|
|
|
func seedDatabase(t *testing.T, database *sql.DB, seedDir string) {
|
|
tx, err := database.BeginTx(context.Background(), nil)
|
|
require.NoError(t, err)
|
|
defer tx.Rollback()
|
|
|
|
// 1. Projects
|
|
pData, err := os.ReadFile(filepath.Join(seedDir, "research_projects.json"))
|
|
require.NoError(t, err)
|
|
var projects []struct {
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Abstract string `json:"abstract"`
|
|
Keywords []string `json:"keywords"`
|
|
StandardsTrack *string `json:"standardsTrack"`
|
|
StandardsOrg *string `json:"standardsOrg"`
|
|
Period *string `json:"period"`
|
|
Funder *string `json:"funder"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(pData, &projects))
|
|
for _, p := range projects {
|
|
kwJSON, _ := json.Marshal(p.Keywords)
|
|
_, err := tx.Exec(`
|
|
INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, p.Slug, p.Title, p.Abstract, string(kwJSON), p.StandardsTrack, p.StandardsOrg, p.Period, p.Funder)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// 2. Areas
|
|
aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json"))
|
|
require.NoError(t, err)
|
|
var areas []struct {
|
|
En string `json:"en"`
|
|
Kr *string `json:"kr"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(aData, &areas))
|
|
for idx, a := range areas {
|
|
_, err := tx.Exec(`
|
|
INSERT INTO research_areas (name_en, name_kr, display_order)
|
|
VALUES (?, ?, ?)
|
|
`, a.En, a.Kr, idx+1)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// 3. Members
|
|
mData, err := os.ReadFile(filepath.Join(seedDir, "members.json"))
|
|
require.NoError(t, err)
|
|
var members []struct {
|
|
Ko string `json:"ko"`
|
|
En string `json:"en"`
|
|
Degree string `json:"degree"`
|
|
Affiliation *string `json:"affiliation"`
|
|
Email *string `json:"email"`
|
|
IsAdvisor bool `json:"is_advisor"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(mData, &members))
|
|
for idx, m := range members {
|
|
_, err := tx.Exec(`
|
|
INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, m.Ko, m.En, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, idx+1)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// 4. Alumni
|
|
alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json"))
|
|
require.NoError(t, err)
|
|
var alumni []struct {
|
|
Ko string `json:"ko"`
|
|
En string `json:"en"`
|
|
Degree string `json:"degree"`
|
|
GraduatedAt string `json:"graduatedAt"`
|
|
Major *string `json:"major"`
|
|
CurrentPosition *string `json:"currentPosition"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(alumniData, &alumni))
|
|
for _, a := range alumni {
|
|
_, err := tx.Exec(`
|
|
INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, a.Ko, a.En, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// 5. Publications
|
|
pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json"))
|
|
require.NoError(t, err)
|
|
var publications []struct {
|
|
Category string `json:"category"`
|
|
Title string `json:"title"`
|
|
Authors *string `json:"authors"`
|
|
Venue string `json:"venue"`
|
|
Volume *string `json:"volume"`
|
|
PublishedAt string `json:"publishedAt"`
|
|
KCI bool `json:"kci"`
|
|
DOI *string `json:"doi"`
|
|
IsHighlight bool `json:"is_highlight"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(pubData, &publications))
|
|
for _, p := range publications {
|
|
_, err := tx.Exec(`
|
|
INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// 6. Patents
|
|
patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json"))
|
|
require.NoError(t, err)
|
|
var patents []struct {
|
|
Title string `json:"title"`
|
|
Inventors string `json:"inventors"`
|
|
ApplicationNo string `json:"applicationNo"`
|
|
ApplicationAt string `json:"applicationAt"`
|
|
RegistrationNo *string `json:"registrationNo"`
|
|
RegistrationAt *string `json:"registrationAt"`
|
|
Country string `json:"country"`
|
|
PublishedAt string `json:"publishedAt"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(patData, &patents))
|
|
for _, p := range patents {
|
|
_, err := tx.Exec(`
|
|
INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// 7. Lectures
|
|
lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json"))
|
|
require.NoError(t, err)
|
|
var semesters []struct {
|
|
Term string `json:"term"`
|
|
Year int `json:"year"`
|
|
Courses []struct {
|
|
Code string `json:"code"`
|
|
En string `json:"en"`
|
|
Note *string `json:"note"`
|
|
} `json:"courses"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(lecData, &semesters))
|
|
for _, s := range semesters {
|
|
res, err := tx.Exec(`INSERT INTO semesters (year, term) VALUES (?, ?)`, s.Year, s.Term)
|
|
require.NoError(t, err)
|
|
semID, _ := res.LastInsertId()
|
|
for cIdx, c := range s.Courses {
|
|
_, err := tx.Exec(`
|
|
INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, semID, c.Code, c.En, nil, c.Note, cIdx+1)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
// 8. Standards
|
|
stdData, err := os.ReadFile(filepath.Join(seedDir, "standards.json"))
|
|
require.NoError(t, err)
|
|
var standards []struct {
|
|
Org string `json:"org"`
|
|
Full string `json:"full"`
|
|
Scope string `json:"scope"`
|
|
Period string `json:"period"`
|
|
Role *string `json:"role"`
|
|
Projects []struct {
|
|
WG string `json:"wg"`
|
|
Name string `json:"name"`
|
|
Documents []struct {
|
|
Title string `json:"title"`
|
|
Ref string `json:"ref"`
|
|
Status string `json:"status"`
|
|
PublishedAt *string `json:"publishedAt"`
|
|
} `json:"documents"`
|
|
} `json:"projects"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(stdData, &standards))
|
|
for bIdx, b := range standards {
|
|
res, err := tx.Exec(`
|
|
INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, b.Org, b.Full, b.Scope, b.Period, b.Role, bIdx+1)
|
|
require.NoError(t, err)
|
|
bodyID, _ := res.LastInsertId()
|
|
|
|
for _, p := range b.Projects {
|
|
for _, d := range p.Documents {
|
|
_, err := tx.Exec(`
|
|
INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, bodyID, p.WG, p.Name, d.Title, d.Ref, d.Status, d.PublishedAt)
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
require.NoError(t, tx.Commit())
|
|
}
|
|
|
|
type APIResponse[T any] struct {
|
|
Data T `json:"data"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
func doRequest(r http.Handler, method, target string, body any, token string) *httptest.ResponseRecorder {
|
|
var reqBody []byte
|
|
if body != nil {
|
|
reqBody, _ = json.Marshal(body)
|
|
}
|
|
req := httptest.NewRequest(method, target, bytes.NewReader(reqBody))
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func TestHealthCheck(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
r := router.SetupRouter(db, testAdminToken)
|
|
|
|
w := doRequest(r, "GET", "/api/v1/health", nil, "")
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
func TestAdminAuthMiddleware(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
|
|
// A. Configured with explicit valid token
|
|
r := router.SetupRouter(db, testAdminToken)
|
|
|
|
// 1. Unauthenticated request to /admin/verify should return 401
|
|
wNoAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "")
|
|
assert.Equal(t, http.StatusUnauthorized, wNoAuth.Code)
|
|
|
|
// 2. Guessable fallback token "admin123" must return 401
|
|
wGuessAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "admin123")
|
|
assert.Equal(t, http.StatusUnauthorized, wGuessAuth.Code)
|
|
|
|
// 3. Wrong token request to /admin/verify should return 401
|
|
wWrongAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "wrong-token")
|
|
assert.Equal(t, http.StatusUnauthorized, wWrongAuth.Code)
|
|
|
|
// 4. Valid token request to /admin/verify should return 200
|
|
wValidAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, testAdminToken)
|
|
assert.Equal(t, http.StatusOK, wValidAuth.Code)
|
|
|
|
// 5. Public GET routes remain accessible without auth
|
|
wPublic := doRequest(r, "GET", "/api/v1/members", nil, "")
|
|
assert.Equal(t, http.StatusOK, wPublic.Code)
|
|
|
|
// B. Unconfigured (empty string) admin token: must strictly reject ALL mutating/verify attempts
|
|
rEmptyToken := router.SetupRouter(db, "")
|
|
|
|
// 6. Any token on verify should return 401
|
|
wEmpty1 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "admin123")
|
|
assert.Equal(t, http.StatusUnauthorized, wEmpty1.Code)
|
|
|
|
wEmpty2 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "")
|
|
assert.Equal(t, http.StatusUnauthorized, wEmpty2.Code)
|
|
|
|
// 7. Mutating POST on empty router should return 401
|
|
newProj := models.ResearchProject{
|
|
Slug: "test-unauth",
|
|
Title: "Unauthorized Title",
|
|
Abstract: "Unauthorized abstract.",
|
|
Keywords: []string{"unauth"},
|
|
}
|
|
wMutate := doRequest(rEmptyToken, "POST", "/api/v1/research-projects", newProj, "admin123")
|
|
assert.Equal(t, http.StatusUnauthorized, wMutate.Code)
|
|
|
|
// 8. Public GET routes still work on empty admin token router
|
|
wPublicEmpty := doRequest(rEmptyToken, "GET", "/api/v1/health", nil, "")
|
|
assert.Equal(t, http.StatusOK, wPublicEmpty.Code)
|
|
}
|
|
|
|
func TestResearchProjectsCRUD(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
r := router.SetupRouter(db, testAdminToken)
|
|
|
|
// 1. Create Project
|
|
newProj := models.ResearchProject{
|
|
Slug: "test-crud-proj",
|
|
Title: "Test Project Title",
|
|
Abstract: "Test project abstract for CRUD verification.",
|
|
Keywords: []string{"test", "crud"},
|
|
}
|
|
wCreate := doRequest(r, "POST", "/api/v1/research-projects", newProj, testAdminToken)
|
|
assert.Equal(t, http.StatusCreated, wCreate.Code)
|
|
|
|
var createResp APIResponse[models.ResearchProject]
|
|
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
|
createdID := createResp.Data.ID
|
|
assert.True(t, createdID > 0)
|
|
|
|
// 2. Update Project
|
|
newProj.Title = "Updated Project Title"
|
|
wUpdate := doRequest(r, "PUT", "/api/v1/research-projects/"+strconvItoa(createdID), newProj, testAdminToken)
|
|
assert.Equal(t, http.StatusOK, wUpdate.Code)
|
|
|
|
// 3. Delete Project
|
|
wDelete := doRequest(r, "DELETE", "/api/v1/research-projects/"+strconvItoa(createdID), nil, testAdminToken)
|
|
assert.Equal(t, http.StatusNoContent, wDelete.Code)
|
|
}
|
|
|
|
func TestMembersAndAlumniCRUD(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
r := router.SetupRouter(db, testAdminToken)
|
|
|
|
// 1. Create Member with Invalid Degree -> 400
|
|
badMember := models.Member{
|
|
NameKo: "홍길동",
|
|
NameEn: "Gildong Hong",
|
|
Degree: "InvalidDegree",
|
|
}
|
|
wBad := doRequest(r, "POST", "/api/v1/members", badMember, testAdminToken)
|
|
assert.Equal(t, http.StatusBadRequest, wBad.Code)
|
|
|
|
// 2. Create Valid Member -> 201
|
|
validMember := models.Member{
|
|
NameKo: "홍길동",
|
|
NameEn: "Gildong Hong",
|
|
Degree: "PhDStudent",
|
|
}
|
|
wCreate := doRequest(r, "POST", "/api/v1/members", validMember, testAdminToken)
|
|
assert.Equal(t, http.StatusCreated, wCreate.Code)
|
|
|
|
var createResp APIResponse[models.Member]
|
|
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
|
|
mID := createResp.Data.ID
|
|
|
|
// 3. Delete Member -> 204
|
|
wDelete := doRequest(r, "DELETE", "/api/v1/members/"+strconvItoa(mID), nil, testAdminToken)
|
|
assert.Equal(t, http.StatusNoContent, wDelete.Code)
|
|
|
|
// 4. Create Alumnus -> 201
|
|
validAlumnus := models.Alumnus{
|
|
NameKo: "김졸업",
|
|
NameEn: "Grad Kim",
|
|
Degree: "MS",
|
|
GraduatedAt: "2024-02",
|
|
}
|
|
wCreateAl := doRequest(r, "POST", "/api/v1/alumni", validAlumnus, testAdminToken)
|
|
assert.Equal(t, http.StatusCreated, wCreateAl.Code)
|
|
|
|
var alResp APIResponse[models.Alumnus]
|
|
require.NoError(t, json.Unmarshal(wCreateAl.Body.Bytes(), &alResp))
|
|
alID := alResp.Data.ID
|
|
|
|
// 5. Delete Alumnus -> 204
|
|
wDelAl := doRequest(r, "DELETE", "/api/v1/alumni/"+strconvItoa(alID), nil, testAdminToken)
|
|
assert.Equal(t, http.StatusNoContent, wDelAl.Code)
|
|
}
|
|
|
|
func TestCascadeDeletes(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
r := router.SetupRouter(db, testAdminToken)
|
|
|
|
// 1. Create a semester
|
|
newSem := models.Semester{Year: 2030, Term: "Spring"}
|
|
wSem := doRequest(r, "POST", "/api/v1/semesters", newSem, testAdminToken)
|
|
assert.Equal(t, http.StatusCreated, wSem.Code)
|
|
var semResp APIResponse[models.Semester]
|
|
require.NoError(t, json.Unmarshal(wSem.Body.Bytes(), &semResp))
|
|
semID := semResp.Data.ID
|
|
|
|
// 2. Create courses under this semester
|
|
course1 := models.Course{Code: "TEST101", NameEn: "Intro to Testing"}
|
|
course2 := models.Course{Code: "TEST102", NameEn: "Advanced Testing"}
|
|
wCourse1 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course1, testAdminToken)
|
|
assert.Equal(t, http.StatusCreated, wCourse1.Code)
|
|
wCourse2 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course2, testAdminToken)
|
|
assert.Equal(t, http.StatusCreated, wCourse2.Code)
|
|
|
|
// Verify courses exist in DB
|
|
var courseCount int
|
|
err := db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, courseCount)
|
|
|
|
// 3. Delete semester
|
|
wDelSem := doRequest(r, "DELETE", "/api/v1/semesters/"+strconvItoa(semID), nil, testAdminToken)
|
|
assert.Equal(t, http.StatusNoContent, wDelSem.Code)
|
|
|
|
// 4. Verify cascade delete removed courses
|
|
err = db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted")
|
|
}
|
|
|
|
func strconvItoa(i int) string {
|
|
return strconv.Itoa(i)
|
|
}
|