Skip to content

Commit 658b317

Browse files
Merge pull request #167 from flocko-motion/development
Main Deployment
2 parents 9ad3c39 + fa988fd commit 658b317

File tree

102 files changed

+8599
-1197
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

102 files changed

+8599
-1197
lines changed

.vscode/launch.json

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,29 @@
7676
]
7777
},
7878
{
79-
"name": "Debug TestGamePlaythrough",
79+
"name": "Debug TestGamePlaythrough Mistral",
8080
"type": "go",
8181
"request": "launch",
8282
"mode": "test",
8383
"program": "${workspaceFolder}/testing",
8484
"cwd": "${workspaceFolder}/testing",
8585
"args": [
8686
"-test.run",
87-
"TestGameEngineTestSuite/TestGamePlaythrough",
87+
"TestGameEngineTestSuite/TestGamePlaythroughMistral",
88+
"-test.v"
89+
],
90+
"buildFlags": "-tags=ai_tests"
91+
},
92+
{
93+
"name": "Debug TestGamePlaythrough OpenAI",
94+
"type": "go",
95+
"request": "launch",
96+
"mode": "test",
97+
"program": "${workspaceFolder}/testing",
98+
"cwd": "${workspaceFolder}/testing",
99+
"args": [
100+
"-test.run",
101+
"TestGameEngineTestSuite/TestGamePlaythroughOpenai",
88102
"-test.v"
89103
],
90104
"buildFlags": "-tags=ai_tests"

run-test.sh

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,50 @@
11
#!/bin/bash
22

3-
# Simple test runner - Docker lifecycle is managed by test suites
3+
# Integration test runner - Docker lifecycle is managed by test suites
44
# Each test suite automatically starts/stops its own Docker environment
55
#
6+
# Requires: gotestsum (go install gotest.tools/gotestsum@latest)
7+
#
68
# Usage:
7-
# ./run-test.sh # Run all tests including AI tests
8-
# ./run-test.sh --no-ai # Run tests excluding AI tests (for CI/CD)
9+
# ./run-test.sh # Clean summary (default)
10+
# ./run-test.sh --verbose # Full verbose output
11+
# ./run-test.sh -v # Full verbose output (short)
12+
# ./run-test.sh --no-ai # Exclude AI tests
13+
# ./run-test.sh --no-ai --verbose # Exclude AI tests, verbose
914

1015
cd "$(dirname "$0")"
1116

12-
set -e
17+
GOTESTSUM="go run gotest.tools/gotestsum@latest"
1318

1419
# Parse arguments
1520
BUILD_TAGS=""
16-
if [[ "$1" == "--no-ai" ]]; then
17-
echo "🧪 Running integration tests (excluding AI tests)..."
18-
BUILD_TAGS=""
19-
else
21+
VERBOSE=false
22+
for arg in "$@"; do
23+
case "$arg" in
24+
--no-ai) ;; # default behavior, no-op
25+
--verbose|-v) VERBOSE=true ;;
26+
--ai) BUILD_TAGS="-tags ai_tests" ;;
27+
esac
28+
done
29+
30+
if [[ -n "$BUILD_TAGS" ]]; then
2031
echo "🧪 Running integration tests (including AI tests)..."
21-
BUILD_TAGS="-tags ai_tests"
32+
else
33+
echo "🧪 Running integration tests (excluding AI tests)..."
2234
fi
2335
echo ""
2436

2537
cd testing
26-
go test -v $BUILD_TAGS ./...
27-
TEST_EXIT_CODE=$?
28-
cd ..
2938

30-
echo ""
31-
if [ $TEST_EXIT_CODE -eq 0 ]; then
32-
echo "✅ All tests passed!"
39+
# Pick format: CI gets collapsible groups, terminal gets compact output
40+
if [[ -n "$CI" ]]; then
41+
FORMAT="github-actions"
42+
elif $VERBOSE; then
43+
FORMAT="standard-verbose"
3344
else
34-
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
45+
FORMAT="testname"
3546
fi
3647

37-
exit $TEST_EXIT_CODE
48+
$GOTESTSUM --format "$FORMAT" -- -v $BUILD_TAGS ./...
49+
50+
exit $?

server/api/httpx/response.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func ErrorCodeToStatus(code string) int {
106106
return http.StatusForbidden
107107
case obj.ErrCodeNotFound:
108108
return http.StatusNotFound
109-
case obj.ErrCodeConflict, obj.ErrCodeDuplicateName:
109+
case obj.ErrCodeConflict, obj.ErrCodeDuplicateName, obj.ErrCodeLastHead:
110110
return http.StatusConflict
111111
case obj.ErrCodeServerError:
112112
return http.StatusInternalServerError

server/api/routes/games_private_share.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,21 @@ func EnablePrivateShare(w http.ResponseWriter, r *http.Request) {
110110
return
111111
}
112112

113-
// Update the game with private share config
114-
g.PrivateSponsoredApiKeyShareID = req.SponsorKeyShareID
113+
// Create a game-scoped share from the user's personal share.
114+
// This is needed so the guest play flow can resolve the API key with uuid.Nil.
115+
gameScopedShareID, err := db.CreatePrivateShareSponsorship(r.Context(), user.ID, gameID, *req.SponsorKeyShareID)
116+
if err != nil {
117+
log.Warn("failed to create private share sponsorship", "game_id", gameID, "error", err)
118+
if appErr, ok := err.(*obj.AppError); ok {
119+
httpx.WriteAppError(w, appErr)
120+
return
121+
}
122+
httpx.WriteError(w, http.StatusInternalServerError, "Failed to set up private share: "+err.Error())
123+
return
124+
}
125+
126+
// Update the game with the game-scoped share ID and session limit
127+
g.PrivateSponsoredApiKeyShareID = gameScopedShareID
115128
g.PrivateShareRemaining = req.MaxSessions
116129

117130
if err := db.UpdateGame(r.Context(), user.ID, g); err != nil {
@@ -179,6 +192,13 @@ func RevokePrivateShare(w http.ResponseWriter, r *http.Request) {
179192
}
180193
}
181194

195+
// Delete the game-scoped share created by EnablePrivateShare
196+
if g.PrivateSponsoredApiKeyShareID != nil {
197+
if err := db.DeletePrivateShareSponsorship(r.Context(), *g.PrivateSponsoredApiKeyShareID); err != nil {
198+
log.Warn("failed to delete private share sponsorship", "game_id", gameID, "share_id", *g.PrivateSponsoredApiKeyShareID, "error", err)
199+
}
200+
}
201+
182202
// Clear all private share fields — hash is removed, a new one will be generated on next enable
183203
g.PrivateShareHash = nil
184204
g.PrivateSponsoredApiKeyShareID = nil

server/api/routes/sessions.go

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ func PostSessionAction(w http.ResponseWriter, r *http.Request) {
168168
httpx.WriteError(w, http.StatusNotFound, "Session not found")
169169
return
170170
}
171+
log.Debug("[TRACE] session loaded from DB for action", "session_id", session.ID, "ai_session", session.AiSession, "platform", session.AiPlatform)
171172

172173
// Get current status fields from the latest message in the session
173174
var currentStatus []obj.StatusField
@@ -184,18 +185,11 @@ func PostSessionAction(w http.ResponseWriter, r *http.Request) {
184185
StatusFields: currentStatus,
185186
}
186187

187-
// Re-resolve API key fresh (sponsorship may have been removed since session was created)
188-
if httpErr := game.ResolveSessionApiKey(r.Context(), session); httpErr != nil {
189-
log.Debug("failed to resolve API key for session action", "session_id", session.ID, "error", httpErr.Message)
190-
httpx.WriteHTTPError(w, httpErr)
191-
return
192-
}
193-
194-
// Execute the action and get streaming response
188+
// Re-resolve API key and execute action with fallback retry logic
195189
log.Debug("executing session action", "session_id", session.ID, "message_length", len(req.Message))
196-
response, httpErr := game.DoSessionAction(r.Context(), session, action)
190+
response, httpErr := game.DoSessionActionWithFallback(r.Context(), session, action)
197191
if httpErr != nil {
198-
log.Debug("session action failed", "session_id", session.ID, "error", httpErr.Message)
192+
log.Warn("session action failed", "session_id", session.ID, "error", httpErr.Message)
199193
httpx.WriteHTTPError(w, httpErr)
200194
return
201195
}

server/api/routes/system_settings.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ import (
2121
// @Failure 500 {object} httpx.ErrorResponse
2222
// @Router /system/settings [get]
2323
func GetSystemSettings(w http.ResponseWriter, r *http.Request) {
24+
user := httpx.UserFromRequest(r)
25+
26+
// Require admin
27+
if user.Role == nil || user.Role.Role != obj.RoleAdmin {
28+
httpx.WriteError(w, http.StatusForbidden, "Forbidden: admin access required")
29+
return
30+
}
31+
2432
settings, err := db.GetSystemSettings(r.Context())
2533
if err != nil {
2634
httpx.WriteError(w, http.StatusInternalServerError, "Failed to get system settings: "+err.Error())

server/api/routes/users.go

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,32 @@ type UsersJwtResponse struct {
4646
// @Security BearerAuth
4747
// @Router /users [get]
4848
func GetUsers(w http.ResponseWriter, r *http.Request) {
49-
users, err := db.GetAllUsers(r.Context())
50-
if err != nil {
51-
httpx.WriteError(w, http.StatusInternalServerError, "Failed to get users: "+err.Error())
49+
user := httpx.UserFromRequest(r)
50+
51+
// Admin sees all users
52+
if user.Role != nil && user.Role.Role == obj.RoleAdmin {
53+
users, err := db.GetAllUsers(r.Context())
54+
if err != nil {
55+
httpx.WriteError(w, http.StatusInternalServerError, "Failed to get users: "+err.Error())
56+
return
57+
}
58+
httpx.WriteJSON(w, http.StatusOK, users)
5259
return
5360
}
5461

55-
httpx.WriteJSON(w, http.StatusOK, users)
62+
// Head/staff sees members of their institution
63+
if user.Role != nil && (user.Role.Role == obj.RoleHead || user.Role.Role == obj.RoleStaff) && user.Role.Institution != nil {
64+
members, err := db.GetInstitutionMembers(r.Context(), user.Role.Institution.ID, user.ID)
65+
if err != nil {
66+
httpx.WriteError(w, http.StatusInternalServerError, "Failed to get users: "+err.Error())
67+
return
68+
}
69+
httpx.WriteJSON(w, http.StatusOK, members)
70+
return
71+
}
72+
73+
// Everyone else (individual, participant) gets 403
74+
httpx.WriteAppError(w, obj.ErrForbidden("only admins or institution heads/staff can list users"))
5675
}
5776

5877
// GetCurrentUser godoc
@@ -159,15 +178,23 @@ func UpdateUserLanguage(w http.ResponseWriter, r *http.Request) {
159178
// @Security BearerAuth
160179
// @Router /users/{id} [get]
161180
func GetUserByID(w http.ResponseWriter, r *http.Request) {
162-
userID, err := httpx.PathParamUUID(r, "id")
181+
currentUser := httpx.UserFromRequest(r)
182+
183+
targetID, err := httpx.PathParamUUID(r, "id")
163184
if err != nil {
164185
httpx.WriteError(w, http.StatusBadRequest, "Invalid user ID")
165186
return
166187
}
167188

168-
log.Debug("getting user by ID", "user_id", userID)
189+
log.Debug("getting user by ID", "user_id", targetID, "requested_by", currentUser.ID)
169190

170-
user, err := db.GetUserByID(r.Context(), userID)
191+
// Permission check: own profile, admin, or head/staff for org members
192+
if err := db.CanReadUser(r.Context(), currentUser.ID, targetID); err != nil {
193+
httpx.WriteError(w, http.StatusForbidden, "Not authorized to read this user")
194+
return
195+
}
196+
197+
user, err := db.GetUserByID(r.Context(), targetID)
171198
if err != nil {
172199
httpx.WriteError(w, http.StatusNotFound, "User not found")
173200
return
@@ -438,6 +465,8 @@ func DeleteUser(w http.ResponseWriter, r *http.Request) {
438465
status = http.StatusForbidden
439466
case obj.ErrCodeUnauthorized:
440467
status = http.StatusUnauthorized
468+
case obj.ErrCodeLastHead:
469+
status = http.StatusConflict
441470
}
442471
httpx.WriteError(w, status, appErr.Error())
443472
} else {

server/api/routes/workshops.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type UpdateWorkshopRequest struct {
2626
AiQualityTier *string `json:"aiQualityTier,omitempty"`
2727
ShowPublicGames bool `json:"showPublicGames"`
2828
ShowOtherParticipantsGames bool `json:"showOtherParticipantsGames"`
29+
DesignEditingEnabled bool `json:"designEditingEnabled"`
30+
IsPaused bool `json:"isPaused"`
2931
}
3032

3133
// CreateWorkshop godoc
@@ -213,6 +215,8 @@ func UpdateWorkshop(w http.ResponseWriter, r *http.Request) {
213215
AiQualityTier: req.AiQualityTier,
214216
ShowPublicGames: req.ShowPublicGames,
215217
ShowOtherParticipantsGames: req.ShowOtherParticipantsGames,
218+
DesignEditingEnabled: req.DesignEditingEnabled,
219+
IsPaused: req.IsPaused,
216220
}
217221

218222
workshop, err := db.UpdateWorkshop(r.Context(), id, user.ID, params)

server/db/api_key_shares.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ func DeleteApiKey(ctx context.Context, userID uuid.UUID, shareID uuid.UUID) erro
125125
return obj.ErrServerError("failed to clear user default api key references")
126126
}
127127

128+
// Clear workshop default_api_key_share_id references before deleting shares
129+
if err := queries().ClearWorkshopDefaultApiKeyShareByApiKeyID(ctx, key.ID); err != nil {
130+
return obj.ErrServerError("failed to clear workshop default api key references")
131+
}
132+
128133
// Clean up private share guest data before clearing references
129134
privateGames, _ := queries().GetGamesWithPrivateShareByApiKeyID(ctx, key.ID)
130135
for _, g := range privateGames {
@@ -316,7 +321,8 @@ func createApiKeyShareInternal(ctx context.Context, userID uuid.UUID, apiKeyID u
316321
return &result.ID, nil
317322
}
318323

319-
// DeleteApiKeyShare deletes a single share. Owner can delete any share, others can only delete their own.
324+
// DeleteApiKeyShare deletes a single share.
325+
// Allowed by: key owner, share target user, or head/staff of the institution the share targets.
320326
func DeleteApiKeyShare(ctx context.Context, userID uuid.UUID, shareID uuid.UUID) error {
321327
share, err := queries().GetApiKeyShareByID(ctx, shareID)
322328
if err != nil {
@@ -331,10 +337,27 @@ func DeleteApiKeyShare(ctx context.Context, userID uuid.UUID, shareID uuid.UUID)
331337
isOwner := key.UserID == userID
332338
isOwnShare := share.UserID.Valid && share.UserID.UUID == userID
333339

334-
if !isOwner && !isOwnShare {
340+
// Head/staff of the target institution can remove org-scoped shares from colleagues
341+
isOrgMember := false
342+
if share.InstitutionID.Valid {
343+
user, lookupErr := GetUserByID(ctx, userID)
344+
if lookupErr == nil && user.Role != nil && user.Role.Institution != nil &&
345+
user.Role.Institution.ID == share.InstitutionID.UUID &&
346+
(user.Role.Role == obj.RoleHead || user.Role.Role == obj.RoleStaff) {
347+
isOrgMember = true
348+
}
349+
}
350+
351+
if !isOwner && !isOwnShare && !isOrgMember {
335352
return obj.ErrForbidden("not authorized to delete this share")
336353
}
337354

355+
// Clear workshop default_api_key_share_id if it references this share
356+
_ = queries().ClearWorkshopDefaultApiKeyShareByShareID(ctx, uuid.NullUUID{UUID: shareID, Valid: true})
357+
358+
// Clear game sponsor references if they reference this share
359+
_ = queries().ClearGameSponsoredApiKeyByShareID(ctx, uuid.NullUUID{UUID: shareID, Valid: true})
360+
338361
return queries().DeleteApiKeyShare(ctx, shareID)
339362
}
340363

0 commit comments

Comments
 (0)