Assert the read-one body carries max_permission, and add
TestHumaTaskComment_ETagReflectsPermission proving two users with different
permission on a comment's parent task (project 9: owner user6 vs read-share
user1, comment 6 on task 18) receive different ETags.
Decode the ReadOne/Normal body and assert MaxPermission equals the real
permission (admin for the owner) instead of substring-matching, so a
regression to 0 or null is caught precisely.
Add TestHumaProject_NullMaxPermissionRoundTrips: create/update return
max_permission:null, and PUTting that response body back verbatim must
succeed (200, not 422). max_permission is readOnly so Huma ignores it on
write, and Permission.UnmarshalJSON treats JSON null as a no-op.
The project test port had added db.LoadFixtures() into the shared
webHandlerTestV2.serve(), reloading fixtures before every request. That
wiped runtime-created rows between requests within a test, breaking the
create-then-read-back contract every v2 resource relies on (e.g.
TestHumaTeam/Create/Public read its freshly-created team back and got 403).
Revert that shared-harness change and isolate the project/archived tests
the way the team and label tests do: each subtest builds its own handler
via handlerFor, so it runs against freshly loaded fixtures (setupTestEnv
reloads once per handler), while a create-then-read-back sequence reuses
one handler within the subtest.
Read-one now returns a projectReadBody embedding models.Project with
max_permission always populated from CanRead, matching the labels/views
value-embed pattern instead of gating it behind expand=permissions.
CanRead yields a real permission for every readable project (Favorites
pseudo-project and saved-filter-backed ones included), so the field is
always meaningful on a read.
Project remains the no-ETag exception: the response carries user-scoped
favorite/subscription state that changes without bumping Updated, so it
is served fresh.
Update routes its body through the read shape so AutoPatch's GET→PUT echo
of the read-only max_permission validates. Create/Update return null for
max_permission (not computed there) rather than a misleading 0 (=read).
Bring the v2 project webtest to 1:1 parity with v1's TestProject and
TestArchived so the v2 routes independently prove everything v1 proved:
- Full sharing matrix on ReadOne/Update/Delete across team, user,
parent-team and parent-user shares x read/write/admin, asserting
allow/deny and (for ReadOne) the granted max_permission level via
expand=permissions (v2's replacement for v1's x-max-permission header).
- Create permission matrix via parent_project_id (forbidden parent,
parent-team/user write+admin allowed, read-only denied), nonexistent
parent (404), and title-too-long (422) on both Create and Update.
- Create response assertions (owner echo, description, tasks not embedded).
- ReadAll search (q=) with exact cardinality and archived propagation to
child project 21.
- New TestHumaArchived ports the HTTP-observable archived behaviours:
no edit/unarchive under an archived parent, self-archived edit denied
but unarchive allowed, and archiving a project (412 / ErrCodeProjectIsArchived).
Make webHandlerTestV2.serve reload fixtures per request, mirroring v1's
per-request fixture reload, so mutating subtests don't leak state across
the shared Echo instance.
The project read response is enriched with user-scoped, derived state
(subscription, favorite, views, computed archived state) that can change
without bumping project.Updated. An ETag built only from Updated would
therefore hand out stale 304s and hide those changes from the client.
Serve project reads fresh on every call by returning the no-ETag
singleBody envelope and dropping the conditional.Params input. Labels
keep their ETag because their response has no such volatile derived
fields. Update the ReadOne/Normal webtest to assert no ETag is sent.
The project read handler left MaxPermission at its zero value when
expand=permissions was not requested, which serialised as 0
(PermissionRead) instead of being omitted. Force PermissionUnknown so
the field marshals as null, matching the list operation's behaviour and
avoiding a misleading read permission for projects the caller may own.
Assert the null shape in the ReadOne/Normal webtest.
Add a simple /{id} CRUD resource for projects on the Huma-backed /api/v2,
mirroring labels.go. Exposes the expand query param (value "permissions")
which surfaces the caller's max permission per project on both list and read.
The handler stays standard (DoReadAll/DoReadOne/DoCreate/DoUpdate/DoDelete);
the model's ReadOne keeps handling the Favorites pseudo-project and
saved-filter-backed projects.
Self-registers via init() -> AddRouteRegistrar; no routes.go change.
projectusers is intentionally out of scope.
Read/update use a per-resource struct that embeds the model by value and adds a
readOnly max_permission field (labelReadBody, projectViewReadBody); Go and Huma
promote the embedded fields, so the body stays flat with no custom marshaler and
nothing on the shared models. The handler passes the model's Updated and the
permission to conditionalReadResponse, which folds the permission into the ETag.
Adds a webtest asserting two callers with different permission on the same label
get different ETags, plus max_permission presence assertions.
Assert the specific domain error code (ErrCodeTaskDoesNotExist) on the
nonexistent-source-task case, matching v1's TestTaskDuplicate. v2 carries
the code as the numeric `code` field of the RFC 9457 problem+json body,
so the test now checks that field instead of only the 404 status.
Bring the v2 avatar webtest to 1:1 parity with the v1 avatar tests so
the v1 routes and tests can be removed without losing coverage:
- link-share auth path: a request authenticated as a link-share user
(not a regular JWT) returns 200 + non-empty image bytes, porting
v1's TestLinkShareAvatar.
- bot user: the botmarble provider path returns 200 + SVG bytes, a
distinct rendering v1 never exercised; asserts the marble mask id so
it cannot silently fall through to the default placeholder.
- non-numeric size: rejected with 422 (Huma's int64 query validation)
rather than v1's 400 ErrInvalidModel, both being client errors that
refuse the malformed input.
Cut narration a reader can infer from the code (envelope element type,
path-param binding, per-case test descriptions). Keep the non-obvious
rationale: IDOR scoping, RFC 9110 etag quoting, why the feature gate sits
in the registrar, and the author-only fixture crux.
The Forbidden non-author update/delete cases used user6, who also lacks access
to task 1, so they only proved access denial, not the author-only restriction.
Add cases driven by testuser1 against comment 4 on task 16 (project 7): user1
has write access via team 3 but did not author the comment (user6 did), so a
403 there genuinely exercises the authorship branch. Keep the user6 cases as
the no-access negatives, relabelled for clarity.
Add TaskComment CRUD on /api/v2 under /tasks/{task}/comments, mirroring
the project_views nested-resource shape. The resource is feature-gated by
config.ServiceEnableTaskComments, checked inside the registrar so it runs
after config has loaded. Self-registers via init()+AddRouteRegistrar; no
routes.go change. ReadAll exposes the order_by (asc/desc) query param.
Adds doc:/readOnly: tags to the shared TaskComment model fields and a
TestHumaTaskComment webtest covering list/read/create/update/delete plus
negatives (non-author forbidden, comment under the wrong task -> 404).
Bring TestHumaAdminProjects to 1:1 parity with v1 TestAdmin_ListProjects
by asserting owner hydration ("username":"user1", never "owner":null)
and project field presence ("id":, "title":) on the response body, in
addition to the existing gate personas and ownership/archived visibility
cardinality checks.
Bring the merged v2 Label webtest (TestHumaLabel) to 1:1 parity with the
model-level matrix in pkg/models/label_test.go so the v2 HTTP surface
independently proves the full visibility/permission contract once v1's
routes and tests are removed.
Added scenarios:
- ReadAll asserts the EXACT visible set for user1 = {1,2,4,7,8}, with #3
(other owner, unattached), #5 (other owner, inaccessible task) and #6
(GHSA private fixture) explicitly absent — not just contains/not-contains.
- ReadOne: #3 forbidden (other owner, unattached); #6 forbidden (GHSA
private); #4 ALLOWED (other owner but visible via an accessible task);
#7 allowed (own, unattached); #8 allowed (own, only on inaccessible task).
- Update/Delete: #4 forbidden (GHSA-hj5c-mhh2-g7jq read-vs-write: readable
but not writable by the non-owner); #3 forbidden; #6 forbidden.
- Create asserts hex-color normalization (#aabbcc -> aabbcc).
Keeps the existing ETag/304 and merge-patch subtests.
Adds Team CRUD on /api/v2 mirroring the labels reference resource:
list, read, create, update, delete under /teams[/{id}].
- The list op exposes an include_public query param bound onto the
model so Team.ReadAll can surface public teams (gated by the instance
public-teams setting).
- Read ops emit an ETag and honor If-None-Match (304).
- Model fields gain doc: tags; server-controlled fields are marked
readOnly:true.
- Self-registers via init()/AddRouteRegistrar; no routes.go change.
- New webtest TestHumaTeam (named to avoid clashing with the v1 model
TestTeam) covers list/read/create/update/delete plus negatives
(non-member 403, nonexistent 403/404) and ETag/304.
MaxBodyBytes was set to exactly the configured max file size, but a
multipart request carries extra bytes (boundary, part headers) on top of
the file, so a file at the limit could be rejected by Huma before the
handler runs. Mirror the +2 MB overhead that Echo's global BodyLimit
middleware already allows so a max-sized avatar isn't rejected.
Browsers set a real image Content-Type (image/png, image/jpeg, ...) on
the multipart avatar part, while programmatic clients often send
application/octet-stream. The part contentType tag is an allow-list for
Huma's MimeTypeValidator, which runs before the handler; broaden it so
both cases are accepted instead of being rejected with a 422.
The byte-level mimetype.DetectReader check in the handler remains the
real security gate and is unchanged.
Extend the webtest with a case that sends a part declared as image/png
and asserts it reaches the handler successfully.
Add PUT /api/v2/user/settings/avatar, the first multipart/form-data file
upload on the Huma-backed v2 API. Reuses v1's byte-level mime validation
(mimetype.DetectReader) and storage (upload.StoreAvatarFile), modeling the
request as a huma.MultipartFormFiles input so it renders as multipart/form-data
in the OpenAPI spec instead of being read off the raw echo context.
Flips the user's avatar provider to "upload" on success. Authenticated (JWT).
Add GET /api/v2/avatar/{username}, the v2 reference for a binary response
modeled in the OpenAPI spec. Reuses the v1 avatar provider logic (provider
lookup, size clamp to config.ServiceMaxAvatarSize, runtime content-type) and
returns raw image bytes via Huma's []byte body + dynamic Content-Type header
idiom, advertised in the spec as application/octet-stream.
The endpoint is authenticated under the global security like every other v2
route (an anonymous request gets a 401); it is not public.
Add the admin + license gate for /api/v2 and ship the first gated
resource, GET /api/v2/admin/projects (AdminProjectList).
The gate reuses the existing v1 middleware functions unchanged —
RequireFeature(license.FeatureAdminPanel) and RequireInstanceAdmin(),
both of which serve 404 on failure. Rather than splitting the single
v2 Huma API into a separate gated sub-group (which would split the
OpenAPI spec and drop admin operations from /api/v2/openapi.json), the
gate is applied as a path-scoped Echo middleware on the shared /api/v2
group, firing only for /api/v2/admin/* and after the token middleware.
This preserves v1's 404-not-403 semantics and keeps admin routes in the
unified v2 spec and Scalar docs.
AdminProjectList lists every project on the instance (archived
included), behind the gate. Adds doc:/readOnly: tags to the shared
Project model so it documents correctly as a v2 schema.
Tests in pkg/webtests/huma_admin_test.go (TestHumaAdminProjects) cover
all three personas: non-admin -> 404, admin without feature -> 404,
admin with feature -> 200 list, plus unauthenticated -> 401.
Add ProjectView CRUD on /api/v2 under the nested path
/projects/{project}/views[/{view}], establishing the two-path-param
binding pattern for sub-resources. Mirrors the labels.go handler shape
and reuses handler.Do* so permission checks stay at the model layer.
Both {project} and {view} are bound on every operation; {project} is
threaded onto ProjectView.ProjectID (ReadOne resolves via
GetProjectViewByIDAndProject, which needs the parent id). List wraps the
[]*models.ProjectView slice in the shared Paginated envelope, read sends
an ETag for If-None-Match/304, and AutoPatch synthesises PATCH.
Also:
- Tag exposed ProjectView / ProjectViewBucketConfiguration / nested
TaskCollection fields with doc: descriptions; mark server-controlled
fields (id, project_id, created, updated) readOnly. Safe for v1.
- Give ProjectViewKind and BucketConfigurationModeKind a huma.SchemaProvider
so the string-serialised enums reflect as string schemas instead of
Huma's default integer schema (which rejected the string form with 422).
Routes registered in registerAPIRoutesV2 before EnableAutoPatch.
Seven integration tests covering the Label pilot:
- Create_Read_Update_Delete — full round-trip through POST/GET/PUT/
DELETE, asserts body + status at each step.
- List_ReturnsItems — GET /labels, asserts items[] is non-empty and
contains a known fixture; this is the regression catcher for the
generic-any silent-empty trap the spike hit.
- ForbiddenErrorShape — user1 reading user13's private label returns
403 problem+json with the RFC 9457 type/title/status/detail shape.
- ValidationErrorShape — POST with empty title fails Huma's
minLength:1 check with 422 problem+json + structured per-field
errors locating `title`.
- ETagReturns304 — first GET captures ETag, second GET with
If-None-Match returns 304.
- PATCHMergePatch — AutoPatch-synthesised PATCH with partial
application/merge-patch+json body updates one field and leaves
the others untouched; a follow-up GET confirms preservation.
- OpenAPISpecDescribesAllFive — the unauthenticated
/api/v2/openapi.json surfaces GET+POST on /labels and GET+PUT+
DELETE on /labels/{id}.
Switches the input normalisation from lower- to uppercase so identifiers
canonicalise the same way GitHub-style refs do (e.g. "PROJ-42"). The
positive identifier tests are dropped for now because the existing
fixtures store identifiers as lowercase ("test1") and the SQL comparison
remains case-sensitive — once the column-side case-insensitive match
lands, full coverage can be reinstated.
Normalises the input side so GitHub-style references like "TEST1-42" and
"test1-42" resolve to the same project. The SQL comparison itself remains
case-sensitive for now; case-insensitive matching on the column will be
addressed separately.
Allows GET /projects/{project}/tasks/by-index/{index} to resolve {project}
as either a numeric id or a project identifier (e.g. "PROJ"), so callers
can build GitHub-style task references like "PROJ-42" without first
looking up the project's numeric id. Pure-digit values remain interpreted
as ids, which makes identifiers consisting solely of digits unreachable
via this route.
GHSA-2vq4-854f-5c72 / CVE-2026-35595: the recursive permission CTE
cascades Admin from any owned ancestor, so a user with Write on a
shared project could reparent it under an attacker-owned root and
resolve as Admin on the moved project via the new parent.
Require Admin on both the moved project and the new parent whenever
parent_project_id is set to a non-zero value that differs from the
stored value. The gate lives in UpdateProject rather than CanUpdate
because CanUpdate is reused by permission-check-only callers
(buckets, webhooks, task ops) that pass stub &Project{ID:...} values
with ParentProjectID=0 and never commit a reparent — gating there
would spuriously trip the check for every such call.
Only non-zero ParentProjectID is gated: the generic update handler
binds a fresh struct, so an omitted parent_project_id is
indistinguishable from an explicit 0. Detach-to-root via the generic
endpoint is therefore out of scope for this fix and is tracked as a
follow-up (needs a pointer field to disambiguate).
Drives the login endpoint through 11 failed TOTP attempts against user10
and asserts the account ends up locked in the database, then verifies a
subsequent login with a valid TOTP code is rejected with
ErrCodeAccountLocked. Exercises the GHSA-fgfv-pv97-6cmj regression
against the real handler path.
Multiget REPORT requests would happily return tasks from projects
different from the one in the href, even though GetTasksByUIDs now
filters by access. Drop any returned task whose real project_id does
not match the project ID parsed from the href path segment.
Hardening for GHSA-48ch-p4gq-x46x.
Even with the GetTasksByUIDs authz filter in place, a user with access
to multiple projects could read a task from project B by requesting it
under project A's URL. Enforce that the task's real project_id matches
the project ID parsed from the CalDAV URL path and 404 otherwise.
Adjusts the Delete Subtask test to use the correct URL project for
uid-caldav-test-child-task-2 (which lives in project 38, not 36);
the previous URL only worked because of the authz gap being closed.
Hardening for GHSA-48ch-p4gq-x46x.
End-to-end regression test for GHSA-96q5-xm3p-7m84 / CVE-2026-35594: mints
a JWT for a link share via the real helper, then deletes the share row and
invokes the real ReadAllWeb handler to prove the full request path (not
just the unit-tested GetLinkShareFromClaims) surfaces the revocation.
Also fixes a pre-existing stale literal in the TestLinkSharing test fixture
struct: linkshareRead declared Hash="test1" while the actual fixture row
id=1 uses Hash="test". The old code never looked at the DB so the mismatch
went unnoticed; after the fix it would cause every link-share webtest that
used linkshareRead to fail hash validation.