diff --git a/pkg/routes/api/v2/projects.go b/pkg/routes/api/v2/projects.go index c8e45018d..37ff4bd1e 100644 --- a/pkg/routes/api/v2/projects.go +++ b/pkg/routes/api/v2/projects.go @@ -126,9 +126,14 @@ func projectsRead(ctx context.Context, in *struct { } // ReadOne doesn't act on Expand itself; the caller's max permission comes // from DoReadOne's CanRead result. Surface it on the model only when asked, - // matching the list operation's expand=permissions behaviour. + // matching the list operation's expand=permissions behaviour. When not + // expanded, force PermissionUnknown so max_permission marshals as null + // instead of defaulting to the zero value (0 = PermissionRead), which would + // be a misleading "read" permission for projects the caller may fully own. if models.ProjectExpandable(in.Expand) == models.ProjectExpandableRights { project.MaxPermission = models.Permission(maxPermission) + } else { + project.MaxPermission = models.PermissionUnknown } // PreconditionFailed wants the unquoted etag; response header uses RFC 9110 quoted form. etag := fmt.Sprintf("%d-%d", project.ID, project.Updated.UnixNano()) diff --git a/pkg/webtests/huma_project_test.go b/pkg/webtests/huma_project_test.go index da820e3f8..df24b107b 100644 --- a/pkg/webtests/huma_project_test.go +++ b/pkg/webtests/huma_project_test.go @@ -69,6 +69,9 @@ func TestHumaProject(t *testing.T) { assert.Contains(t, rec.Body.String(), `"title":"Test1"`) assert.NotContains(t, rec.Body.String(), `"title":"Test2"`) assert.Contains(t, rec.Body.String(), `"username":"user1"`) + // Without expand=permissions, max_permission must be null rather than + // defaulting to 0 (which would falsely read as PermissionRead). + assert.Contains(t, rec.Body.String(), `"max_permission":null`) assert.NotEmpty(t, rec.Result().Header.Get("ETag")) }) t.Run("Expand permissions", func(t *testing.T) {