Merged in feature/eula.part1 (pull request #206)

eula support

* eula support

* docs
This commit is contained in:
Jay Brown
2026-01-22 18:17:27 +00:00
parent c10fa98d0a
commit 63c12a2f44
38 changed files with 15646 additions and 230 deletions
+2 -1
View File
@@ -36,9 +36,10 @@ func getAdminUserForAudit(ctx echo.Context) (cognitoauth.UserInfo, error) {
}
// If auth is disabled, use a default system user for audit logging
// Note: Email must be a valid format (with TLD) to pass openapi_types.Email validation
if os.Getenv("DISABLE_AUTH") == "true" {
return cognitoauth.UserInfo{
Email: "system@localhost",
Email: "system@test.local",
Username: "system",
Groups: []string{"admin"},
}, nil
+814 -214
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,6 +9,7 @@ import (
"queryorchestration/internal/document"
documentbatch "queryorchestration/internal/document/batch"
documentupload "queryorchestration/internal/document/upload"
"queryorchestration/internal/eula"
"queryorchestration/internal/export"
"queryorchestration/internal/fieldextraction"
"queryorchestration/internal/folder"
@@ -33,6 +34,7 @@ type Services struct {
FieldExtraction *fieldextraction.Service
Folder *folder.Service
Label *label.Service
Eula *eula.Service
}
// ConfigProvider combines auth and objectstore interfaces for the Controllers
+520
View File
@@ -0,0 +1,520 @@
// eulaAdminHandlers.go implements the admin EULA API handlers.
// These endpoints handle EULA version management, agreement listing, and compliance reporting.
package queryapi
import (
"context"
"errors"
"net/http"
"strings"
"time"
"queryorchestration/internal/database/repository"
"queryorchestration/internal/eula"
"queryorchestration/internal/serviceconfig/aws"
"github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/oapi-codegen/nullable"
openapi_types "github.com/oapi-codegen/runtime/types"
)
// ListEulaVersions returns a paginated list of all EULA versions.
// GET /admin/eula
//
// Requires AdminService permission.
//
// Parameters:
// - page: Page number (1-based, default: 1)
// - page_size: Items per page (1-100, default: 20)
//
// Returns:
// - 200 OK with EulaVersionList on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 500 Internal Server Error on database errors
func (s *Controllers) ListEulaVersions(ctx echo.Context, params ListEulaVersionsParams) error {
// Apply defaults
page := int32(1)
pageSize := int32(20)
if params.Page != nil {
page = *params.Page
}
if params.PageSize != nil {
pageSize = *params.PageSize
}
result, err := s.svc.Eula.ListVersions(ctx.Request().Context(), &eula.ListVersionsInput{
Page: page,
PageSize: pageSize,
})
if err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to list EULA versions",
})
}
// Convert to API response
versions := make([]EulaVersion, len(result.Versions))
for i, v := range result.Versions {
versions[i] = convertVersionToAPI(v)
}
response := EulaVersionList{
Versions: versions,
Total: int32(result.Total), // #nosec G115 -- Total is bounded by practical EULA version counts (< 1000)
Page: page,
PageSize: pageSize,
HasMore: &result.HasMore,
}
return ctx.JSON(http.StatusOK, response)
}
// CreateEulaVersion creates a new EULA version.
// POST /admin/eula
//
// Requires AdminService permission.
//
// Request body: EulaVersionCreate
//
// Returns:
// - 201 Created with EulaVersion on success
// - 400 Bad Request on validation errors
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 409 Conflict if version string already exists
// - 500 Internal Server Error on database errors
func (s *Controllers) CreateEulaVersion(ctx echo.Context) error {
var req EulaVersionCreate
if err := ctx.Bind(&req); err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid request body",
})
}
// Get admin user for audit
adminUser, err := getAdminUserForAudit(ctx)
if err != nil {
return ctx.JSON(http.StatusUnauthorized, ErrorMessage{
Message: "Could not identify admin user",
})
}
// Extract effectiveDate if specified (nullable field)
var effectiveDate *time.Time
if req.EffectiveDate.IsSpecified() && !req.EffectiveDate.IsNull() {
val := req.EffectiveDate.MustGet()
effectiveDate = &val
}
version, err := s.svc.Eula.CreateVersion(ctx.Request().Context(), &eula.CreateVersionInput{
Version: req.Version,
Title: req.Title,
Content: req.Content,
EffectiveDate: effectiveDate,
CreatedBy: adminUser.Email,
})
if err != nil {
// Check for duplicate version
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
return ctx.JSON(http.StatusConflict, ErrorMessage{
Message: "EULA version already exists",
})
}
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to create EULA version",
})
}
return ctx.JSON(http.StatusCreated, convertVersionToAPI(version))
}
// GetEulaVersion retrieves a specific EULA version by ID.
// GET /admin/eula/{version_id}
//
// Requires AdminService permission.
//
// Returns:
// - 200 OK with EulaVersion on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 404 Not Found if version doesn't exist
// - 500 Internal Server Error on database errors
func (s *Controllers) GetEulaVersion(ctx echo.Context, versionID EulaVersionID) error {
id, err := convertVersionIDParam(versionID)
if err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid version ID format",
})
}
version, err := s.svc.Eula.GetVersionByID(ctx.Request().Context(), id)
if err != nil {
if errors.Is(err, eula.ErrVersionNotFound) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "EULA version not found",
})
}
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to get EULA version",
})
}
return ctx.JSON(http.StatusOK, convertVersionToAPI(version))
}
// UpdateEulaVersion updates the metadata of an EULA version.
// Only title and effective date can be updated; content is immutable.
// PATCH /admin/eula/{version_id}
//
// Requires AdminService permission.
//
// Request body: EulaVersionUpdate
//
// Returns:
// - 200 OK with updated EulaVersion on success
// - 400 Bad Request on validation errors
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 404 Not Found if version doesn't exist
// - 500 Internal Server Error on database errors
func (s *Controllers) UpdateEulaVersion(ctx echo.Context, versionID EulaVersionID) error {
id, err := convertVersionIDParam(versionID)
if err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid version ID format",
})
}
var req EulaVersionUpdate
if err := ctx.Bind(&req); err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid request body",
})
}
input := &eula.UpdateVersionInput{}
if req.Title != nil {
input.Title = req.Title
}
if req.EffectiveDate != nil {
input.EffectiveDate = req.EffectiveDate
}
version, err := s.svc.Eula.UpdateVersion(ctx.Request().Context(), id, input)
if err != nil {
if errors.Is(err, eula.ErrVersionNotFound) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "EULA version not found",
})
}
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to update EULA version",
})
}
return ctx.JSON(http.StatusOK, convertVersionToAPI(version))
}
// ActivateEulaVersion sets the specified version as the current active EULA.
// POST /admin/eula/{version_id}/activate
//
// Requires AdminService permission.
// Uses a transaction to atomically clear the previous current flag and set the new one.
//
// Returns:
// - 200 OK with activated EulaVersion on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 404 Not Found if version doesn't exist
// - 409 Conflict if another admin activated a different version concurrently
// - 500 Internal Server Error on database errors
func (s *Controllers) ActivateEulaVersion(ctx echo.Context, versionID EulaVersionID) error {
id, err := convertVersionIDParam(versionID)
if err != nil {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "Invalid version ID format",
})
}
// Get admin user for audit
adminUser, err := getAdminUserForAudit(ctx)
if err != nil {
return ctx.JSON(http.StatusUnauthorized, ErrorMessage{
Message: "Could not identify admin user",
})
}
version, err := s.svc.Eula.ActivateVersion(ctx.Request().Context(), id, adminUser.Email)
if err != nil {
if errors.Is(err, eula.ErrVersionNotFound) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "EULA version not found",
})
}
if errors.Is(err, eula.ErrConcurrentActivation) {
return ctx.JSON(http.StatusConflict, ErrorMessage{
Message: "Concurrent activation conflict - another version was activated. Please refresh and try again.",
})
}
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to activate EULA version",
})
}
return ctx.JSON(http.StatusOK, convertVersionToAPI(version))
}
// ListEulaAgreements returns a paginated list of EULA agreements.
// GET /admin/eula/agreements
//
// Requires AdminService permission.
//
// Parameters:
// - page: Page number (1-based, default: 1)
// - page_size: Items per page (1-100, default: 20)
// - version_id: Optional filter by EULA version
// - user_id: Optional filter by Cognito subject ID
//
// Returns:
// - 200 OK with EulaAgreementList on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 500 Internal Server Error on database errors
func (s *Controllers) ListEulaAgreements(ctx echo.Context, params ListEulaAgreementsParams) error {
// Apply defaults
page := int32(1)
pageSize := int32(20)
if params.Page != nil {
page = *params.Page
}
if params.PageSize != nil {
pageSize = *params.PageSize
}
input := &eula.ListAgreementsInput{
Page: page,
PageSize: pageSize,
}
// Convert optional UUID filter
if params.VersionId != nil {
versionID := uuid.UUID(*params.VersionId)
input.VersionID = &versionID
}
if params.UserId != nil {
input.UserID = params.UserId
}
result, err := s.svc.Eula.ListAgreements(ctx.Request().Context(), input)
if err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to list EULA agreements",
})
}
// Convert to API response
agreements := make([]EulaAgreement, len(result.Agreements))
for i, a := range result.Agreements {
agreements[i] = convertAgreementToAPI(a)
}
response := EulaAgreementList{
Agreements: agreements,
Total: int32(result.Total), // #nosec G115 -- Total is bounded by pageSize pagination
Page: page,
PageSize: pageSize,
HasMore: &result.HasMore,
}
return ctx.JSON(http.StatusOK, response)
}
// GetEulaCompliance returns a comprehensive compliance report.
// GET /admin/eula/compliance
//
// Requires AdminService permission.
// This endpoint queries Cognito for all users and compares with EULA agreements.
//
// Parameters:
// - page: Page number (1-based, default: 1)
// - page_size: Items per page (1-100, default: 50)
// - version_id: Optional specific version to report on (defaults to current)
// - agreed: Optional filter by agreement status
//
// Returns:
// - 200 OK with EulaComplianceReport on success
// - 401 Unauthorized if not authenticated
// - 403 Forbidden if not authorized
// - 404 Not Found if specified version doesn't exist or no current EULA
// - 502 Bad Gateway on Cognito errors
// - 500 Internal Server Error on database errors
func (s *Controllers) GetEulaCompliance(ctx echo.Context, params GetEulaComplianceParams) error {
// Initialize AWS config for Cognito
awsCfg, err := aws.GetAWSConfig(context.Background())
if err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to initialize AWS configuration",
})
}
cognitoClient := cognitoidentityprovider.NewFromConfig(awsCfg)
userPoolID := s.cfg.GetAuthUserPoolID()
// Build service input from params
input := &eula.ComplianceReportInput{
Page: 1,
PageSize: 50,
}
if params.Page != nil {
input.Page = *params.Page
}
if params.PageSize != nil {
input.PageSize = *params.PageSize
}
if params.VersionId != nil {
// Convert openapi_types.UUID to uuid.UUID
versionID := uuid.UUID(*params.VersionId)
input.VersionID = &versionID
}
if params.Agreed != nil {
input.Agreed = params.Agreed
}
// Call service method
result, err := s.svc.Eula.GetComplianceReport(ctx.Request().Context(), cognitoClient, userPoolID, input)
if err != nil {
if errors.Is(err, eula.ErrNoCurrentVersion) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "No current EULA version configured",
})
}
if errors.Is(err, eula.ErrVersionNotFound) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "EULA version not found",
})
}
// Check if it's a Cognito error
if strings.Contains(err.Error(), "Cognito") || strings.Contains(err.Error(), "cognito") {
return ctx.JSON(http.StatusBadGateway, ErrorMessage{
Message: "Failed to fetch users from Cognito: " + err.Error(),
})
}
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to generate compliance report",
})
}
// Convert result to API response
response := convertComplianceReportToAPI(result)
return ctx.JSON(http.StatusOK, response)
}
// convertComplianceReportToAPI converts the service result to the API response type.
func convertComplianceReportToAPI(result *eula.ComplianceReportResult) EulaComplianceReport {
// Convert version to summary format
versionSummary := EulaVersionSummary{
Id: openapi_types.UUID(result.Version.ID),
Version: result.Version.Version,
Title: result.Version.Title,
IsCurrent: result.Version.Iscurrent,
}
// Handle nullable effectiveDate
if result.Version.Effectivedate.Valid {
versionSummary.EffectiveDate = nullable.NewNullableWithValue(result.Version.Effectivedate.Time)
}
// Convert summary
summary := EulaComplianceSummary{
TotalUsers: result.Summary.TotalUsers,
AgreedCount: result.Summary.AgreedCount,
NotAgreedCount: result.Summary.NotAgreedCount,
CompliancePercentage: result.Summary.CompliancePercentage,
}
// Convert users
users := make([]EulaComplianceUser, 0, len(result.Users))
for _, u := range result.Users {
user := EulaComplianceUser{
CognitoSubjectId: u.CognitoSubjectID,
CurrentEmail: openapi_types.Email(u.CurrentEmail),
Agreed: u.Agreed,
}
if u.Email != nil {
user.Email = nullable.NewNullableWithValue(openapi_types.Email(*u.Email))
}
if u.AgreedAt != nil {
user.AgreedAt = nullable.NewNullableWithValue(*u.AgreedAt)
}
if u.AgreedFromIP != nil {
user.AgreedFromIp = nullable.NewNullableWithValue(*u.AgreedFromIP)
}
users = append(users, user)
}
// Build pagination
pagination := EulaCompliancePagination{
Page: result.Page,
PageSize: result.PageSize,
TotalPages: result.TotalPages,
TotalItems: result.TotalItems,
}
return EulaComplianceReport{
EulaVersion: versionSummary,
Summary: summary,
Users: users,
Pagination: pagination,
}
}
// convertVersionToAPI converts a repository Eulaversion to the API EulaVersion type.
func convertVersionToAPI(v *repository.Eulaversion) EulaVersion {
version := EulaVersion{
Id: openapi_types.UUID(v.ID),
Version: v.Version,
Title: v.Title,
Content: v.Content,
CreatedAt: v.Createdat.Time,
CreatedBy: openapi_types.Email(v.Createdby),
IsCurrent: v.Iscurrent,
}
// Handle nullable effectiveDate
if v.Effectivedate.Valid {
version.EffectiveDate = nullable.NewNullableWithValue(v.Effectivedate.Time)
}
if v.Activatedat.Valid {
version.ActivatedAt = nullable.NewNullableWithValue(v.Activatedat.Time)
}
if v.Activatedby != nil {
version.ActivatedBy = nullable.NewNullableWithValue(openapi_types.Email(*v.Activatedby))
}
return version
}
// convertAgreementToAPI converts a repository ListEulaAgreementsRow to the API EulaAgreement type.
func convertAgreementToAPI(a *repository.ListEulaAgreementsRow) EulaAgreement {
var agreedAt time.Time
if a.Agreedat.Valid {
agreedAt = a.Agreedat.Time
}
return EulaAgreement{
Id: openapi_types.UUID(a.ID),
CognitoSubjectId: a.Cognitosubjectid,
UserEmail: openapi_types.Email(a.Useremail),
EulaVersionId: openapi_types.UUID(a.Eulaversionid),
EulaVersion: a.Eulaversionstring,
AgreedAt: agreedAt,
AgreedFromIp: a.Agreedfromip,
}
}
+609
View File
@@ -0,0 +1,609 @@
package queryapi_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
queryapi "queryorchestration/api/queryAPI"
"queryorchestration/internal/cognitoauth"
"queryorchestration/internal/eula"
"queryorchestration/internal/test"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/oapi-codegen/nullable"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ptrTime is a helper to create a pointer to a time.Time value.
func ptrTime(t time.Time) *time.Time {
return &t
}
// setupEulaTestContext creates an echo context with the specified path and method.
func setupEulaTestContext(method, path string, body interface{}) (echo.Context, *httptest.ResponseRecorder) {
e := echo.New()
var req *http.Request
if body != nil {
jsonBody, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(jsonBody))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
} else {
req = httptest.NewRequest(method, path, nil)
}
rec := httptest.NewRecorder()
ctx := e.NewContext(req, rec)
return ctx, rec
}
// setupEulaTestContextWithUser creates an echo context with user info set.
func setupEulaTestContextWithUser(method, path string, body interface{}, userSubject, userEmail string) (echo.Context, *httptest.ResponseRecorder) {
ctx, rec := setupEulaTestContext(method, path, body)
// Set up user info in context (simulating authenticated user)
// Note: GetUserSubject expects map[string]interface{} with "sub" key
// Note: GetUserInfo expects cognitoauth.UserInfo (not pointer)
ctx.Set("user_claims", map[string]interface{}{
"sub": userSubject,
"email": userEmail,
})
ctx.Set("user_info", cognitoauth.UserInfo{
Email: userEmail,
})
return ctx, rec
}
// createTestEulaVersion creates a test EULA version in the database.
func createTestEulaVersion(t *testing.T, svc *eula.Service, version, title string, activate bool) *uuid.UUID {
ctx := t.Context()
input := &eula.CreateVersionInput{
Version: version,
Title: title,
Content: "# Test EULA\n\nThis is test content.",
EffectiveDate: ptrTime(time.Now()),
CreatedBy: "admin@test.com",
}
created, err := svc.CreateVersion(ctx, input)
require.NoError(t, err)
if activate {
_, err = svc.ActivateVersion(ctx, created.ID, "admin@test.com")
require.NoError(t, err)
}
return &created.ID
}
func TestGetCurrentEula(t *testing.T) {
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
t.Run("returns current EULA version", func(t *testing.T) {
// Create and activate a version
versionStr := "1.0.0-test-" + uuid.New().String()[:8]
createTestEulaVersion(t, svc.Eula, versionStr, "Test EULA", true)
ctx, rec := setupEulaTestContext(http.MethodGet, "/eula", nil)
err := cons.GetCurrentEula(ctx)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaPublicResponse
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, versionStr, response.Version)
})
t.Run("returns 404 when no current version", func(t *testing.T) {
// Create a new config without any EULA versions activated
cfg2 := &ControllerConfig{}
test.CreateDB(t, cfg2)
initializeTestConfig(t, cfg2)
svc2 := createControllerServices(cfg2)
cons2 := queryapi.NewControllers(svc2, cfg2)
ctx, rec := setupEulaTestContext(http.MethodGet, "/eula", nil)
err := cons2.GetCurrentEula(ctx)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}
func TestGetEulaStatus(t *testing.T) {
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
// Create and activate a version
versionStr := "status-test-" + uuid.New().String()[:8]
versionID := createTestEulaVersion(t, svc.Eula, versionStr, "Status Test EULA", true)
t.Run("returns status for unagreed user", func(t *testing.T) {
userSubject := "user-" + uuid.New().String()[:8]
ctx, rec := setupEulaTestContextWithUser(http.MethodGet, "/eula/status", nil, userSubject, "user@test.com")
err := cons.GetEulaStatus(ctx)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaStatusResponse
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.False(t, response.HasAgreed)
assert.Equal(t, versionStr, response.CurrentVersion)
})
t.Run("returns status for agreed user", func(t *testing.T) {
userSubject := "agreed-user-" + uuid.New().String()[:8]
userEmail := "agreed@test.com"
// Record agreement
_, err := svc.Eula.RecordAgreement(t.Context(), &eula.RecordAgreementInput{
CognitoSubjectID: userSubject,
UserEmail: userEmail,
EulaVersionID: *versionID,
IPAddress: "192.168.1.1",
})
require.NoError(t, err)
ctx, rec := setupEulaTestContextWithUser(http.MethodGet, "/eula/status", nil, userSubject, userEmail)
err = cons.GetEulaStatus(ctx)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaStatusResponse
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.True(t, response.HasAgreed)
})
t.Run("returns 401 for unauthenticated user", func(t *testing.T) {
ctx, rec := setupEulaTestContext(http.MethodGet, "/eula/status", nil)
// Don't set user info
err := cons.GetEulaStatus(ctx)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
}
func TestAgreeToEula(t *testing.T) {
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
// Create and activate a version
versionStr := "agree-test-" + uuid.New().String()[:8]
createTestEulaVersion(t, svc.Eula, versionStr, "Agree Test EULA", true)
t.Run("records new agreement", func(t *testing.T) {
userSubject := "agree-user-" + uuid.New().String()[:8]
userEmail := "agreeuser@test.com"
ctx, rec := setupEulaTestContextWithUser(http.MethodPost, "/eula/agree", nil, userSubject, userEmail)
ctx.Request().RemoteAddr = "192.168.1.100:12345"
err := cons.AgreeToEula(ctx)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
var response queryapi.EulaAgreementResponse
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, versionStr, response.EulaVersion)
})
t.Run("idempotent - returns 200 for existing agreement", func(t *testing.T) {
userSubject := "idempotent-user-" + uuid.New().String()[:8]
userEmail := "idempotent@test.com"
// First call
ctx1, rec1 := setupEulaTestContextWithUser(http.MethodPost, "/eula/agree", nil, userSubject, userEmail)
ctx1.Request().RemoteAddr = "192.168.1.100:12345"
err := cons.AgreeToEula(ctx1)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec1.Code)
// Second call (same user)
ctx2, rec2 := setupEulaTestContextWithUser(http.MethodPost, "/eula/agree", nil, userSubject, userEmail)
ctx2.Request().RemoteAddr = "192.168.1.100:12345"
err = cons.AgreeToEula(ctx2)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec2.Code)
})
t.Run("returns 401 for unauthenticated user", func(t *testing.T) {
ctx, rec := setupEulaTestContext(http.MethodPost, "/eula/agree", nil)
err := cons.AgreeToEula(ctx)
require.NoError(t, err)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
}
func TestListEulaVersions(t *testing.T) {
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
// Create multiple versions
for i := 0; i < 5; i++ {
createTestEulaVersion(t, svc.Eula, "list-v"+uuid.New().String()[:8], "List EULA", false)
}
t.Run("lists all versions with pagination", func(t *testing.T) {
ctx, rec := setupEulaTestContext(http.MethodGet, "/admin/eula?page=1&page_size=3", nil)
page := int32(1)
pageSize := int32(3)
params := queryapi.ListEulaVersionsParams{
Page: &page,
PageSize: &pageSize,
}
err := cons.ListEulaVersions(ctx, params)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaVersionList
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.LessOrEqual(t, len(response.Versions), 3)
assert.GreaterOrEqual(t, response.Total, int32(5))
})
}
func TestCreateEulaVersion(t *testing.T) {
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
t.Run("creates new EULA version", func(t *testing.T) {
versionStr := "create-test-" + uuid.New().String()[:8]
body := queryapi.EulaVersionCreate{
Version: versionStr,
Title: "Created EULA",
Content: "# Created EULA Content\n\nTest content here.",
EffectiveDate: nullable.NewNullableWithValue(time.Now().Add(24 * time.Hour)),
}
ctx, rec := setupEulaTestContextWithUser(http.MethodPost, "/admin/eula", body, "admin-subject", "admin@test.com")
err := cons.CreateEulaVersion(ctx)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
var response queryapi.EulaVersion
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, versionStr, response.Version)
assert.Equal(t, "Created EULA", response.Title)
})
t.Run("returns 409 for duplicate version", func(t *testing.T) {
versionStr := "dup-test-" + uuid.New().String()[:8]
body := queryapi.EulaVersionCreate{
Version: versionStr,
Title: "First EULA",
Content: "Content",
EffectiveDate: nullable.NewNullableWithValue(time.Now()),
}
// First creation
ctx1, _ := setupEulaTestContextWithUser(http.MethodPost, "/admin/eula", body, "admin-subject", "admin@test.com")
err := cons.CreateEulaVersion(ctx1)
require.NoError(t, err)
// Second creation (same version)
ctx2, rec2 := setupEulaTestContextWithUser(http.MethodPost, "/admin/eula", body, "admin-subject", "admin@test.com")
err = cons.CreateEulaVersion(ctx2)
require.NoError(t, err)
assert.Equal(t, http.StatusConflict, rec2.Code)
})
}
func TestGetEulaVersion(t *testing.T) {
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
t.Run("gets existing version by ID", func(t *testing.T) {
versionStr := "get-test-" + uuid.New().String()[:8]
versionID := createTestEulaVersion(t, svc.Eula, versionStr, "Get Test EULA", false)
ctx, rec := setupEulaTestContext(http.MethodGet, "/admin/eula/"+versionID.String(), nil)
err := cons.GetEulaVersion(ctx, *versionID)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaVersion
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, versionStr, response.Version)
})
t.Run("returns 404 for non-existent version", func(t *testing.T) {
nonExistentID := uuid.New()
ctx, rec := setupEulaTestContext(http.MethodGet, "/admin/eula/"+nonExistentID.String(), nil)
err := cons.GetEulaVersion(ctx, nonExistentID)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}
func TestUpdateEulaVersion(t *testing.T) {
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
t.Run("updates version title", func(t *testing.T) {
versionStr := "update-test-" + uuid.New().String()[:8]
versionID := createTestEulaVersion(t, svc.Eula, versionStr, "Original Title", false)
newTitle := "Updated Title"
body := queryapi.EulaVersionUpdate{
Title: &newTitle,
}
ctx, rec := setupEulaTestContext(http.MethodPatch, "/admin/eula/"+versionID.String(), body)
err := cons.UpdateEulaVersion(ctx, *versionID)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaVersion
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, newTitle, response.Title)
})
}
func TestActivateEulaVersion(t *testing.T) {
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
t.Run("activates version", func(t *testing.T) {
versionStr := "activate-test-" + uuid.New().String()[:8]
versionID := createTestEulaVersion(t, svc.Eula, versionStr, "Activate Test EULA", false)
ctx, rec := setupEulaTestContextWithUser(http.MethodPost, "/admin/eula/"+versionID.String()+"/activate", nil, "admin-subject", "admin@test.com")
err := cons.ActivateEulaVersion(ctx, *versionID)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaVersion
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.True(t, response.IsCurrent)
})
t.Run("returns 404 for non-existent version", func(t *testing.T) {
nonExistentID := uuid.New()
ctx, rec := setupEulaTestContextWithUser(http.MethodPost, "/admin/eula/"+nonExistentID.String()+"/activate", nil, "admin-subject", "admin@test.com")
err := cons.ActivateEulaVersion(ctx, nonExistentID)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}
func TestListEulaAgreements(t *testing.T) {
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
// Create version and some agreements
versionID := createTestEulaVersion(t, svc.Eula, "agreements-test-"+uuid.New().String()[:8], "Agreements Test", true)
for i := 0; i < 3; i++ {
_, err := svc.Eula.RecordAgreement(t.Context(), &eula.RecordAgreementInput{
CognitoSubjectID: "user-" + uuid.New().String()[:8],
UserEmail: "user" + uuid.New().String()[:4] + "@test.com",
EulaVersionID: *versionID,
IPAddress: "192.168.1.1",
})
require.NoError(t, err)
}
t.Run("lists agreements", func(t *testing.T) {
ctx, rec := setupEulaTestContext(http.MethodGet, "/admin/eula/agreements", nil)
page := int32(1)
pageSize := int32(10)
params := queryapi.ListEulaAgreementsParams{
Page: &page,
PageSize: &pageSize,
}
err := cons.ListEulaAgreements(ctx, params)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaAgreementList
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(response.Agreements), 3)
})
}
func TestGetEulaCompliance(t *testing.T) {
// This test requires real Cognito access - opt-in via env var
if os.Getenv("ENABLE_COGNITO_INTEGRATION_TESTS") != "true" {
t.Skip("Skipping Cognito integration test: set ENABLE_COGNITO_INTEGRATION_TESTS=true to run")
}
userPoolID := os.Getenv("COGNITO_USER_POOL_ID")
awsRegion := os.Getenv("AWS_REGION")
if awsRegion == "" {
awsRegion = os.Getenv("AUTH_REGION")
}
if userPoolID == "" || awsRegion == "" {
t.Skip("Skipping compliance handler test: COGNITO_USER_POOL_ID and AWS_REGION/AUTH_REGION must be set")
}
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
// Create and activate a version for testing
versionStr := "compliance-handler-" + uuid.New().String()[:8]
createTestEulaVersion(t, svc.Eula, versionStr, "Compliance Handler Test EULA", true)
t.Run("returns_compliance_report", func(t *testing.T) {
ctx, rec := setupEulaTestContext(http.MethodGet, "/admin/eula/compliance", nil)
params := queryapi.GetEulaComplianceParams{}
err := cons.GetEulaCompliance(ctx, params)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaComplianceReport
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
// Verify structure
assert.NotEmpty(t, response.EulaVersion.Id)
assert.NotEmpty(t, response.EulaVersion.Version)
assert.GreaterOrEqual(t, response.Summary.TotalUsers, int32(0))
assert.Equal(t, response.Summary.TotalUsers, response.Summary.AgreedCount+response.Summary.NotAgreedCount)
})
t.Run("returns_404_for_nonexistent_version", func(t *testing.T) {
ctx, rec := setupEulaTestContext(http.MethodGet, "/admin/eula/compliance?version_id=00000000-0000-0000-0000-000000000000", nil)
nonExistentID := uuid.MustParse("00000000-0000-0000-0000-000000000000")
params := queryapi.GetEulaComplianceParams{
VersionId: (*queryapi.EulaVersionID)(&nonExistentID),
}
err := cons.GetEulaCompliance(ctx, params)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
t.Run("filters_by_agreed_status", func(t *testing.T) {
ctx, rec := setupEulaTestContext(http.MethodGet, "/admin/eula/compliance?agreed=false", nil)
agreed := false
params := queryapi.GetEulaComplianceParams{
Agreed: &agreed,
}
err := cons.GetEulaCompliance(ctx, params)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaComplianceReport
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
// All users in the result should have agreed=false
for _, user := range response.Users {
assert.False(t, user.Agreed)
}
})
t.Run("pagination_params_work", func(t *testing.T) {
ctx, rec := setupEulaTestContext(http.MethodGet, "/admin/eula/compliance?page=1&page_size=5", nil)
page := int32(1)
pageSize := int32(5)
params := queryapi.GetEulaComplianceParams{
Page: &page,
PageSize: &pageSize,
}
err := cons.GetEulaCompliance(ctx, params)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var response queryapi.EulaComplianceReport
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, int32(1), response.Pagination.Page)
assert.Equal(t, int32(5), response.Pagination.PageSize)
assert.LessOrEqual(t, len(response.Users), 5)
})
}
func TestGetEulaComplianceNoCurrentVersion(t *testing.T) {
// This test requires real Cognito access - opt-in via env var
if os.Getenv("ENABLE_COGNITO_INTEGRATION_TESTS") != "true" {
t.Skip("Skipping Cognito integration test: set ENABLE_COGNITO_INTEGRATION_TESTS=true to run")
}
userPoolID := os.Getenv("COGNITO_USER_POOL_ID")
awsRegion := os.Getenv("AWS_REGION")
if awsRegion == "" {
awsRegion = os.Getenv("AUTH_REGION")
}
if userPoolID == "" || awsRegion == "" {
t.Skip("Skipping compliance handler test: COGNITO_USER_POOL_ID and AWS_REGION/AUTH_REGION must be set")
}
// Fresh database with no EULA versions
cfg := &ControllerConfig{}
test.CreateDB(t, cfg)
initializeTestConfig(t, cfg)
svc := createControllerServices(cfg)
cons := queryapi.NewControllers(svc, cfg)
t.Run("returns_404_no_current_eula", func(t *testing.T) {
ctx, rec := setupEulaTestContext(http.MethodGet, "/admin/eula/compliance", nil)
params := queryapi.GetEulaComplianceParams{}
err := cons.GetEulaCompliance(ctx, params)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
}
+183
View File
@@ -0,0 +1,183 @@
// eulaPublicHandlers.go implements the public EULA API handlers.
// These endpoints handle EULA retrieval and user agreement recording.
package queryapi
import (
"errors"
"net/http"
"time"
"queryorchestration/internal/cognitoauth"
"queryorchestration/internal/eula"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/oapi-codegen/nullable"
openapi_types "github.com/oapi-codegen/runtime/types"
)
// GetCurrentEula returns the current active EULA version.
// This endpoint is public and does not require authentication.
// GET /eula
//
// Returns:
// - 200 OK with EulaPublicResponse on success
// - 404 Not Found if no EULA is currently active
// - 500 Internal Server Error on database errors
func (s *Controllers) GetCurrentEula(ctx echo.Context) error {
version, err := s.svc.Eula.GetCurrentVersion(ctx.Request().Context())
if err != nil {
if errors.Is(err, eula.ErrNoCurrentVersion) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "No current EULA version configured",
})
}
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to get current EULA version",
})
}
// Convert to public response
response := EulaPublicResponse{
Id: openapi_types.UUID(version.ID),
Version: version.Version,
Title: version.Title,
Content: version.Content,
}
// Handle nullable effectiveDate
if version.Effectivedate.Valid {
response.EffectiveDate = nullable.NewNullableWithValue(version.Effectivedate.Time)
}
return ctx.JSON(http.StatusOK, response)
}
// GetEulaStatus returns the current user's agreement status for the current EULA.
// Used by the frontend to determine if the user needs to be prompted to agree.
// GET /eula/status
//
// Requires authentication via JWT.
//
// Returns:
// - 200 OK with EulaStatusResponse on success
// - 401 Unauthorized if not authenticated
// - 404 Not Found if no EULA is currently active
// - 500 Internal Server Error on database errors
func (s *Controllers) GetEulaStatus(ctx echo.Context) error {
// Get user subject from JWT
userSubject, ok := cognitoauth.GetUserSubject(ctx)
if !ok {
return ctx.JSON(http.StatusUnauthorized, ErrorMessage{
Message: "Authentication required",
})
}
status, err := s.svc.Eula.GetUserEulaStatus(ctx.Request().Context(), userSubject)
if err != nil {
if errors.Is(err, eula.ErrNoCurrentVersion) {
return ctx.JSON(http.StatusNotFound, ErrorMessage{
Message: "No current EULA version configured",
})
}
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to get EULA status",
})
}
// Convert to API response
response := EulaStatusResponse{
HasAgreed: status.HasAgreed,
CurrentVersion: status.CurrentVersion,
CurrentVersionId: openapi_types.UUID(status.CurrentVersionID),
}
if status.AgreedAt != nil {
response.AgreedAt = nullable.NewNullableWithValue(*status.AgreedAt)
}
if status.AgreedVersion != nil {
response.AgreedVersion = nullable.NewNullableWithValue(*status.AgreedVersion)
}
return ctx.JSON(http.StatusOK, response)
}
// AgreeToEula records the current user's agreement to the current EULA version.
// This operation is idempotent - calling it multiple times will not create duplicate records.
// POST /eula/agree
//
// Requires authentication via JWT.
// IP address is automatically captured from the request.
//
// Returns:
// - 201 Created with EulaAgreementResponse for new agreements
// - 200 OK with EulaAgreementResponse if user already agreed (idempotent)
// - 400 Bad Request if no current EULA is configured
// - 401 Unauthorized if not authenticated
// - 500 Internal Server Error on database errors
func (s *Controllers) AgreeToEula(ctx echo.Context) error {
// Get user info from JWT
userSubject, subjectOK := cognitoauth.GetUserSubject(ctx)
userInfo, infoOK := cognitoauth.GetUserInfo(ctx)
if !subjectOK || !infoOK {
return ctx.JSON(http.StatusUnauthorized, ErrorMessage{
Message: "Authentication required",
})
}
// Get current EULA version
currentVersion, err := s.svc.Eula.GetCurrentVersion(ctx.Request().Context())
if err != nil {
if errors.Is(err, eula.ErrNoCurrentVersion) {
return ctx.JSON(http.StatusBadRequest, ErrorMessage{
Message: "No current EULA version configured",
})
}
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to get current EULA version",
})
}
// Get client IP address
ipAddress := ctx.RealIP()
if ipAddress == "" {
ipAddress = ctx.Request().RemoteAddr
}
// Record the agreement
result, err := s.svc.Eula.RecordAgreement(ctx.Request().Context(), &eula.RecordAgreementInput{
CognitoSubjectID: userSubject,
UserEmail: userInfo.Email,
EulaVersionID: currentVersion.ID,
IPAddress: ipAddress,
})
if err != nil {
return ctx.JSON(http.StatusInternalServerError, ErrorMessage{
Message: "Failed to record EULA agreement",
})
}
// Build response
var agreedAt time.Time
if result.Agreement.Agreedat.Valid {
agreedAt = result.Agreement.Agreedat.Time
}
response := EulaAgreementResponse{
Id: openapi_types.UUID(result.Agreement.ID),
EulaVersionId: openapi_types.UUID(result.Agreement.Eulaversionid),
EulaVersion: currentVersion.Version,
AgreedAt: agreedAt,
}
// Return 200 if already agreed (idempotent), 201 for new agreement
if result.AlreadyAgreed {
return ctx.JSON(http.StatusOK, response)
}
return ctx.JSON(http.StatusCreated, response)
}
// convertVersionIDParam converts the path parameter to uuid.UUID.
// Returns error if the ID format is invalid.
func convertVersionIDParam(versionID EulaVersionID) (uuid.UUID, error) {
return uuid.UUID(versionID), nil
}
+3
View File
@@ -10,6 +10,7 @@ import (
"queryorchestration/internal/document"
documentbatch "queryorchestration/internal/document/batch"
documentupload "queryorchestration/internal/document/upload"
"queryorchestration/internal/eula"
"queryorchestration/internal/export"
"queryorchestration/internal/fieldextraction"
"queryorchestration/internal/folder"
@@ -49,6 +50,7 @@ func createControllerServices(cfg *ControllerConfig) *queryapi.Services {
fieldext := fieldextraction.New(cfg)
fld := folder.New(cfg)
lbl := label.New(cfg)
eul := eula.New(cfg)
return &queryapi.Services{
Export: exp,
@@ -62,5 +64,6 @@ func createControllerServices(cfg *ControllerConfig) *queryapi.Services {
FieldExtraction: fieldext,
Folder: fld,
Label: lbl,
Eula: eul,
}
}