diff --git a/README.md b/README.md index 1ae1f52..4ba4678 100644 --- a/README.md +++ b/README.md @@ -124,16 +124,51 @@ make test-all ## Development Scripts Helper scripts are located in the `scripts/` directory. -### Create Development User -Seeds a user into the DB and generates a valid JWT for testing. +### Create User +Seeds or updates a user in the database and prints a JWT for that user. `--email` is required; the other fields have defaults. ```bash -go run scripts/create_dev_user/main.go +go run scripts/create_user/main.go --email dev@example.com --role dev ``` -### Generate Token -Manually generates a JWT for an existing user (by email). +### Create Bot Token +Creates a bot token in the database and prints the full `token_id.secret` value once for use with the `X-Bot-Token` header. `--name` and `--created-by` are required; `--hours 0` means the token does not expire. ```bash -go run scripts/generate_token/main.go +go run scripts/create_bot_token/main.go --name my-bot --created-by 00000000-0000-0000-0000-000000000001 --hours 24 +``` + +### Run DB-Connected Scripts Without Go in the API Image +If you are running the API and Postgres with Docker Compose, the API container does not include the Go toolchain. To run local Go scripts that need database access, start a one-off Go container on the same Compose network and mount the repository into it. + +Current local network: +```bash +api_default +``` + +Example: +```bash +docker run --rm \ + --network api_default \ + -v "$PWD":/app \ + -w /app \ + --env-file .env \ + golang:1.25 \ + go run scripts/create_user/main.go --email dev@example.com --role dev +``` + +Bot token example: +```bash +docker run --rm \ + --network api_default \ + -v "$PWD":/app \ + -w /app \ + --env-file .env \ + golang:1.25 \ + go run scripts/create_bot_token/main.go --name my-bot --created-by 00000000-0000-0000-0000-000000000001 --hours 24 +``` + +If your Compose project name is different, the network name will usually be `_default`. You can check it with: +```bash +docker network ls ``` ## Project Structure diff --git a/docs/schema/schema.json b/docs/schema/schema.json index 67eaee4..737f7e8 100644 --- a/docs/schema/schema.json +++ b/docs/schema/schema.json @@ -71699,8 +71699,8 @@ "insert_into_table": null }, { - "text": "\nSELECT token_id, token_hash, name, created_by, created_at, last_used_at, expires_at, is_active FROM bot_tokens WHERE token_hash = $1 AND is_active = true", - "name": "GetBotTokenByHash", + "text": "\nSELECT token_id, token_hash, name, created_by, created_at, last_used_at, expires_at, is_active FROM bot_tokens WHERE token_id = $1", + "name": "GetBotTokenByID", "cmd": ":one", "columns": [ { @@ -71916,7 +71916,7 @@ { "number": 1, "column": { - "name": "token_hash", + "name": "token_id", "not_null": true, "is_array": false, "comment": "", @@ -71933,11 +71933,11 @@ "type": { "catalog": "", "schema": "", - "name": "text" + "name": "uuid" }, "is_sqlc_slice": false, "embed_table": null, - "original_name": "token_hash", + "original_name": "token_id", "unsigned": false, "array_dims": 0 } diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 35a1411..d6a0539 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -218,7 +218,7 @@ const docTemplate = `{ "BotToken": [] } ], - "description": "Returns information about the current bot token", + "description": "Returns information about the current bot token. Authenticate with X-Bot-Token: ., for example: curl -H 'X-Bot-Token: ' http://localhost:8080/api/v1/bot/me", "consumes": [ "application/json" ], @@ -233,7 +233,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.BotTokenResponse" + "$ref": "#/definitions/handler.BotMeResponse" } }, "401": { @@ -287,7 +287,7 @@ const docTemplate = `{ "CookieAuth": [] } ], - "description": "Creates a new bot token (requires faculty role)", + "description": "Creates a new bot token (requires faculty role). The raw token is returned only once and must be stored by the caller.", "consumes": [ "application/json" ], @@ -1821,7 +1821,24 @@ const docTemplate = `{ "type": "string" }, "token": { - "description": "Only on creation", + "description": "Only on creation. Store it immediately; it is not returned again.", + "type": "string" + }, + "token_id": { + "type": "string" + } + } + }, + "handler.BotMeResponse": { + "type": "object", + "properties": { + "auth_type": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "name": { "type": "string" }, "token_id": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index f68e0ee..e57d7c0 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -212,7 +212,7 @@ "BotToken": [] } ], - "description": "Returns information about the current bot token", + "description": "Returns information about the current bot token. Authenticate with X-Bot-Token: ., for example: curl -H 'X-Bot-Token: ' http://localhost:8080/api/v1/bot/me", "consumes": [ "application/json" ], @@ -227,7 +227,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.BotTokenResponse" + "$ref": "#/definitions/handler.BotMeResponse" } }, "401": { @@ -281,7 +281,7 @@ "CookieAuth": [] } ], - "description": "Creates a new bot token (requires faculty role)", + "description": "Creates a new bot token (requires faculty role). The raw token is returned only once and must be stored by the caller.", "consumes": [ "application/json" ], @@ -1815,7 +1815,24 @@ "type": "string" }, "token": { - "description": "Only on creation", + "description": "Only on creation. Store it immediately; it is not returned again.", + "type": "string" + }, + "token_id": { + "type": "string" + } + } + }, + "handler.BotMeResponse": { + "type": "object", + "properties": { + "auth_type": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "name": { "type": "string" }, "token_id": { @@ -1883,4 +1900,4 @@ "in": "cookie" } } -} \ No newline at end of file +} diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 98f40b9..a7e12ef 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -187,7 +187,18 @@ definitions: name: type: string token: - description: Only on creation + description: Only on creation. Store it immediately; it is not returned again. + type: string + token_id: + type: string + type: object + handler.BotMeResponse: + properties: + auth_type: + type: string + expires_at: + type: string + name: type: string token_id: type: string @@ -360,14 +371,15 @@ paths: get: consumes: - application/json - description: Returns information about the current bot token + description: 'Returns information about the current bot token. Authenticate with X-Bot-Token: + ., for example: curl -H ''X-Bot-Token: '' http://localhost:8080/api/v1/bot/me' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handler.BotTokenResponse' + $ref: '#/definitions/handler.BotMeResponse' "401": description: Unauthorized schema: @@ -403,7 +415,7 @@ paths: post: consumes: - application/json - description: Creates a new bot token (requires faculty role) + description: Creates a new bot token (requires faculty role). The raw token is returned only once and must be stored by the caller. parameters: - description: Token data in: body diff --git a/internal/database/mocks/Querier.go b/internal/database/mocks/Querier.go index f8822b0..3baf283 100644 --- a/internal/database/mocks/Querier.go +++ b/internal/database/mocks/Querier.go @@ -220,27 +220,27 @@ func (_m *Querier) DeleteUser(ctx context.Context, uid uuid.UUID) error { return r0 } -// GetBotTokenByHash provides a mock function with given fields: ctx, tokenHash -func (_m *Querier) GetBotTokenByHash(ctx context.Context, tokenHash string) (database.BotToken, error) { - ret := _m.Called(ctx, tokenHash) +// GetBotTokenByID provides a mock function with given fields: ctx, tokenID +func (_m *Querier) GetBotTokenByID(ctx context.Context, tokenID uuid.UUID) (database.BotToken, error) { + ret := _m.Called(ctx, tokenID) if len(ret) == 0 { - panic("no return value specified for GetBotTokenByHash") + panic("no return value specified for GetBotTokenByID") } var r0 database.BotToken var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) (database.BotToken, error)); ok { - return rf(ctx, tokenHash) + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (database.BotToken, error)); ok { + return rf(ctx, tokenID) } - if rf, ok := ret.Get(0).(func(context.Context, string) database.BotToken); ok { - r0 = rf(ctx, tokenHash) + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) database.BotToken); ok { + r0 = rf(ctx, tokenID) } else { r0 = ret.Get(0).(database.BotToken) } - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, tokenHash) + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = rf(ctx, tokenID) } else { r1 = ret.Error(1) } diff --git a/internal/database/models.go b/internal/database/models.go index a54ed5a..16ba637 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -19,6 +19,7 @@ const ( UserRoleAlumni UserRole = "alumni" UserRoleFaculty UserRole = "faculty" UserRoleExternal UserRole = "external" + UserRoleDev UserRole = "dev" ) func (e *UserRole) Scan(src interface{}) error { diff --git a/internal/database/querier.go b/internal/database/querier.go index fa8a0c9..1a39c35 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -22,7 +22,7 @@ type Querier interface { DeleteOrganization(ctx context.Context, oid uuid.UUID) error DeleteUser(ctx context.Context, uid uuid.UUID) error // Bot Token Queries - GetBotTokenByHash(ctx context.Context, tokenHash string) (BotToken, error) + GetBotTokenByID(ctx context.Context, tokenID uuid.UUID) (BotToken, error) GetEventByID(ctx context.Context, eid uuid.UUID) (Event, error) GetEventRegistrations(ctx context.Context, eid uuid.UUID) ([]GetEventRegistrationsRow, error) GetOrgMembers(ctx context.Context, oid uuid.UUID) ([]GetOrgMembersRow, error) diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 426505f..3344cfd 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -123,7 +123,21 @@ ON CONFLICT (uid, eid) DO UPDATE SET is_attending = $3; DELETE FROM event_registrations WHERE uid = $1 AND eid = $2; -- name: IsEventAdmin :one -SELECT is_admin FROM event_registrations WHERE uid = $1 AND eid = $2; +SELECT EXISTS ( + SELECT 1 + FROM event_registrations er + WHERE er.uid = $1 + AND er.eid = $2 + AND er.is_admin = TRUE +) +OR EXISTS ( + SELECT 1 + FROM event_hosting eh + JOIN org_members om ON om.oid = eh.oid + WHERE eh.eid = $2 + AND om.uid = $1 + AND om.is_admin = TRUE +); -- name: GetUserEvents :many SELECT e.*, er.is_attending, er.is_admin, er.date_registered @@ -134,8 +148,8 @@ ORDER BY e.event_time DESC; -- Bot Token Queries --- name: GetBotTokenByHash :one -SELECT * FROM bot_tokens WHERE token_hash = $1 AND is_active = true; +-- name: GetBotTokenByID :one +SELECT * FROM bot_tokens WHERE token_id = $1; -- name: CreateBotToken :one INSERT INTO bot_tokens (token_hash, name, created_by, expires_at) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index d9f267d..d495a7e 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -192,14 +192,14 @@ func (q *Queries) DeleteUser(ctx context.Context, uid uuid.UUID) error { return err } -const getBotTokenByHash = `-- name: GetBotTokenByHash :one +const getBotTokenByID = `-- name: GetBotTokenByID :one -SELECT token_id, token_hash, name, created_by, created_at, last_used_at, expires_at, is_active FROM bot_tokens WHERE token_hash = $1 AND is_active = true +SELECT token_id, token_hash, name, created_by, created_at, last_used_at, expires_at, is_active FROM bot_tokens WHERE token_id = $1 ` // Bot Token Queries -func (q *Queries) GetBotTokenByHash(ctx context.Context, tokenHash string) (BotToken, error) { - row := q.db.QueryRow(ctx, getBotTokenByHash, tokenHash) +func (q *Queries) GetBotTokenByID(ctx context.Context, tokenID uuid.UUID) (BotToken, error) { + row := q.db.QueryRow(ctx, getBotTokenByID, tokenID) var i BotToken err := row.Scan( &i.TokenID, @@ -503,7 +503,21 @@ func (q *Queries) GetUserOrganizations(ctx context.Context, uid uuid.UUID) ([]Ge } const isEventAdmin = `-- name: IsEventAdmin :one -SELECT is_admin FROM event_registrations WHERE uid = $1 AND eid = $2 +SELECT EXISTS ( + SELECT 1 + FROM event_registrations er + WHERE er.uid = $1 + AND er.eid = $2 + AND er.is_admin = TRUE +) +OR EXISTS ( + SELECT 1 + FROM event_hosting eh + JOIN org_members om ON om.oid = eh.oid + WHERE eh.eid = $2 + AND om.uid = $1 + AND om.is_admin = TRUE +) ` type IsEventAdminParams struct { diff --git a/internal/dto/dto.go b/internal/dto/dto.go index cabfa00..692c076 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -17,7 +17,7 @@ type CreateUserRequest struct { SchoolEmail string `json:"school_email,omitempty" validate:"omitempty,email"` Phone string `json:"phone,omitempty"` GradYear int `json:"grad_year,omitempty" validate:"omitempty,gte=2000,lte=2100"` - Role string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external"` + Role string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external dev"` } type UpdateUserRequest struct { @@ -27,7 +27,7 @@ type UpdateUserRequest struct { SchoolEmail *string `json:"school_email,omitempty" validate:"omitempty,email"` Phone *string `json:"phone,omitempty"` GradYear *int `json:"grad_year,omitempty" validate:"omitempty,gte=2000,lte=2100"` - Role *string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external"` + Role *string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external dev"` } type UserResponse struct { diff --git a/internal/handler/auth.go b/internal/handler/auth.go index dbbc3cf..11c92f9 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -45,6 +45,13 @@ type BotTokenResponse struct { IsActive bool `json:"is_active"` } +type BotMeResponse struct { + TokenID uuid.UUID `json:"token_id"` + Name string `json:"name"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AuthType string `json:"auth_type"` +} + type CreateBotTokenRequest struct { Name string `json:"name" validate:"required,min=1,max=100"` ExpiresAt *time.Time `json:"expires_at,omitempty"` @@ -304,7 +311,7 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { // ListBotTokens lists all bot tokens // @Summary List bot tokens -// @Description Returns all bot tokens (requires faculty role) +// @Description Returns all bot tokens (requires dev role) // @Tags bot // @Accept json // @Produce json @@ -313,7 +320,10 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { // @Security CookieAuth // @Router /bot/tokens [get] func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) { - // TODO: Check faculty role + if !h.requireDev(w, r) { + return + } + tokens, err := h.queries.ListBotTokens(r.Context()) if err != nil { h.handleDBError(w, err) @@ -336,7 +346,7 @@ func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) { // CreateBotToken creates a new bot token // @Summary Create bot token -// @Description Creates a new bot token (requires faculty role) +// @Description Creates a new bot token (requires dev role). The raw token is returned only once and must be stored by the caller. // @Tags bot // @Accept json // @Produce json @@ -347,14 +357,11 @@ func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) { // @Security CookieAuth // @Router /bot/tokens [post] func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { - claims, ok := middleware.GetUserClaims(r.Context()) + claims, ok := h.requireDevClaims(w, r) if !ok { - h.respondError(w, http.StatusUnauthorized, "Not authenticated") return } - // TODO: Check faculty role - var req CreateBotTokenRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -366,15 +373,13 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { return } - // Generate random token - rawToken, err := generateSecureToken(32) + secret, err := generateSecureToken(32) if err != nil { h.respondError(w, http.StatusInternalServerError, "Failed to generate token") return } - // Hash the token for storage - hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), bcrypt.DefaultCost) + hashedToken, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) if err != nil { h.respondError(w, http.StatusInternalServerError, "Failed to hash token") return @@ -393,10 +398,12 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { return } + rawToken := formatBotToken(token.TokenID, secret) + h.respondJSON(w, http.StatusCreated, BotTokenResponse{ TokenID: token.TokenID, Name: token.Name, - Token: rawToken, // Only returned on creation! + Token: rawToken, // Only returned on creation CreatedAt: token.CreatedAt.Time, ExpiresAt: fromPgTimestamp(token.ExpiresAt), IsActive: token.IsActive.Bool, @@ -405,7 +412,7 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { // RevokeBotToken revokes a bot token // @Summary Revoke bot token -// @Description Revokes a bot token (requires faculty role) +// @Description Revokes a bot token (requires dev role) // @Tags bot // @Accept json // @Produce json @@ -416,6 +423,10 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { // @Security CookieAuth // @Router /bot/tokens/{token_id} [delete] func (h *Handler) RevokeBotToken(w http.ResponseWriter, r *http.Request) { + if !h.requireDev(w, r) { + return + } + tokenIDStr := chi.URLParam(r, "token_id") tokenID, err := uuid.Parse(tokenIDStr) if err != nil { @@ -423,8 +434,6 @@ func (h *Handler) RevokeBotToken(w http.ResponseWriter, r *http.Request) { return } - // TODO: Check faculty role - if err := h.queries.RevokeBotToken(r.Context(), tokenID); err != nil { h.handleDBError(w, err) return @@ -435,19 +444,26 @@ func (h *Handler) RevokeBotToken(w http.ResponseWriter, r *http.Request) { // GetBotMe returns info about the current bot token // @Summary Get bot info -// @Description Returns information about the current bot token +// @Description Returns information about the current bot token. Authenticate with X-Bot-Token: ., for example: curl -H 'X-Bot-Token: ' http://localhost:8080/api/v1/bot/me // @Tags bot // @Accept json // @Produce json -// @Success 200 {object} BotTokenResponse +// @Success 200 {object} BotMeResponse // @Failure 401 {object} ErrorResponse // @Security BotToken // @Router /bot/me [get] func (h *Handler) GetBotMe(w http.ResponseWriter, r *http.Request) { - // Token info would be in context from M2M middleware - h.respondJSON(w, http.StatusOK, map[string]string{ - "status": "authenticated", - "type": "bot", + token, ok := middleware.GetBotToken(r.Context()) + if !ok { + h.respondError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + h.respondJSON(w, http.StatusOK, BotMeResponse{ + TokenID: token.TokenID, + Name: token.Name, + ExpiresAt: token.ExpiresAt, + AuthType: middleware.GetAuthType(r.Context()), }) } @@ -459,7 +475,6 @@ func (h *Handler) generateJWT(user database.User) (string, error) { claims := &middleware.UserClaims{ UserID: user.Uid.String(), Email: getEmail(user), - Role: string(user.Role.UserRole), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(h.Config.JWT.ExpiryHours) * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -569,7 +584,8 @@ func (h *Handler) verifyStateCookie(w http.ResponseWriter, r *http.Request, stat } func (h *Handler) upsertUser(ctx context.Context, email, firstName, lastName string) (database.User, error) { - pgEmail := toPgTextFromString(email) + normalizedEmail := normalizeEmail(email) + pgEmail := toPgTextFromString(normalizedEmail) // Check if user exists user, err := h.queries.GetUserByEmail(ctx, pgEmail) @@ -587,7 +603,7 @@ func (h *Handler) upsertUser(ctx context.Context, email, firstName, lastName str LastName: lastName, PersonalEmail: pgEmail, // Default to personal email for oauth SchoolEmail: pgtype.Text{Valid: false}, - Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, // Default role + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, }) } @@ -608,3 +624,7 @@ func generateSecureToken(length int) (string, error) { } return hex.EncodeToString(bytes), nil } + +func formatBotToken(tokenID uuid.UUID, secret string) string { + return tokenID.String() + "." + secret +} diff --git a/internal/handler/auth_test.go b/internal/handler/auth_test.go new file mode 100644 index 0000000..fd06d2f --- /dev/null +++ b/internal/handler/auth_test.go @@ -0,0 +1,63 @@ +package handler + +import ( + "context" + "testing" + + "github.com/capyrpi/api/internal/config" + "github.com/capyrpi/api/internal/database" + "github.com/capyrpi/api/internal/database/mocks" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestUpsertUserCreatesStudentByDefault(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + h := New(mockQueries, &config.Config{Env: "development"}) + + mockQueries.On("GetUserByEmail", mock.Anything, pgtype.Text{ + String: "student@example.com", + Valid: true, + }).Return(database.User{}, pgx.ErrNoRows).Once() + mockQueries.On("CreateUser", mock.Anything, mock.MatchedBy(func(arg database.CreateUserParams) bool { + return arg.FirstName == "Grace" && + arg.LastName == "Hopper" && + arg.PersonalEmail.Valid && + arg.PersonalEmail.String == "student@example.com" && + arg.Role.Valid && + arg.Role.UserRole == database.UserRoleStudent + })).Return(database.User{ + Uid: uuid.New(), + PersonalEmail: pgtype.Text{String: "student@example.com", Valid: true}, + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, + }, nil).Once() + + user, err := h.upsertUser(context.Background(), " Student@Example.com ", "Grace", "Hopper") + + assert.NoError(t, err) + assert.Equal(t, database.UserRoleStudent, user.Role.UserRole) +} + +func TestUpsertUserReturnsExistingUserWithoutRolePromotion(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + h := New(mockQueries, &config.Config{Env: "development"}) + userID := uuid.New() + + mockQueries.On("GetUserByEmail", mock.Anything, pgtype.Text{ + String: "dev@example.com", + Valid: true, + }).Return(database.User{ + Uid: userID, + PersonalEmail: pgtype.Text{String: "dev@example.com", Valid: true}, + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, + }, nil).Once() + + user, err := h.upsertUser(context.Background(), " dev@example.com ", "Katherine", "Johnson") + + assert.NoError(t, err) + assert.Equal(t, userID, user.Uid) + assert.Equal(t, database.UserRoleStudent, user.Role.UserRole) +} diff --git a/internal/handler/authz.go b/internal/handler/authz.go new file mode 100644 index 0000000..881396d --- /dev/null +++ b/internal/handler/authz.go @@ -0,0 +1,140 @@ +package handler + +import ( + "net/http" + + "github.com/capyrpi/api/internal/database" + "github.com/capyrpi/api/internal/middleware" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (h *Handler) requireAuthenticatedUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, *middleware.UserClaims, bool) { + claims, ok := middleware.GetUserClaims(r.Context()) + if !ok { + h.respondError(w, http.StatusUnauthorized, "Not authenticated") + return uuid.Nil, nil, false + } + + uid, err := uuid.Parse(claims.UserID) + if err != nil { + h.respondError(w, http.StatusUnauthorized, "Invalid user ID in token") + return uuid.Nil, nil, false + } + + return uid, claims, true +} + +func (h *Handler) requireAuthenticatedUserRecord(w http.ResponseWriter, r *http.Request) (database.User, *middleware.UserClaims, bool) { + uid, claims, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return database.User{}, nil, false + } + + user, err := h.queries.GetUserByID(r.Context(), uid) + if err != nil { + h.handleDBError(w, err) + return database.User{}, nil, false + } + + return user, claims, true +} + +func (h *Handler) requireDev(w http.ResponseWriter, r *http.Request) bool { + _, ok := h.requireDevClaims(w, r) + return ok +} + +func (h *Handler) requireDevClaims(w http.ResponseWriter, r *http.Request) (*middleware.UserClaims, bool) { + user, claims, ok := h.requireAuthenticatedUserRecord(w, r) + if !ok { + return nil, false + } + + if !user.Role.Valid || user.Role.UserRole != database.UserRoleDev { + h.respondError(w, http.StatusForbidden, "Dev role required") + return nil, false + } + + return claims, true +} + +func (h *Handler) requireSelfOrDev(w http.ResponseWriter, r *http.Request, targetUID uuid.UUID) (database.User, bool) { + user, _, ok := h.requireAuthenticatedUserRecord(w, r) + if !ok { + return database.User{}, false + } + + if user.Uid == targetUID { + return user, true + } + + if user.Role.Valid && user.Role.UserRole == database.UserRoleDev { + return user, true + } + + h.respondError(w, http.StatusForbidden, "Insufficient permissions") + return database.User{}, false +} + +func (h *Handler) requireOrgAdmin(w http.ResponseWriter, r *http.Request, oid uuid.UUID) (uuid.UUID, bool) { + if middleware.GetAuthType(r.Context()) == "bot" { + return uuid.Nil, true + } + + uid, _, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return uuid.Nil, false + } + + isAdmin, err := h.queries.IsOrgAdmin(r.Context(), database.IsOrgAdminParams{ + Uid: uid, + Oid: oid, + }) + if err != nil { + if err == pgx.ErrNoRows { + h.respondError(w, http.StatusForbidden, "Organization admin required") + return uuid.Nil, false + } + h.handleDBError(w, err) + return uuid.Nil, false + } + + if !isAdmin.Valid || !isAdmin.Bool { + h.respondError(w, http.StatusForbidden, "Organization admin required") + return uuid.Nil, false + } + + return uid, true +} + +func (h *Handler) requireEventAdmin(w http.ResponseWriter, r *http.Request, eid uuid.UUID) (uuid.UUID, bool) { + if middleware.GetAuthType(r.Context()) == "bot" { + return uuid.Nil, true + } + + uid, _, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return uuid.Nil, false + } + + isAdmin, err := h.queries.IsEventAdmin(r.Context(), database.IsEventAdminParams{ + Uid: uid, + Eid: eid, + }) + if err != nil { + if err == pgx.ErrNoRows { + h.respondError(w, http.StatusForbidden, "Event admin required") + return uuid.Nil, false + } + h.handleDBError(w, err) + return uuid.Nil, false + } + + if !isAdmin.Valid || !isAdmin.Bool { + h.respondError(w, http.StatusForbidden, "Event admin required") + return uuid.Nil, false + } + + return uid, true +} diff --git a/internal/handler/events.go b/internal/handler/events.go index 0d247f7..81c58ef 100644 --- a/internal/handler/events.go +++ b/internal/handler/events.go @@ -6,6 +6,7 @@ import ( "github.com/capyrpi/api/internal/database" "github.com/capyrpi/api/internal/dto" + "github.com/capyrpi/api/internal/middleware" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -66,6 +67,10 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, req.OrgID); !ok { + return + } + event, err := h.queries.CreateEvent(r.Context(), database.CreateEventParams{ Location: toPgTextFromString(req.Location), EventTime: toPgTimestamp(req.EventTime), @@ -138,6 +143,10 @@ func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireEventAdmin(w, r, eid); !ok { + return + } + var req dto.UpdateEventRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -178,6 +187,10 @@ func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireEventAdmin(w, r, eid); !ok { + return + } + if err := h.queries.DeleteEvent(r.Context(), eid); err != nil { h.handleDBError(w, err) return @@ -254,14 +267,34 @@ func (h *Handler) RegisterForEvent(w http.ResponseWriter, r *http.Request) { return } - // TODO: Get user ID from context if not provided (for human auth) - if req.UID == nil { - h.respondError(w, http.StatusBadRequest, "uid is required") - return + var targetUID uuid.UUID + switch middleware.GetAuthType(r.Context()) { + case "bot": + if req.UID == nil { + h.respondError(w, http.StatusBadRequest, "uid is required") + return + } + targetUID = *req.UID + default: + authenticatedUID, _, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return + } + if req.UID == nil { + h.respondError(w, http.StatusBadRequest, "uid is required") + return + } + + targetUID = *req.UID + if targetUID != authenticatedUID { + if _, ok := h.requireEventAdmin(w, r, eid); !ok { + return + } + } } if err := h.queries.RegisterForEvent(r.Context(), database.RegisterForEventParams{ - Uid: *req.UID, + Uid: targetUID, Eid: eid, IsAttending: pgtype.Bool{Bool: req.IsAttending, Valid: true}, }); err != nil { @@ -293,18 +326,40 @@ func (h *Handler) UnregisterFromEvent(w http.ResponseWriter, r *http.Request) { return } - // Get UID from query param or context - uidStr := r.URL.Query().Get("uid") - if uidStr == "" { - // TODO: Get from auth context - h.respondError(w, http.StatusBadRequest, "uid is required") - return - } + var uid uuid.UUID + switch middleware.GetAuthType(r.Context()) { + case "bot": + uidStr := r.URL.Query().Get("uid") + if uidStr == "" { + h.respondError(w, http.StatusBadRequest, "uid is required") + return + } - uid, err := uuid.Parse(uidStr) - if err != nil { - h.respondError(w, http.StatusBadRequest, "Invalid user ID") - return + uid, err = uuid.Parse(uidStr) + if err != nil { + h.respondError(w, http.StatusBadRequest, "Invalid user ID") + return + } + default: + authenticatedUID, _, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return + } + + uid = authenticatedUID + if uidStr := r.URL.Query().Get("uid"); uidStr != "" { + targetUID, err := uuid.Parse(uidStr) + if err != nil { + h.respondError(w, http.StatusBadRequest, "Invalid user ID") + return + } + if targetUID != authenticatedUID { + if _, ok := h.requireEventAdmin(w, r, eid); !ok { + return + } + } + uid = targetUID + } } if err := h.queries.UnregisterFromEvent(r.Context(), database.UnregisterFromEventParams{ diff --git a/internal/handler/handler.go b/internal/handler/handler.go index cbfc2e7..66c2447 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strings" "github.com/capyrpi/api/internal/config" "github.com/capyrpi/api/internal/database" @@ -30,6 +31,10 @@ func New(queries database.Querier, cfg *config.Config) *Handler { } } +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + // ErrorResponse represents an API error type ErrorResponse struct { Error string `json:"error"` diff --git a/internal/handler/organizations.go b/internal/handler/organizations.go index c009ace..28f6eaa 100644 --- a/internal/handler/organizations.go +++ b/internal/handler/organizations.go @@ -143,6 +143,10 @@ func (h *Handler) UpdateOrganization(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, oid); !ok { + return + } + var req dto.UpdateOrganizationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -181,6 +185,10 @@ func (h *Handler) DeleteOrganization(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, oid); !ok { + return + } + if err := h.queries.DeleteOrganization(r.Context(), oid); err != nil { h.handleDBError(w, err) return @@ -251,6 +259,10 @@ func (h *Handler) AddOrgMember(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, oid); !ok { + return + } + var req dto.AddMemberRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -290,6 +302,10 @@ func (h *Handler) RemoveOrgMember(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, oid); !ok { + return + } + uidStr := chi.URLParam(r, "uid") uid, err := uuid.Parse(uidStr) if err != nil { diff --git a/internal/handler/users.go b/internal/handler/users.go index c1c9b26..340a592 100644 --- a/internal/handler/users.go +++ b/internal/handler/users.go @@ -60,6 +60,11 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { return } + authenticatedUser, ok := h.requireSelfOrDev(w, r, uid) + if !ok { + return + } + var req dto.UpdateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -68,6 +73,10 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { var role database.NullUserRole if req.Role != nil { + if !authenticatedUser.Role.Valid || authenticatedUser.Role.UserRole != database.UserRoleDev { + h.respondError(w, http.StatusForbidden, "Only dev may update user roles") + return + } role = database.NullUserRole{UserRole: database.UserRole(*req.Role), Valid: true} } @@ -109,6 +118,10 @@ func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireSelfOrDev(w, r, uid); !ok { + return + } + if err := h.queries.DeleteUser(r.Context(), uid); err != nil { h.handleDBError(w, err) return diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ce7d3c7..84024a2 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -19,7 +19,6 @@ const ( type UserClaims struct { UserID string `json:"uid"` Email string `json:"email"` - Role string `json:"role"` jwt.RegisteredClaims } @@ -43,15 +42,6 @@ func Auth(jwtSecret string) func(http.Handler) http.Handler { } } - // Check for bot token - botToken := r.Header.Get("X-Bot-Token") - if botToken != "" { - // Bot authentication will be handled separately - ctx := context.WithValue(r.Context(), AuthTypeKey, "bot") - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - if tokenString == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return diff --git a/internal/middleware/auth_human_test.go b/internal/middleware/auth_human_test.go index b649206..2028f25 100644 --- a/internal/middleware/auth_human_test.go +++ b/internal/middleware/auth_human_test.go @@ -45,6 +45,15 @@ func TestAuth(t *testing.T) { }, expectedStatus: http.StatusUnauthorized, }, + { + name: "BotHeaderDoesNotBypassHumanAuth", + tokenSetup: func() *http.Request { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Bot-Token", "ignored") + return req + }, + expectedStatus: http.StatusUnauthorized, + }, { name: "InvalidToken", tokenSetup: func() *http.Request { diff --git a/internal/middleware/auth_m2m.go b/internal/middleware/auth_m2m.go index c2d6aab..010d776 100644 --- a/internal/middleware/auth_m2m.go +++ b/internal/middleware/auth_m2m.go @@ -2,10 +2,14 @@ package middleware import ( "context" + "errors" "net/http" + "strings" "time" "github.com/capyrpi/api/internal/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) @@ -13,65 +17,58 @@ const BotTokenKey contextKey = "bot_token" // BotTokenInfo contains information about the authenticated bot type BotTokenInfo struct { - TokenID string - Name string + TokenID uuid.UUID + Name string + ExpiresAt *time.Time } // M2MAuth middleware validates bot tokens from X-Bot-Token header func M2MAuth(queries database.Querier) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := r.Header.Get("X-Bot-Token") - if token == "" { + rawToken := r.Header.Get("X-Bot-Token") + if rawToken == "" { http.Error(w, "Missing X-Bot-Token header", http.StatusUnauthorized) return } - // Get all active tokens and check against hash - // Note: In production with many tokens, implement a token prefix lookup - tokens, err := queries.ListBotTokens(r.Context()) + tokenID, secret, err := ParseBotToken(rawToken) if err != nil { - http.Error(w, "Internal server error", http.StatusInternalServerError) + http.Error(w, "Invalid bot token", http.StatusUnauthorized) return } - var matchedToken *database.ListBotTokensRow - for i, t := range tokens { - if !t.IsActive.Bool { - continue - } - // Check expiry - if t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now()) { - continue + token, err := queries.GetBotTokenByID(r.Context(), tokenID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Invalid bot token", http.StatusUnauthorized) + return } - // Note: We need the full token row with hash for comparison - // This simplified version won't work as ListBotTokens doesn't return hash - // You'd need a GetBotTokenByHash query that iterates or uses prefix matching - _ = t - matchedToken = &tokens[i] - break + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if !token.IsActive.Bool { + http.Error(w, "Bot token is inactive", http.StatusUnauthorized) + return + } + + if token.ExpiresAt.Valid && token.ExpiresAt.Time.Before(time.Now()) { + http.Error(w, "Bot token is expired", http.StatusUnauthorized) + return } - // For now, simplified validation - in real implementation: - // 1. Hash the provided token - // 2. Look up by hash in database - // 3. Verify expiry and active status - if matchedToken == nil { - // Try bcrypt comparison against stored hashes - // This requires fetching hashes which ListBotTokens doesn't do + if !ValidateBotToken(secret, token.TokenHash) { http.Error(w, "Invalid bot token", http.StatusUnauthorized) return } - // Update last used timestamp (fire and forget) - go func() { - _ = queries.UpdateBotTokenLastUsed(context.Background(), matchedToken.TokenID) - }() + _ = queries.UpdateBotTokenLastUsed(r.Context(), token.TokenID) - // Add bot info to context ctx := context.WithValue(r.Context(), BotTokenKey, &BotTokenInfo{ - TokenID: matchedToken.TokenID.String(), - Name: matchedToken.Name, + TokenID: token.TokenID, + Name: token.Name, + ExpiresAt: botTokenExpiry(token), }) ctx = context.WithValue(ctx, AuthTypeKey, "bot") @@ -91,3 +88,26 @@ func GetBotToken(ctx context.Context) (*BotTokenInfo, bool) { info, ok := ctx.Value(BotTokenKey).(*BotTokenInfo) return info, ok } + +func ParseBotToken(rawToken string) (uuid.UUID, string, error) { + tokenIDPart, secret, ok := strings.Cut(rawToken, ".") + if !ok || secret == "" { + return uuid.Nil, "", errors.New("invalid token format") + } + + tokenID, err := uuid.Parse(tokenIDPart) + if err != nil { + return uuid.Nil, "", err + } + + return tokenID, secret, nil +} + +func botTokenExpiry(token database.BotToken) *time.Time { + if !token.ExpiresAt.Valid { + return nil + } + + expiresAt := token.ExpiresAt.Time + return &expiresAt +} diff --git a/internal/router/router.go b/internal/router/router.go index 5895b73..1e2adfd 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -10,6 +10,50 @@ import ( httpSwagger "github.com/swaggo/http-swagger/v2" ) +func mountProtectedRoutes(r chi.Router, h *handler.Handler, jwtSecret string) { + r.Group(func(r chi.Router) { + r.Use(middleware.Auth(jwtSecret)) + + r.Route("/users", func(r chi.Router) { + r.Get("/{uid}", h.GetUser) + r.Put("/{uid}", h.UpdateUser) + r.Delete("/{uid}", h.DeleteUser) + r.Get("/{uid}/organizations", h.GetUserOrganizations) + r.Get("/{uid}/events", h.GetUserEvents) + }) + + r.Route("/organizations", func(r chi.Router) { + r.Get("/", h.ListOrganizations) + r.Post("/", h.CreateOrganization) + r.Get("/{oid}", h.GetOrganization) + r.Put("/{oid}", h.UpdateOrganization) + r.Delete("/{oid}", h.DeleteOrganization) + r.Get("/{oid}/members", h.ListOrgMembers) + r.Post("/{oid}/members", h.AddOrgMember) + r.Delete("/{oid}/members/{uid}", h.RemoveOrgMember) + r.Get("/{oid}/events", h.ListOrgEvents) + }) + + r.Route("/events", func(r chi.Router) { + r.Get("/", h.ListEvents) + r.Post("/", h.CreateEvent) + r.Get("/org/{oid}", h.ListEventsByOrg) + r.Get("/{eid}", h.GetEvent) + r.Put("/{eid}", h.UpdateEvent) + r.Delete("/{eid}", h.DeleteEvent) + r.Get("/{eid}/registrations", h.ListEventRegistrations) + r.Post("/{eid}/register", h.RegisterForEvent) + r.Delete("/{eid}/register", h.UnregisterFromEvent) + }) + + r.Route("/bot/tokens", func(r chi.Router) { + r.Get("/", h.ListBotTokens) + r.Post("/", h.CreateBotToken) + r.Delete("/{token_id}", h.RevokeBotToken) + }) + }) +} + // New creates a new chi router with all routes configured func New(h *handler.Handler, queries database.Querier, jwtSecret string, allowedOrigins []string) chi.Router { r := chi.NewRouter() @@ -50,52 +94,7 @@ func New(h *handler.Handler, queries database.Querier, jwtSecret string, allowed }) }) - // Protected routes - require human authentication - r.Group(func(r chi.Router) { - r.Use(middleware.Auth(jwtSecret)) - - // Users - r.Route("/users", func(r chi.Router) { - r.Get("/{uid}", h.GetUser) - r.Put("/{uid}", h.UpdateUser) - r.Delete("/{uid}", h.DeleteUser) - r.Get("/{uid}/organizations", h.GetUserOrganizations) - r.Get("/{uid}/events", h.GetUserEvents) - }) - - // Organizations - r.Route("/organizations", func(r chi.Router) { - r.Get("/", h.ListOrganizations) - r.Post("/", h.CreateOrganization) - r.Get("/{oid}", h.GetOrganization) - r.Put("/{oid}", h.UpdateOrganization) - r.Delete("/{oid}", h.DeleteOrganization) - r.Get("/{oid}/members", h.ListOrgMembers) - r.Post("/{oid}/members", h.AddOrgMember) - r.Delete("/{oid}/members/{uid}", h.RemoveOrgMember) - r.Get("/{oid}/events", h.ListOrgEvents) - }) - - // Events - r.Route("/events", func(r chi.Router) { - r.Get("/", h.ListEvents) - r.Post("/", h.CreateEvent) - r.Get("/org/{oid}", h.ListEventsByOrg) - r.Get("/{eid}", h.GetEvent) - r.Put("/{eid}", h.UpdateEvent) - r.Delete("/{eid}", h.DeleteEvent) - r.Get("/{eid}/registrations", h.ListEventRegistrations) - r.Post("/{eid}/register", h.RegisterForEvent) - r.Delete("/{eid}/register", h.UnregisterFromEvent) - }) - - // Bot token management (human auth only) - r.Route("/bot/tokens", func(r chi.Router) { - r.Get("/", h.ListBotTokens) - r.Post("/", h.CreateBotToken) - r.Delete("/{token_id}", h.RevokeBotToken) - }) - }) + mountProtectedRoutes(r, h, jwtSecret) // Bot routes (M2M auth) r.Group(func(r chi.Router) { @@ -134,5 +133,9 @@ func New(h *handler.Handler, queries database.Querier, jwtSecret string, allowed }) }) + r.Route("/v1", func(r chi.Router) { + mountProtectedRoutes(r, h, jwtSecret) + }) + return r } diff --git a/internal/router/router_auth_test.go b/internal/router/router_auth_test.go new file mode 100644 index 0000000..43a359d --- /dev/null +++ b/internal/router/router_auth_test.go @@ -0,0 +1,311 @@ +package router_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/capyrpi/api/internal/config" + "github.com/capyrpi/api/internal/database" + "github.com/capyrpi/api/internal/database/mocks" + "github.com/capyrpi/api/internal/handler" + "github.com/capyrpi/api/internal/middleware" + "github.com/capyrpi/api/internal/router" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +func TestBotTokenLifecycle(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + devID := uuid.New() + tokenID := uuid.New() + var storedHash string + devUser := database.User{ + Uid: devID, + Role: database.NullUserRole{UserRole: database.UserRoleDev, Valid: true}, + } + + mockQueries.On("GetUserByID", mock.Anything, devID).Return(devUser, nil).Times(3) + + mockQueries.On("CreateBotToken", mock.Anything, mock.MatchedBy(func(arg database.CreateBotTokenParams) bool { + storedHash = arg.TokenHash + return arg.Name == "deploy-bot" && arg.CreatedBy == devID && arg.ExpiresAt.Valid + })).Return(database.BotToken{ + TokenID: tokenID, + Name: "deploy-bot", + CreatedAt: pgTimestamp(time.Now().UTC()), + ExpiresAt: pgTimestamp(time.Now().UTC().Add(24 * time.Hour)), + IsActive: pgBool(true), + }, nil).Once() + + createReqBody := []byte(`{"name":"deploy-bot","expires_at":"2030-01-02T03:04:05Z"}`) + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/bot/tokens", bytes.NewReader(createReqBody)) + createReq.Header.Set("Authorization", "Bearer "+makeJWT(t, devID, string(database.UserRoleDev))) + createRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(createRes, createReq) + require.Equal(t, http.StatusCreated, createRes.Code) + + var createdTokenResp handler.BotTokenResponse + require.NoError(t, json.Unmarshal(createRes.Body.Bytes(), &createdTokenResp)) + assert.Equal(t, tokenID, createdTokenResp.TokenID) + assert.NotEmpty(t, createdTokenResp.Token) + + mockQueries.On("ListBotTokens", mock.Anything).Return([]database.ListBotTokensRow{ + { + TokenID: tokenID, + Name: "deploy-bot", + CreatedAt: pgTimestamp(time.Now().UTC()), + ExpiresAt: pgTimestamp(time.Now().UTC().Add(24 * time.Hour)), + IsActive: pgBool(true), + }, + }, nil).Once() + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/bot/tokens", nil) + listReq.Header.Set("Authorization", "Bearer "+makeJWT(t, devID, string(database.UserRoleDev))) + listRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(listRes, listReq) + require.Equal(t, http.StatusOK, listRes.Code) + + var listedTokens []map[string]any + require.NoError(t, json.Unmarshal(listRes.Body.Bytes(), &listedTokens)) + require.Len(t, listedTokens, 1) + _, tokenPresent := listedTokens[0]["token"] + assert.False(t, tokenPresent) + + mockQueries.On("GetBotTokenByID", mock.Anything, tokenID).Return(func(context.Context, uuid.UUID) database.BotToken { + return database.BotToken{ + TokenID: tokenID, + TokenHash: storedHash, + Name: "deploy-bot", + ExpiresAt: pgTimestamp(time.Now().UTC().Add(24 * time.Hour)), + IsActive: pgBool(true), + } + }, nil).Once() + mockQueries.On("UpdateBotTokenLastUsed", mock.Anything, tokenID).Return(nil).Once() + + meReq := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + meReq.Header.Set("X-Bot-Token", createdTokenResp.Token) + meRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(meRes, meReq) + require.Equal(t, http.StatusOK, meRes.Code) + + var meResp handler.BotMeResponse + require.NoError(t, json.Unmarshal(meRes.Body.Bytes(), &meResp)) + assert.Equal(t, tokenID, meResp.TokenID) + assert.Equal(t, "deploy-bot", meResp.Name) + assert.Equal(t, "bot", meResp.AuthType) + + mockQueries.On("RevokeBotToken", mock.Anything, tokenID).Return(nil).Once() + + revokeReq := httptest.NewRequest(http.MethodDelete, "/api/v1/bot/tokens/"+tokenID.String(), nil) + revokeReq.Header.Set("Authorization", "Bearer "+makeJWT(t, devID, string(database.UserRoleDev))) + revokeRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(revokeRes, revokeReq) + require.Equal(t, http.StatusNoContent, revokeRes.Code) + + mockQueries.On("GetBotTokenByID", mock.Anything, tokenID).Return(database.BotToken{ + TokenID: tokenID, + TokenHash: storedHash, + Name: "deploy-bot", + IsActive: pgBool(false), + }, nil).Once() + + revokedReq := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + revokedReq.Header.Set("X-Bot-Token", createdTokenResp.Token) + revokedRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(revokedRes, revokedReq) + assert.Equal(t, http.StatusUnauthorized, revokedRes.Code) +} + +func TestBotRouteAuthBoundaries(t *testing.T) { + t.Run("MissingBotHeaderFails", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("InvalidBotTokenFails", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + tokenID := uuid.New() + hash, err := bcryptHash("correct-secret") + require.NoError(t, err) + + mockQueries.On("GetBotTokenByID", mock.Anything, tokenID).Return(database.BotToken{ + TokenID: tokenID, + TokenHash: hash, + Name: "deploy-bot", + IsActive: pgBool(true), + }, nil).Once() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + req.Header.Set("X-Bot-Token", tokenID.String()+".wrong-secret") + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("ExpiredBotTokenFails", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + tokenID := uuid.New() + hash, err := bcryptHash("secret") + require.NoError(t, err) + + mockQueries.On("GetBotTokenByID", mock.Anything, tokenID).Return(database.BotToken{ + TokenID: tokenID, + TokenHash: hash, + Name: "deploy-bot", + ExpiresAt: pgTimestamp(time.Now().UTC().Add(-time.Minute)), + IsActive: pgBool(true), + }, nil).Once() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + req.Header.Set("X-Bot-Token", tokenID.String()+".secret") + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("HumanEndpointsRejectBotToken", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("X-Bot-Token", uuid.New().String()+".secret") + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("BotEndpointsRejectHumanJWT", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + req.Header.Set("Authorization", "Bearer "+makeJWT(t, uuid.New(), string(database.UserRoleDev))) + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) +} + +func TestBotTokenManagementRequiresDev(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + userID := uuid.New() + tokenID := uuid.New() + studentUser := database.User{ + Uid: userID, + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, + } + + mockQueries.On("GetUserByID", mock.Anything, userID).Return(studentUser, nil).Times(3) + + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/bot/tokens", bytes.NewBufferString(`{"name":"deploy-bot"}`)) + createReq.Header.Set("Authorization", "Bearer "+makeJWT(t, userID, string(database.UserRoleStudent))) + createRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(createRes, createReq) + assert.Equal(t, http.StatusForbidden, createRes.Code) + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/bot/tokens", nil) + listReq.Header.Set("Authorization", "Bearer "+makeJWT(t, userID, string(database.UserRoleStudent))) + listRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(listRes, listReq) + assert.Equal(t, http.StatusForbidden, listRes.Code) + + revokeReq := httptest.NewRequest(http.MethodDelete, "/api/v1/bot/tokens/"+tokenID.String(), nil) + revokeReq.Header.Set("Authorization", "Bearer "+makeJWT(t, userID, string(database.UserRoleStudent))) + revokeRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(revokeRes, revokeReq) + assert.Equal(t, http.StatusForbidden, revokeRes.Code) +} + +func TestBotTokenManagementUsesDatabaseRole(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + devID := uuid.New() + devUser := database.User{ + Uid: devID, + Role: database.NullUserRole{UserRole: database.UserRoleDev, Valid: true}, + } + + mockQueries.On("GetUserByID", mock.Anything, devID).Return(devUser, nil).Once() + mockQueries.On("ListBotTokens", mock.Anything).Return([]database.ListBotTokensRow{}, nil).Once() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/tokens", nil) + req.Header.Set("Authorization", "Bearer "+makeJWT(t, devID, string(database.UserRoleStudent))) + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusOK, res.Code) +} + +func newTestRouter(queries database.Querier) http.Handler { + cfg := &config.Config{ + Env: "test", + JWT: config.JWTConfig{ + Secret: "test-secret", + ExpiryHours: 24, + }, + } + + h := handler.New(queries, cfg) + return router.New(h, queries, cfg.JWT.Secret, nil) +} + +func makeJWT(t *testing.T, userID uuid.UUID, role string) string { + t.Helper() + + claims := middleware.UserClaims{ + UserID: userID.String(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte("test-secret")) + require.NoError(t, err) + return tokenString +} + +func pgBool(v bool) pgtype.Bool { + return pgtype.Bool{Bool: v, Valid: true} +} + +func pgTimestamp(v time.Time) pgtype.Timestamp { + return pgtype.Timestamp{Time: v, Valid: true} +} + +func bcryptHash(secret string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} diff --git a/migrations/20260318030206_telemetry_table.down.sql b/migrations/20260318030206_telemetry_table.down.sql new file mode 100644 index 0000000..dc9e460 --- /dev/null +++ b/migrations/20260318030206_telemetry_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS telemetry_interactions; +DROP TABLE IF EXISTS telemetry_completions; diff --git a/migrations/20260318030206_telemetry_table.up.sql b/migrations/20260318030206_telemetry_table.up.sql new file mode 100644 index 0000000..da7778b --- /dev/null +++ b/migrations/20260318030206_telemetry_table.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS telemetry_interactions ( + id BIGSERIAL PRIMARY KEY, + correlation_id VARCHAR(12) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + interaction_type VARCHAR(20) NOT NULL, + user_id BIGINT NOT NULL, + command_name VARCHAR(100), + guild_id BIGINT, + guild_name VARCHAR(100), + channel_id BIGINT NOT NULL, + options JSONB NOT NULL DEFAULT '{}'::jsonb, + bot_version VARCHAR(20) NOT NULL DEFAULT 'unknown' +); + +CREATE TABLE IF NOT EXISTS telemetry_completions ( + id BIGSERIAL PRIMARY KEY, + correlation_id VARCHAR(12) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + command_name VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL, + duration_ms NUMERIC(10,2), + error_type VARCHAR(100), + CONSTRAINT telemetry_completions_status_check + CHECK (status IN ('success', 'user_error', 'internal_error')) +); diff --git a/migrations/20260319120000_add_dev_user_role.down.sql b/migrations/20260319120000_add_dev_user_role.down.sql new file mode 100644 index 0000000..6b4d569 --- /dev/null +++ b/migrations/20260319120000_add_dev_user_role.down.sql @@ -0,0 +1,2 @@ +-- PostgreSQL enums do not support dropping a value directly. +-- No-op down migration. diff --git a/migrations/20260319120000_add_dev_user_role.up.sql b/migrations/20260319120000_add_dev_user_role.up.sql new file mode 100644 index 0000000..6dc0c62 --- /dev/null +++ b/migrations/20260319120000_add_dev_user_role.up.sql @@ -0,0 +1 @@ +ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'dev'; diff --git a/schema.sql b/schema.sql index 160c511..d67c444 100644 --- a/schema.sql +++ b/schema.sql @@ -2,7 +2,7 @@ -- Database Schema for CAPY (Club Assistant in Python) -- 1. ENUMs & Functions -CREATE TYPE user_role AS ENUM ('student', 'alumni', 'faculty', 'external'); +CREATE TYPE user_role AS ENUM ('student', 'alumni', 'faculty', 'external', 'dev'); CREATE OR REPLACE FUNCTION update_modified_column() RETURNS TRIGGER AS $$ @@ -88,4 +88,4 @@ DROP TRIGGER IF EXISTS update_orgs_modtime ON organizations; CREATE TRIGGER update_orgs_modtime BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION update_modified_column(); DROP TRIGGER IF EXISTS update_events_modtime ON events; -CREATE TRIGGER update_events_modtime BEFORE UPDATE ON events FOR EACH ROW EXECUTE FUNCTION update_modified_column(); \ No newline at end of file +CREATE TRIGGER update_events_modtime BEFORE UPDATE ON events FOR EACH ROW EXECUTE FUNCTION update_modified_column(); diff --git a/scripts/create_bot_token/main.go b/scripts/create_bot_token/main.go new file mode 100644 index 0000000..6f7b63c --- /dev/null +++ b/scripts/create_bot_token/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "flag" + "fmt" + "log" + "time" + + "github.com/capyrpi/api/internal/config" + "github.com/capyrpi/api/internal/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" +) + +func main() { + envFile := flag.String("env", ".env", "Path to .env file") + name := flag.String("name", "", "Bot token name (required)") + createdBy := flag.String("created-by", "", "Creator user ID (UUID, required)") + hours := flag.Int("hours", 0, "Token validity in hours (0 means no expiry)") + flag.Parse() + + if *name == "" { + log.Fatal("name is required") + } + + creatorID, err := uuid.Parse(*createdBy) + if err != nil { + log.Fatalf("invalid created-by UUID: %v", err) + } + + if *hours < 0 { + log.Fatal("hours must be >= 0") + } + + if err := godotenv.Load(*envFile); err != nil { + log.Printf("Warning: Error loading .env file: %v", err) + } + + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + ctx := context.Background() + pool, err := database.NewPool(ctx, cfg.Database.URL) + if err != nil { + log.Fatalf("Unable to connect to database: %v", err) + } + defer pool.Close() + + rawToken, err := generateSecureToken(32) + if err != nil { + log.Fatalf("Failed to generate token: %v", err) + } + + hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("Failed to hash token: %v", err) + } + + token, err := database.New(pool).CreateBotToken(ctx, database.CreateBotTokenParams{ + TokenHash: string(hashedToken), + Name: *name, + CreatedBy: creatorID, + ExpiresAt: expiryTimestamp(*hours), + }) + if err != nil { + log.Fatalf("Failed to create bot token: %v", err) + } + + fmt.Println("\nBot token created successfully.") + fmt.Println("---------------------------------------------------") + fmt.Printf("Token ID: %s\n", token.TokenID) + fmt.Printf("Name: %s\n", token.Name) + fmt.Printf("Created By: %s\n", token.CreatedBy) + if token.ExpiresAt.Valid { + fmt.Printf("Expires At: %s\n", token.ExpiresAt.Time.Format(time.RFC3339)) + } else { + fmt.Println("Expires At: never") + } + fmt.Println("---------------------------------------------------") + fmt.Println("\nUsage with curl:") + fmt.Printf("curl -H \"X-Bot-Token: %s\" http://localhost:8080/api/v1/bot/me\n", formatBotToken(token.TokenID, rawToken)) + fmt.Println("\nToken:") + fmt.Println(formatBotToken(token.TokenID, rawToken)) +} + +func generateSecureToken(byteLen int) (string, error) { + buf := make([]byte, byteLen) + if _, err := rand.Read(buf); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func expiryTimestamp(hours int) pgtype.Timestamp { + if hours == 0 { + return pgtype.Timestamp{Valid: false} + } + + return pgtype.Timestamp{ + Time: time.Now().Add(time.Duration(hours) * time.Hour), + Valid: true, + } +} + +func formatBotToken(tokenID uuid.UUID, secret string) string { + return tokenID.String() + "." + secret +} diff --git a/scripts/create_dev_user/main.go b/scripts/create_dev_user/main.go deleted file mode 100644 index 1fef6fc..0000000 --- a/scripts/create_dev_user/main.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "log" - "time" - - "github.com/capyrpi/api/internal/config" - "github.com/capyrpi/api/internal/database" - "github.com/capyrpi/api/internal/middleware" - "github.com/golang-jwt/jwt/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/joho/godotenv" -) - -func main() { - envFile := flag.String("env", ".env", "Path to .env file") - flag.Parse() - - if err := godotenv.Load(*envFile); err != nil { - log.Printf("Warning: Error loading .env file: %v", err) - } - - cfg, err := config.Load() - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - // Connect to DB - pool, err := pgxpool.New(context.Background(), cfg.Database.URL) - if err != nil { - log.Fatalf("Unable to connect to database: %v", err) - } - defer pool.Close() - - queries := database.New(pool) - ctx := context.Background() - - // Create User - // Note: UID is generated by DB - user, err := queries.CreateUser(ctx, database.CreateUserParams{ - FirstName: "Dev", - LastName: "User", - PersonalEmail: pgtype.Text{String: "dev@example.com", Valid: true}, - Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, - }) - if err != nil { - // Try to find existing if duplicate - log.Printf("User creation failed (likely exists), finding by email...") - user, err = queries.GetUserByEmail(ctx, pgtype.Text{String: "dev@example.com", Valid: true}) - if err != nil { - log.Fatalf("Failed to create or find user: %v", err) - } - } - - // Create Token - claims := middleware.UserClaims{ - UserID: user.Uid.String(), - Email: user.PersonalEmail.String, - Role: "student", - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(cfg.JWT.Secret)) - if err != nil { - log.Fatalf("Failed to sign token: %v", err) - } - - fmt.Println("\n✅ User Seeded & Token Generated!") - fmt.Println("---------------------------------------------------") - fmt.Printf("User Name: %s %s\n", user.FirstName, user.LastName) - fmt.Printf("User ID: %s\n", user.Uid) - fmt.Println("---------------------------------------------------") - fmt.Println("\nTry this command now:") - fmt.Printf("curl -H \"Authorization: Bearer %s\" http://localhost:8080/api/v1/auth/me\n", tokenString) -} diff --git a/scripts/create_user/main.go b/scripts/create_user/main.go new file mode 100644 index 0000000..f6146e3 --- /dev/null +++ b/scripts/create_user/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "strings" + "time" + + "github.com/capyrpi/api/internal/config" + "github.com/capyrpi/api/internal/database" + "github.com/capyrpi/api/internal/middleware" + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" +) + +func main() { + envFile := flag.String("env", ".env", "Path to .env file") + email := flag.String("email", "", "User email (required)") + firstName := flag.String("first-name", "Dev", "User first name") + lastName := flag.String("last-name", "User", "User last name") + role := flag.String("role", string(database.UserRoleStudent), "User role") + hours := flag.Int("hours", 24, "Token validity in hours") + flag.Parse() + + normalizedEmail := normalizeEmail(*email) + if normalizedEmail == "" { + log.Fatal("email is required") + } + + userRole, err := parseRole(*role) + if err != nil { + log.Fatal(err) + } + + if err := godotenv.Load(*envFile); err != nil { + log.Printf("Warning: Error loading .env file: %v", err) + } + + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + pool, err := pgxpool.New(context.Background(), cfg.Database.URL) + if err != nil { + log.Fatalf("Unable to connect to database: %v", err) + } + defer pool.Close() + + queries := database.New(pool) + ctx := context.Background() + emailText := pgtype.Text{String: normalizedEmail, Valid: true} + + user, err := queries.GetUserByEmail(ctx, emailText) + if err != nil { + if err != pgx.ErrNoRows { + log.Fatalf("Failed to look up user: %v", err) + } + + user, err = queries.CreateUser(ctx, database.CreateUserParams{ + FirstName: *firstName, + LastName: *lastName, + PersonalEmail: emailText, + Role: database.NullUserRole{UserRole: userRole, Valid: true}, + }) + if err != nil { + log.Fatalf("Failed to create user: %v", err) + } + + printResult("created", user, mustMintToken(user, cfg, *hours)) + return + } + + user, err = queries.UpdateUser(ctx, database.UpdateUserParams{ + Uid: user.Uid, + FirstName: pgtype.Text{String: *firstName, Valid: true}, + LastName: pgtype.Text{String: *lastName, Valid: true}, + PersonalEmail: emailText, + SchoolEmail: pgtype.Text{Valid: false}, + Phone: pgtype.Text{Valid: false}, + GradYear: pgtype.Int4{Valid: false}, + Role: database.NullUserRole{UserRole: userRole, Valid: true}, + }) + if err != nil { + log.Fatalf("Failed to update user: %v", err) + } + + printResult("updated", user, mustMintToken(user, cfg, *hours)) +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func parseRole(raw string) (database.UserRole, error) { + role := database.UserRole(strings.ToLower(strings.TrimSpace(raw))) + + switch role { + case database.UserRoleStudent, database.UserRoleAlumni, database.UserRoleFaculty, database.UserRoleExternal, database.UserRoleDev: + return role, nil + default: + return "", fmt.Errorf("invalid role %q", raw) + } +} + +func mustMintToken(user database.User, cfg *config.Config, hours int) string { + claims := middleware.UserClaims{ + UserID: user.Uid.String(), + Email: user.PersonalEmail.String, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(hours) * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "capy-api", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(cfg.JWT.Secret)) + if err != nil { + log.Fatalf("Failed to sign token: %v", err) + } + return tokenString +} + +func printResult(action string, user database.User, token string) { + fmt.Printf("\nUser %s successfully.\n", action) + fmt.Println("---------------------------------------------------") + fmt.Printf("User Name: %s %s\n", user.FirstName, user.LastName) + fmt.Printf("User ID: %s\n", user.Uid) + if user.PersonalEmail.Valid { + fmt.Printf("Email: %s\n", user.PersonalEmail.String) + } + if user.Role.Valid { + fmt.Printf("Role: %s\n", user.Role.UserRole) + } + fmt.Println("---------------------------------------------------") + fmt.Println("\nUsage with curl:") + fmt.Printf("curl -H \"Authorization: Bearer %s\" http://localhost:8080/api/v1/auth/me\n", token) + fmt.Println("\nToken:") + fmt.Println(token) +} diff --git a/scripts/generate_token/main.go b/scripts/generate_token/main.go deleted file mode 100644 index f65c859..0000000 --- a/scripts/generate_token/main.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "time" - - "github.com/capyrpi/api/internal/config" - "github.com/capyrpi/api/internal/middleware" - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" - "github.com/joho/godotenv" -) - -func main() { - // flags - envFile := flag.String("env", ".env", "Path to .env file") - uid := flag.String("uid", "", "User ID (UUID). If empty, generates a random one.") - email := flag.String("email", "dev@example.com", "User email") - role := flag.String("role", "student", "User role") - hours := flag.Int("hours", 24, "Token validity in hours") - flag.Parse() - - // Load .env if present - if err := godotenv.Load(*envFile); err != nil { - log.Printf("Warning: Error loading .env file: %v. Assuming environment variables are set.", err) - } - - // Load config to get secret - cfg, err := config.Load() - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - // Determine UID - var userID string - if *uid == "" { - userID = uuid.New().String() - fmt.Printf("Generated random User ID: %s\n", userID) - } else { - userID = *uid - // Validate UUID - if _, err := uuid.Parse(userID); err != nil { - log.Fatalf("Invalid UUID format: %v", err) - } - } - - // Create Claims - claims := middleware.UserClaims{ - UserID: userID, - Email: *email, - Role: *role, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(*hours) * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - - // Sign Token - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(cfg.JWT.Secret)) - if err != nil { - log.Fatalf("Failed to sign token: %v", err) - } - - fmt.Println("\n✅ Token Generated Successfully!") - fmt.Println("---------------------------------------------------") - fmt.Printf("User ID: %s\n", userID) - fmt.Printf("Email: %s\n", *email) - fmt.Printf("Role: %s\n", *role) - fmt.Println("---------------------------------------------------") - fmt.Println("\nUsage with curl:") - fmt.Printf("curl -H \"Authorization: Bearer %s\" http://localhost:8080/api/v1/auth/me\n", tokenString) - fmt.Println("\nToken:") - fmt.Println(tokenString) -} diff --git a/tests/benchmarks/suite_test.go b/tests/benchmarks/suite_test.go index 6d950d3..66f574b 100644 --- a/tests/benchmarks/suite_test.go +++ b/tests/benchmarks/suite_test.go @@ -156,7 +156,6 @@ func setupTestData(ctx context.Context) { claims := middleware.UserClaims{ UserID: benchUserID, Email: "bench@example.com", - Role: "student", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), diff --git a/tests/integration/api_test.go b/tests/integration/api_test.go index 45330c7..850ae27 100644 --- a/tests/integration/api_test.go +++ b/tests/integration/api_test.go @@ -20,6 +20,7 @@ import ( "github.com/capyrpi/api/internal/router" "github.com/capyrpi/api/internal/testutils" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -696,3 +697,281 @@ func TestAddDuplicateUser(t *testing.T) { schoolUser, err := q.GetUserByEmail(ctx, pgtype.Text{String: "testuser@rpi.edu", Valid: true}) assert.Equal(t, addedUser.Uid, schoolUser.Uid) } + +func TestBotRoutes(t *testing.T) { + pool := testutils.SetupTestDB(t) + defer pool.Close() + + q := database.New(pool) + cfg := &config.Config{JWT: config.JWTConfig{Secret: "test-secret", ExpiryHours: 1}} + h := handler.New(q, cfg) + r := router.New(h, q, cfg.JWT.Secret, []string{}) + server := httptest.NewServer(r) + defer server.Close() + client := server.Client() + + ctx := context.Background() + devUser, err := q.CreateUser(ctx, database.CreateUserParams{ + FirstName: "Bot", + LastName: "Admin", + Role: database.NullUserRole{UserRole: database.UserRoleDev, Valid: true}, + }) + require.NoError(t, err) + + member, err := q.CreateUser(ctx, database.CreateUserParams{ + FirstName: "Bot", + LastName: "Member", + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, + }) + require.NoError(t, err) + + botToken := createIntegrationBotToken(t, client, server.URL, cfg.JWT.Secret, devUser.Uid) + + meReq, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/bot/me", nil) + meReq.Header.Set("X-Bot-Token", botToken) + meResp, err := client.Do(meReq) + require.NoError(t, err) + defer meResp.Body.Close() + require.Equal(t, http.StatusOK, meResp.StatusCode) + + var botMe handler.BotMeResponse + require.NoError(t, json.NewDecoder(meResp.Body).Decode(&botMe)) + assert.Equal(t, "bot", botMe.AuthType) + + orgCreateReq, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/bot/organizations", bytes.NewBufferString(`{"name":"Bot Org"}`)) + orgCreateReq.Header.Set("Content-Type", "application/json") + orgCreateReq.Header.Set("X-Bot-Token", botToken) + orgCreateResp, err := client.Do(orgCreateReq) + require.NoError(t, err) + defer orgCreateResp.Body.Close() + require.Equal(t, http.StatusCreated, orgCreateResp.StatusCode) + + var createdOrg dto.OrganizationResponse + require.NoError(t, json.NewDecoder(orgCreateResp.Body).Decode(&createdOrg)) + assert.Equal(t, "Bot Org", createdOrg.Name) + + orgListReq, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/bot/organizations", nil) + orgListReq.Header.Set("X-Bot-Token", botToken) + orgListResp, err := client.Do(orgListReq) + require.NoError(t, err) + defer orgListResp.Body.Close() + require.Equal(t, http.StatusOK, orgListResp.StatusCode) + + var orgs []dto.OrganizationResponse + require.NoError(t, json.NewDecoder(orgListResp.Body).Decode(&orgs)) + require.NotEmpty(t, orgs) + + orgGetReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/organizations/%s", server.URL, createdOrg.OID), nil) + orgGetReq.Header.Set("X-Bot-Token", botToken) + orgGetResp, err := client.Do(orgGetReq) + require.NoError(t, err) + defer orgGetResp.Body.Close() + require.Equal(t, http.StatusOK, orgGetResp.StatusCode) + + var fetchedOrg dto.OrganizationResponse + require.NoError(t, json.NewDecoder(orgGetResp.Body).Decode(&fetchedOrg)) + assert.Equal(t, createdOrg.OID, fetchedOrg.OID) + + orgUpdateReq, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/bot/organizations/%s", server.URL, createdOrg.OID), bytes.NewBufferString(`{"name":"Bot Org Updated"}`)) + orgUpdateReq.Header.Set("Content-Type", "application/json") + orgUpdateReq.Header.Set("X-Bot-Token", botToken) + orgUpdateResp, err := client.Do(orgUpdateReq) + require.NoError(t, err) + defer orgUpdateResp.Body.Close() + require.Equal(t, http.StatusOK, orgUpdateResp.StatusCode) + + var updatedOrg dto.OrganizationResponse + require.NoError(t, json.NewDecoder(orgUpdateResp.Body).Decode(&updatedOrg)) + assert.Equal(t, "Bot Org Updated", updatedOrg.Name) + + memberListReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/organizations/%s/members", server.URL, createdOrg.OID), nil) + memberListReq.Header.Set("X-Bot-Token", botToken) + memberListResp, err := client.Do(memberListReq) + require.NoError(t, err) + defer memberListResp.Body.Close() + require.Equal(t, http.StatusOK, memberListResp.StatusCode) + + var initialMembers []dto.OrgMemberResponse + require.NoError(t, json.NewDecoder(memberListResp.Body).Decode(&initialMembers)) + assert.Len(t, initialMembers, 0) + + addMemberReq, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/bot/organizations/%s/members", server.URL, createdOrg.OID), bytes.NewBufferString(fmt.Sprintf(`{"uid":"%s","is_admin":false}`, member.Uid))) + addMemberReq.Header.Set("Content-Type", "application/json") + addMemberReq.Header.Set("X-Bot-Token", botToken) + addMemberResp, err := client.Do(addMemberReq) + require.NoError(t, err) + defer addMemberResp.Body.Close() + require.Equal(t, http.StatusCreated, addMemberResp.StatusCode) + + memberListReq, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/organizations/%s/members", server.URL, createdOrg.OID), nil) + memberListReq.Header.Set("X-Bot-Token", botToken) + memberListResp, err = client.Do(memberListReq) + require.NoError(t, err) + defer memberListResp.Body.Close() + require.Equal(t, http.StatusOK, memberListResp.StatusCode) + + var orgMembers []dto.OrgMemberResponse + require.NoError(t, json.NewDecoder(memberListResp.Body).Decode(&orgMembers)) + require.Len(t, orgMembers, 1) + assert.Equal(t, member.Uid, orgMembers[0].UID) + + userReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/users/%s", server.URL, member.Uid), nil) + userReq.Header.Set("X-Bot-Token", botToken) + userResp, err := client.Do(userReq) + require.NoError(t, err) + defer userResp.Body.Close() + require.Equal(t, http.StatusOK, userResp.StatusCode) + + var user dto.UserResponse + require.NoError(t, json.NewDecoder(userResp.Body).Decode(&user)) + assert.Equal(t, member.Uid, user.UID) + + userOrgsReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/users/%s/organizations", server.URL, member.Uid), nil) + userOrgsReq.Header.Set("X-Bot-Token", botToken) + userOrgsResp, err := client.Do(userOrgsReq) + require.NoError(t, err) + defer userOrgsResp.Body.Close() + require.Equal(t, http.StatusOK, userOrgsResp.StatusCode) + + var userOrgs []dto.OrganizationResponse + require.NoError(t, json.NewDecoder(userOrgsResp.Body).Decode(&userOrgs)) + require.Len(t, userOrgs, 1) + assert.Equal(t, createdOrg.OID, userOrgs[0].OID) + + eventTime := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339) + eventCreateReq, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/bot/events", bytes.NewBufferString(fmt.Sprintf(`{"org_id":"%s","location":"Bot Hall","description":"Bot Event","event_time":"%s"}`, createdOrg.OID, eventTime))) + eventCreateReq.Header.Set("Content-Type", "application/json") + eventCreateReq.Header.Set("X-Bot-Token", botToken) + eventCreateResp, err := client.Do(eventCreateReq) + require.NoError(t, err) + defer eventCreateResp.Body.Close() + require.Equal(t, http.StatusCreated, eventCreateResp.StatusCode) + + var createdEvent dto.EventResponse + require.NoError(t, json.NewDecoder(eventCreateResp.Body).Decode(&createdEvent)) + require.NotEqual(t, uuid.Nil, createdEvent.EID) + + eventListReq, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/bot/events", nil) + eventListReq.Header.Set("X-Bot-Token", botToken) + eventListResp, err := client.Do(eventListReq) + require.NoError(t, err) + defer eventListResp.Body.Close() + require.Equal(t, http.StatusOK, eventListResp.StatusCode) + + var events []dto.EventResponse + require.NoError(t, json.NewDecoder(eventListResp.Body).Decode(&events)) + require.NotEmpty(t, events) + + eventGetReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/events/%s", server.URL, createdEvent.EID), nil) + eventGetReq.Header.Set("X-Bot-Token", botToken) + eventGetResp, err := client.Do(eventGetReq) + require.NoError(t, err) + defer eventGetResp.Body.Close() + require.Equal(t, http.StatusOK, eventGetResp.StatusCode) + + var fetchedEvent dto.EventResponse + require.NoError(t, json.NewDecoder(eventGetResp.Body).Decode(&fetchedEvent)) + assert.Equal(t, createdEvent.EID, fetchedEvent.EID) + + eventUpdateReq, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/bot/events/%s", server.URL, createdEvent.EID), bytes.NewBufferString(`{"location":"Bot Hall Updated","description":"Updated Bot Event"}`)) + eventUpdateReq.Header.Set("Content-Type", "application/json") + eventUpdateReq.Header.Set("X-Bot-Token", botToken) + eventUpdateResp, err := client.Do(eventUpdateReq) + require.NoError(t, err) + defer eventUpdateResp.Body.Close() + require.Equal(t, http.StatusOK, eventUpdateResp.StatusCode) + + var updatedEvent dto.EventResponse + require.NoError(t, json.NewDecoder(eventUpdateResp.Body).Decode(&updatedEvent)) + require.NotNil(t, updatedEvent.Location) + assert.Equal(t, "Bot Hall Updated", *updatedEvent.Location) + + registerReq, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/bot/events/%s/register", server.URL, createdEvent.EID), bytes.NewBufferString(fmt.Sprintf(`{"uid":"%s","is_attending":true}`, member.Uid))) + registerReq.Header.Set("Content-Type", "application/json") + registerReq.Header.Set("X-Bot-Token", botToken) + registerResp, err := client.Do(registerReq) + require.NoError(t, err) + defer registerResp.Body.Close() + require.Equal(t, http.StatusCreated, registerResp.StatusCode) + + regListReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/events/%s/registrations", server.URL, createdEvent.EID), nil) + regListReq.Header.Set("X-Bot-Token", botToken) + regListResp, err := client.Do(regListReq) + require.NoError(t, err) + defer regListResp.Body.Close() + require.Equal(t, http.StatusOK, regListResp.StatusCode) + + var regs []dto.EventRegistrationResponse + require.NoError(t, json.NewDecoder(regListResp.Body).Decode(®s)) + require.Len(t, regs, 1) + assert.Equal(t, member.Uid, regs[0].UID) + + userEventsReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/users/%s/events", server.URL, member.Uid), nil) + userEventsReq.Header.Set("X-Bot-Token", botToken) + userEventsResp, err := client.Do(userEventsReq) + require.NoError(t, err) + defer userEventsResp.Body.Close() + require.Equal(t, http.StatusOK, userEventsResp.StatusCode) + + var userEvents []dto.EventResponse + require.NoError(t, json.NewDecoder(userEventsResp.Body).Decode(&userEvents)) + require.Len(t, userEvents, 1) + assert.Equal(t, createdEvent.EID, userEvents[0].EID) + + unregisterReq, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/bot/events/%s/register?uid=%s", server.URL, createdEvent.EID, member.Uid), nil) + unregisterReq.Header.Set("X-Bot-Token", botToken) + unregisterResp, err := client.Do(unregisterReq) + require.NoError(t, err) + defer unregisterResp.Body.Close() + require.Equal(t, http.StatusNoContent, unregisterResp.StatusCode) + + removeMemberReq, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/bot/organizations/%s/members/%s", server.URL, createdOrg.OID, member.Uid), nil) + removeMemberReq.Header.Set("X-Bot-Token", botToken) + removeMemberResp, err := client.Do(removeMemberReq) + require.NoError(t, err) + defer removeMemberResp.Body.Close() + require.Equal(t, http.StatusNoContent, removeMemberResp.StatusCode) + + deleteEventReq, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/bot/events/%s", server.URL, createdEvent.EID), nil) + deleteEventReq.Header.Set("X-Bot-Token", botToken) + deleteEventResp, err := client.Do(deleteEventReq) + require.NoError(t, err) + defer deleteEventResp.Body.Close() + require.Equal(t, http.StatusNoContent, deleteEventResp.StatusCode) + + deleteOrgReq, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/bot/organizations/%s", server.URL, createdOrg.OID), nil) + deleteOrgReq.Header.Set("X-Bot-Token", botToken) + deleteOrgResp, err := client.Do(deleteOrgReq) + require.NoError(t, err) + defer deleteOrgResp.Body.Close() + require.Equal(t, http.StatusNoContent, deleteOrgResp.StatusCode) +} + +func createIntegrationBotToken(t *testing.T, client *http.Client, serverURL, jwtSecret string, devUserID uuid.UUID) string { + t.Helper() + + claims := middleware.UserClaims{ + UserID: devUserID.String(), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(jwtSecret)) + require.NoError(t, err) + + req, _ := http.NewRequest(http.MethodPost, serverURL+"/api/v1/bot/tokens", bytes.NewBufferString(`{"name":"integration-bot"}`)) + req.Header.Set("Authorization", "Bearer "+tokenString) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + + var botTokenResp handler.BotTokenResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&botTokenResp)) + require.NotEmpty(t, botTokenResp.Token) + + return botTokenResp.Token +}