From 6a6e95b30362372f79c9b329c43e04186f4c54b2 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Wed, 18 Mar 2026 10:29:46 -0400 Subject: [PATCH 1/3] feat(bot): api bot tokens implemented --- docker-compose.yml | 16 +- docs/schema/schema.json | 10 +- docs/swagger/docs.go | 25 ++- docs/swagger/swagger.json | 27 ++- docs/swagger/swagger.yaml | 20 +- internal/database/mocks/Querier.go | 20 +- internal/database/querier.go | 2 +- internal/database/queries.sql | 4 +- internal/database/queries.sql.go | 8 +- internal/handler/auth.go | 76 +++++-- internal/middleware/auth.go | 9 - internal/middleware/auth_human_test.go | 9 + internal/middleware/auth_m2m.go | 92 ++++---- internal/router/router.go | 95 +++++---- internal/router/router_auth_test.go | 279 +++++++++++++++++++++++++ 15 files changed, 540 insertions(+), 152 deletions(-) create mode 100644 internal/router/router_auth_test.go diff --git a/docker-compose.yml b/docker-compose.yml index 6594f6f..5ceb332 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,14 +25,14 @@ services: db: condition: service_healthy - tunnel: - image: cloudflare/cloudflared:latest - restart: unless-stopped - command: tunnel run - env_file: - - .env - depends_on: - - api + # tunnel: + # image: cloudflare/cloudflared:latest + # restart: unless-stopped + # command: tunnel run + # env_file: + # - .env + # depends_on: + # - api volumes: pgdata: 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/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..6c9cf49 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -134,8 +134,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..10d77b2 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, diff --git a/internal/handler/auth.go b/internal/handler/auth.go index dbbc3cf..d389a90 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"` @@ -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.requireFaculty(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 faculty 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.requireFacultyClaims(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,6 +398,8 @@ 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, @@ -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.requireFaculty(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()), }) } @@ -608,3 +624,27 @@ func generateSecureToken(length int) (string, error) { } return hex.EncodeToString(bytes), nil } + +func formatBotToken(tokenID uuid.UUID, secret string) string { + return tokenID.String() + "." + secret +} + +func (h *Handler) requireFaculty(w http.ResponseWriter, r *http.Request) bool { + _, ok := h.requireFacultyClaims(w, r) + return ok +} + +func (h *Handler) requireFacultyClaims(w http.ResponseWriter, r *http.Request) (*middleware.UserClaims, bool) { + claims, ok := middleware.GetUserClaims(r.Context()) + if !ok { + h.respondError(w, http.StatusUnauthorized, "Not authenticated") + return nil, false + } + + if claims.Role != string(database.UserRoleFaculty) { + h.respondError(w, http.StatusForbidden, "Faculty role required") + return nil, false + } + + return claims, true +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ce7d3c7..1503c27 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -43,15 +43,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..4079fa7 --- /dev/null +++ b/internal/router/router_auth_test.go @@ -0,0 +1,279 @@ +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) + + facultyID := uuid.New() + tokenID := uuid.New() + var storedHash string + + mockQueries.On("CreateBotToken", mock.Anything, mock.MatchedBy(func(arg database.CreateBotTokenParams) bool { + storedHash = arg.TokenHash + return arg.Name == "deploy-bot" && arg.CreatedBy == facultyID && 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, facultyID, string(database.UserRoleFaculty))) + 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, facultyID, string(database.UserRoleFaculty))) + 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, facultyID, string(database.UserRoleFaculty))) + 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.UserRoleFaculty))) + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) +} + +func TestBotTokenManagementRequiresFaculty(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + userID := uuid.New() + tokenID := uuid.New() + + 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 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(), + Role: role, + 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 +} From f782b7d667577a847e75147087bf5928c301bb91 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Wed, 18 Mar 2026 10:43:34 -0400 Subject: [PATCH 2/3] feat(tests): added testing for bot routes --- tests/integration/api_test.go | 280 ++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) diff --git a/tests/integration/api_test.go b/tests/integration/api_test.go index 45330c7..b826787 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,282 @@ 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() + faculty, err := q.CreateUser(ctx, database.CreateUserParams{ + FirstName: "Bot", + LastName: "Admin", + Role: database.NullUserRole{UserRole: database.UserRoleFaculty, 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, faculty.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, facultyID uuid.UUID) string { + t.Helper() + + claims := middleware.UserClaims{ + UserID: facultyID.String(), + Role: string(database.UserRoleFaculty), + 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 +} From 2c3b2ec9a2f6fc45a177ef6643257167e7712b67 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 19 Mar 2026 15:16:34 -0400 Subject: [PATCH 3/3] fix: docker compose --- docker-compose.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5ceb332..6594f6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,14 +25,14 @@ services: db: condition: service_healthy - # tunnel: - # image: cloudflare/cloudflared:latest - # restart: unless-stopped - # command: tunnel run - # env_file: - # - .env - # depends_on: - # - api + tunnel: + image: cloudflare/cloudflared:latest + restart: unless-stopped + command: tunnel run + env_file: + - .env + depends_on: + - api volumes: pgdata: