Merge pull request #609 from iflytek/fix/auth-revoked-token-validation

test(auth): cover revoked CLI token lifecycles
This commit is contained in:
XiaoSeS 2026-07-29 03:37:47 +08:00 committed by GitHub
commit 7872e64177
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2487 additions and 5 deletions

View file

@ -377,7 +377,7 @@ API Token 仍保留但定位从“CLI 唯一认证方式”调整为“平台
- 用途:自动化脚本、兼容层调用、手工 Token 管理、后续系统集成
- 存储:只存 SHA-256 哈希,明文只展示一次
- 校验:从 `Authorization: Bearer <token>` 提取 → 哈希比对 → 加载关联用户 → 检查用户状态
- 失败闭合:公共读接口只有在缺少 `Authorization` 头时才按匿名访问处理;只要出现 Bearer 凭证,空值、格式错误、未知、过期、已吊销、用户缺失或用户禁用均返回 401不能回退为匿名访问
- 失败闭合与身份优先级:共享认证过滤器只识别 Bearer scheme。有效 Bearer 覆盖已加载的 Web Session 身份Bearer 为空、格式错误、未知、过期、已吊销、用户缺失或用户禁用时立即返回 401即使存在有效 Session 也不得回退。缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时保留有效 Session若无 Session公共读接口按匿名访问`whoami` 返回 401
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
- 拒绝原因API Token 缺少作用域或不能访问某个接口时403 响应返回本地化的安全原因和 `requestId`;其他授权失败仍返回通用信息,避免暴露内部异常
@ -622,10 +622,15 @@ window.location.href = '/oauth2/authorization/github'
### 10.3 CLI API
| 接口 | 所需凭证 | 额外判定 |
|------|---------|---------|
| `GET /api/v1/whoami` | 任意有效 Bearer Token | 无 |
| `POST /api/v1/publish` | Bearer Token + `skill:publish` | 普通用户要求目标 namespace 成员;`SUPER_ADMIN` 可绕过 |
| 接口 | 凭证规则 | 授权与错误语义 |
|------|---------|---------------|
| `GET /api/cli/v1/auth/whoami` | 有效 Web Session 或有效 Bearer Token | 无有效身份返回 401坏 Bearer 即使存在 Session 也返回 401 |
| `GET /api/cli/v1/skills/search` | Session 可用;无 Session 时可匿名;提供 Bearer 时必须有效 | 匿名仅返回公开可安装 skill有效 Bearer 覆盖 Session坏 Bearer 返回 401不得降级 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/resolve` | Session 可用;无 Session 时可匿名读取公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403 |
| `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download` | Session 可用;无 Session 时可匿名下载公开资源;提供 Bearer 时必须有效 | 有效 Bearer 覆盖 Session坏 Bearer 返回 401有效身份无资源权限返回 403 |
Spring Security 先加载 Web Session 身份,共享 API token 过滤器随后只处理 Bearer scheme。有效 Bearer 会覆盖 Session确保请求使用 token 的用户、角色与 scopeBearer 为空、格式错误、未知、过期、已撤销、用户缺失或用户禁用时,过滤器清除当前身份并立即返回 401不能回退到 Session 或匿名身份。完全缺少 `Authorization` 头或使用 Basic/其他非 Bearer scheme 时,过滤器不改变已有 Session如果 Session 也不存在,公共读接口按匿名身份执行,而 `whoami` 返回 401。身份已验证但 token scope 或资源可见性不足时返回 403服务端不向客户端区分 token 不存在、过期或已撤销。`whoami.email` 字段始终存在,但没有可用邮箱时值为 `null`
### 10.4 Admin API

View file

@ -0,0 +1,305 @@
openapi: 3.0.3
info:
title: SkillHub CLI Authentication API
version: 1.0.0
description: >-
Authentication contract for CLI identity and public skill reads. A valid
Bearer credential overrides a Web Session identity. Once the Bearer scheme
is used, the credential must be valid: empty, malformed, unknown, expired,
or revoked Bearer credentials return HTTP 401 and never fall back to the
Session or anonymous access. An absent Authorization header or an
unsupported scheme such as Basic preserves a valid Web Session. Without a
Session, public reads use anonymous visibility and whoami returns HTTP 401.
servers:
- url: /
tags:
- name: CLI Authentication
- name: CLI Skills
paths:
/api/cli/v1/auth/whoami:
get:
tags: [CLI Authentication]
summary: Return the current CLI identity
operationId: cliWhoAmI
description: Requires a valid Bearer credential or Web Session. Bearer takes priority over Session; invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session, but returns 401 when no Session exists.
security:
- bearerAuth: []
- sessionAuth: []
responses:
'200':
description: Authenticated CLI identity
content:
application/json:
schema:
$ref: '#/components/schemas/CliWhoAmIEnvelope'
'401':
$ref: '#/components/responses/Unauthorized'
/api/cli/v1/skills/search:
get:
tags: [CLI Skills]
summary: Search CLI-installable skills
operationId: cliSearchSkills
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- name: q
in: query
required: false
schema: {type: string}
example: pdf
description: Optional search text.
- name: limit
in: query
required: false
schema: {type: integer, format: int32, default: 20}
example: 20
description: Maximum number of results.
responses:
'200':
description: Search result
content:
application/json:
schema:
$ref: '#/components/schemas/CliSearchEnvelope'
'401':
$ref: '#/components/responses/Unauthorized'
/api/cli/v1/skills/{namespace}/{slug}/resolve:
get:
tags: [CLI Skills]
summary: Resolve a skill version
operationId: cliResolveSkill
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Namespace'
- $ref: '#/components/parameters/Slug'
- name: version
in: query
required: false
schema: {type: string}
example: 1.0.0
description: Optional exact version; omitted resolves latest.
responses:
'200':
description: Resolved version
content:
application/json:
schema:
$ref: '#/components/schemas/CliResolveEnvelope'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/cli/v1/skills/{namespace}/{slug}/download:
get:
tags: [CLI Skills]
summary: Download the latest installable skill version
operationId: cliDownloadLatestSkill
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Namespace'
- $ref: '#/components/parameters/Slug'
responses:
'200':
$ref: '#/components/responses/Download'
'302':
$ref: '#/components/responses/DownloadRedirect'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'503':
$ref: '#/components/responses/StorageUnavailable'
/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download:
get:
tags: [CLI Skills]
summary: Download an exact installable skill version
operationId: cliDownloadSkillVersion
description: Valid Bearer overrides Web Session. Invalid Bearer returns 401 without Session fallback. An absent or non-Bearer Authorization header preserves Session; without Session, the request uses anonymous public visibility.
security:
- {}
- sessionAuth: []
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Namespace'
- $ref: '#/components/parameters/Slug'
- $ref: '#/components/parameters/Version'
responses:
'200':
$ref: '#/components/responses/Download'
'302':
$ref: '#/components/responses/DownloadRedirect'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'503':
$ref: '#/components/responses/StorageUnavailable'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: SkillHub API token
description: API token issued by SkillHub. A valid token overrides Web Session; invalid lifecycle states all return the same 401 response without Session fallback.
sessionAuth:
type: apiKey
in: cookie
name: SESSION
description: Spring Session browser identity. It is preserved when Authorization is absent or uses a non-Bearer scheme, and is overridden by a valid Bearer token.
parameters:
Namespace:
name: namespace
in: path
required: true
schema: {type: string}
example: global
description: Namespace slug.
Slug:
name: slug
in: path
required: true
schema: {type: string}
example: pdf-parser
description: Skill slug.
Version:
name: version
in: path
required: true
schema: {type: string}
example: 1.0.0
description: Exact semantic version.
responses:
Download:
description: ZIP package stream
headers:
Content-Disposition:
schema: {type: string}
description: Attachment filename.
content:
application/zip:
schema: {type: string, format: binary}
DownloadRedirect:
description: Redirect to a presigned object-storage URL
headers:
Location:
schema: {type: string, format: uri}
BadRequest:
description: Namespace, skill, or version cannot be resolved.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
Unauthorized:
description: No valid supported identity is present where required, or the Bearer credential is empty, malformed, unknown, expired, revoked, or belongs to an unavailable user. Invalid Bearer never falls back to Web Session.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
example:
code: 401
msg: Authentication required
data: null
timestamp: '2026-07-28T00:00:00Z'
requestId: req-123
Forbidden:
description: Credential is valid but token scope or resource permission is insufficient.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
example:
code: 403
msg: Forbidden
data: null
timestamp: '2026-07-28T00:00:00Z'
requestId: req-123
StorageUnavailable:
description: Object storage is unavailable.
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
schemas:
Envelope:
type: object
required: [code, msg, data, timestamp, requestId]
properties:
code: {type: integer, format: int32}
msg: {type: string}
data: {type: object, nullable: true}
timestamp: {type: string, format: date-time}
requestId: {type: string, example: req-123}
ErrorEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data: {type: object, nullable: true, example: null}
CliWhoAmIEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
$ref: '#/components/schemas/CliWhoAmI'
CliWhoAmI:
type: object
required: [handle, displayName, email]
properties:
handle: {type: string, example: user-123}
displayName: {type: string, example: CLI User}
email: {type: string, format: email, nullable: true, example: cli@example.com, description: Email address when available; the required field is null when the account has no email.}
CliSearchEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
$ref: '#/components/schemas/CliSearchResult'
CliSearchResult:
type: object
required: [items, total, limit]
properties:
items:
type: array
items: {$ref: '#/components/schemas/CliSearchItem'}
total: {type: integer, format: int64, example: 1}
limit: {type: integer, format: int32, example: 20}
CliSearchItem:
type: object
required: [namespace, slug, latestVersion]
properties:
namespace: {type: string, example: global}
slug: {type: string, example: pdf-parser}
latestVersion: {type: string, example: 1.2.0}
summary: {type: string, nullable: true, example: Parse PDF files}
CliResolveEnvelope:
allOf:
- $ref: '#/components/schemas/Envelope'
- type: object
properties:
data:
$ref: '#/components/schemas/CliResolveResult'
CliResolveResult:
type: object
required: [namespace, slug, version, versionId, fingerprint, downloadUrl]
properties:
namespace: {type: string, example: global}
slug: {type: string, example: pdf-parser}
version: {type: string, example: 1.2.0}
versionId: {type: integer, format: int64, example: 42}
fingerprint: {type: string, example: 'sha256:abc123'}
downloadUrl: {type: string, example: /api/v1/skills/global/pdf-parser/versions/1.2.0/download}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,279 @@
# Revoked API Token Validation Design
## Goal
Prove and preserve fail-closed API-token behavior across the CLI API using a
real persisted token lifecycle. Invalid Bearer credentials must return HTTP
401 before endpoint business logic runs, including when a valid Web Session is
also present. A valid Bearer credential overrides the Session identity. When
Bearer is absent or the Authorization scheme is unsupported, the existing Web
Session identity is preserved; without a valid Session, public reads remain
anonymous and `whoami` returns 401. Valid credentials without sufficient
authorization continue to return HTTP 403.
## Scope
This change covers the following CLI routes:
- `GET /api/cli/v1/auth/whoami`
- `GET /api/cli/v1/skills/search`
- `GET /api/cli/v1/skills/{namespace}/{slug}/resolve`
- `GET /api/cli/v1/skills/{namespace}/{slug}/download`
- `GET /api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download`
It also covers the authenticated-versus-forbidden boundary on the affected
restricted read routes. An existing scope-protected CLI route may provide
supplementary scope-filter evidence only. This change does not add endpoints,
change runtime response fields, change token storage, add a database migration,
or change anonymous resource visibility rules. The OpenAPI correction marks
the already-nullable `whoami.email` value accurately without changing its JSON
field presence.
## Current-State Finding
The fail-closed implementation from closed PR #511 was later included in the
single replacement PR #523 and is present in both v0.2.14 and current `main`.
`ApiTokenAuthenticationFilter` already validates Bearer credentials before
business logic and rejects empty, malformed, unknown, expired, revoked,
missing-user, and disabled-user credentials through the configured
`AuthenticationEntryPoint`.
The verified repository gap is regression coverage, not a demonstrated
production-code gap. Existing tests separately prove token lifecycle
validation and invalid-Bearer filtering, but they do not exercise persisted
token creation, revocation, and all affected CLI endpoints in one integrated
matrix. The CLI API table in `docs/03-authentication-design.md` also retains
legacy paths, and there is no dedicated OpenAPI 3.0 authentication contract in
`docs/api/`.
The reported v0.2.14 runtime behavior still contradicts the source and test
evidence. Source equality alone does not establish which artifact or replica
served the reported requests. The defect therefore remains open until the
release artifact and affected runtime are identified and the same token
lifecycle is replayed against that identified runtime.
## Release Artifact and Runtime Identity Gate
Runtime verification is a required investigation track, not an optional
deployment check. Before interpreting a runtime result, record all of the
following for every server replica that may receive the request:
1. The configured deployment version and resolved image reference from the
runtime environment and `docker compose config --images`.
2. The running container's image ID and registry `RepoDigest` from
`docker inspect` / `docker image inspect`.
3. The OCI `org.opencontainers.image.revision` and
`org.opencontainers.image.version` labels. The publish workflow generates
these labels and also publishes a `sha-<short-sha>` tag, so the revision can
be mapped back to a repository commit.
4. The externally observed application URL, health result, deployment profile,
and request IDs for the authentication probes.
If the revision label is absent, the image digest must be mapped to the
corresponding publish-images workflow output or registry manifest. A mutable
tag such as `latest` or `v0.2.14` is not sufficient identity evidence by
itself. If neither a revision nor a digest-to-build mapping can be obtained,
the source/runtime contradiction is unresolved and the defect cannot be
closed.
Using a dedicated test user and non-production token, replay one lifecycle
against the identified running image:
1. Issue the token and call every matrix endpoint while it is valid.
2. Revoke that same token through the normal product flow and verify its
persisted `revoked_at` value without exposing the raw token.
3. Reuse the same raw token against every matrix endpoint and capture status,
response envelope, request ID, timestamp, and serving replica when
available.
4. Repeat or pin requests per replica when a load balancer can route to mixed
versions, and compare the image digest/revision of each replica.
If production mutation is not authorized, run the exact identified digest in
an approved isolated environment with equivalent auth/proxy configuration and
record that limitation. This does not by itself close the original field
report: an authorized runtime replay or owner-provided equivalent evidence is
still required.
The contradiction is closed only when source commit, published image digest,
running instance identity, and replay result form one consistent chain. A
mismatched digest indicates deployment drift; identical application images
with divergent behavior require investigation of proxy header forwarding,
mixed replicas, session/cookie contamination, and request routing before any
source-code conclusion is accepted.
## Architecture
`ApiTokenAuthenticationFilter` remains the single Bearer-authentication entry
point. Spring Security loads an existing Web Session identity before the token
filter runs. A valid Bearer token replaces that identity; an invalid, empty, or
malformed Bearer attempt clears it and returns 401. The filter ignores Basic
and other non-Bearer schemes, preserving the loaded Session identity. If no
Session exists, those schemes reach public reads anonymously and `whoami`
returns 401. Controllers must not duplicate token parsing, Session resolution,
or lifecycle checks.
The regression test will boot the Spring application with MockMvc, real
`ApiTokenService`, real `ApiTokenRepository`, and real user persistence. CLI
endpoint business services may be mocked only to make successful public-read
responses deterministic; authentication and token lifecycle components remain
real. This isolates the contract boundary under test: a rejected credential
must stop in the security chain before controller business logic executes.
The restricted-read authorization test is separate and must not mock the
permission decision. It will persist a PRIVATE or NAMESPACE_ONLY skill owned by
another user, authenticate a valid outsider token with no qualifying namespace
role, and exercise the real `CliSkillAppService` plus domain query/download
authorization path. At least `resolve`, latest download, and versioned download
must return HTTP 403. A DELETE request with a missing token scope may supplement
this check, but cannot replace any affected read-path assertion.
Production authentication code will be changed only when a new regression
test fails for the expected behavioral reason. Any fix must be the smallest
change at the shared authentication or token-validation source of the failure.
Endpoint-specific authentication patches and unrelated refactoring are out of
scope.
## Persisted Token Lifecycle
The test fixture creates an active user and issues a token through
`ApiTokenService`, retaining only the raw token returned at creation time.
Lifecycle transitions use production persistence paths:
1. Call an affected endpoint with the valid raw token and confirm successful
authentication.
2. Revoke the token through `ApiTokenService.revokeToken`.
3. Call every affected endpoint with the same raw token.
4. Assert HTTP 401 and confirm protected endpoint business logic was not
reached.
Expired-token coverage persists a token with an expiration timestamp earlier
than the service clock, then validates it through the same filter and
repository path. Unknown and malformed tokens exercise the same HTTP security
chain without creating a token row.
## Behavioral Matrix
The authentication rows use deterministic public fixtures. Latest and
versioned downloads are independent endpoints and must have independent test
arguments and assertions for every credential state.
| Credential state | `whoami` | Public `search` | Public `resolve` | Public latest download | Public versioned download | Meaning |
|---|---:|---:|---:|---:|---:|---|
| No `Authorization` header | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Anonymous access is preserved only where already public |
| Basic or another non-Bearer scheme | 401 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Unsupported schemes are not treated as API-token attempts |
| Valid Web Session, no `Authorization` header | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Existing browser identity is preserved |
| Valid Web Session + Basic | 200 as Session user | 200 as Session user | 200 as Session user | Existing 200/302 as Session user | Existing 200/302 as Session user | Non-Bearer schemes do not erase Session identity |
| Valid active token | 200 | 200 | 200 | Existing 200/302 success | Existing 200/302 success | Principal and roles/scopes are projected |
| Valid Web Session + valid active token | 200 as token user | 200 as token user | 200 as token user | Existing 200/302 as token user | Existing 200/302 as token user | Bearer identity overrides Session identity |
| Valid Web Session + revoked token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
| Valid Web Session + expired token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
| Valid Web Session + unknown token | 401 | 401 | 401 | 401 | 401 | Credential cannot fall back to Session or anonymous |
| Valid Web Session + empty Bearer credential | 401 | 401 | 401 | 401 | 401 | Empty authentication attempt is rejected before business logic |
| Valid Web Session + malformed Bearer credential | 401 | 401 | 401 | 401 | 401 | Malformed authentication attempt is rejected before business logic |
The authorization row uses a persisted PRIVATE or NAMESPACE_ONLY fixture and
the real read-authorization path:
| Valid credential, insufficient resource permission | `whoami` | `search` | Restricted `resolve` | Restricted latest download | Restricted versioned download |
|---|---:|---:|---:|---:|---:|
| Outsider token with no qualifying namespace role | 200 | 200 with restricted skill omitted | 403 | 403 | 403 |
The same fixture must also prove that an authorized owner or qualifying
namespace member can reach the restricted read path, so a 403 cannot be caused
by an invalid fixture. Missing-scope DELETE coverage is optional supplementary
evidence for the API-token scope filter only.
## Error Handling and Security
- Invalid Bearer credentials return the existing structured HTTP 401 response
through `ApiAuthenticationEntryPoint`.
- Valid credentials that fail scope or resource authorization return the
existing structured HTTP 403 response through the access-denied path.
- Responses must not reveal whether a token is unknown, expired, or revoked.
- Tests, logs, documentation, and commits must not contain real secrets. Test
credentials are generated locally and exist only in the in-memory test
database.
- Token material must never be logged.
## Documentation
Two documentation updates are required:
1. Update `docs/03-authentication-design.md` so the CLI API section uses the
current `/api/cli/v1/...` routes and explicitly states Bearer-over-Session
priority, Session fallback, and the anonymous/401/403 boundary.
2. Add `docs/api/authentication.openapi.yaml` using OpenAPI 3.0. The document
must define Bearer and Web Session authentication, all affected paths,
query/path parameters, success schemas, the common response envelope, HTTP
401 and 403 responses, examples, credential priority, and the rule that
requests without either identity are allowed only on existing public-read
routes. `CliWhoAmI.email` remains required but is nullable.
No controller signature or response schema changes are planned. Therefore the
generated `web/src/api/generated/schema.d.ts` should remain unchanged; if a
production fix unexpectedly changes a controller contract, `make generate-api`
becomes mandatory and the generated diff must be committed.
## Implementation Plan Requirements
The detailed implementation plan must preserve the following independent
steps rather than collapsing them into one generic download case:
1. Create the real persisted token/user fixture and public endpoint stubs used
by the authentication matrix.
2. Exercise `whoami`, `search`, and `resolve` for every credential state.
3. Exercise latest download for every credential state.
4. Exercise versioned download for every credential state.
5. Persist a restricted skill plus authorized and unauthorized users, then use
the real read-authorization path to prove 403 for restricted `resolve`,
latest download, and versioned download and success for an authorized user.
6. Update the authentication design and OpenAPI contract.
7. Exercise Session-only, Session + Basic, Basic-only, and Session + valid or
invalid Bearer independently on all five endpoints; latest and versioned
download remain separate cases.
8. Prove PRIVATE search omission with a non-empty same-keyword PUBLIC result
and assert the fixed five-field 403 envelope on each restricted read.
9. Identify the published/running image and replay the valid-to-revoked token
lifecycle against that exact digest, or record the external access blocker
without treating the field contradiction as resolved.
Each endpoint/state step must state its own expected status and test command.
The plan may share fixture helpers, but it must not share one assertion in a
way that can skip either download route.
## Verification
Verification proceeds in this order:
1. Run the new focused persisted-token matrix and record whether it fails or
passes on unmodified `main` behavior, with separate results for latest and
versioned download.
2. Run the persisted restricted-resource checks through real query/download
authorization and record outsider 403 plus authorized-user success.
3. If an authentication row fails, preserve the failure output as reproduction
evidence, apply one minimal shared fix, and rerun the focused matrix.
4. Run auth-module and affected app integration tests.
5. Run `make test-backend-app`.
6. Run `make typecheck-web` and `make lint-web` as repository pre-PR gates.
7. Run `make staging` for containerized regression and smoke coverage.
8. Run `git diff --check` and confirm no generated OpenAPI type drift when no
controller contract changed.
9. Record the release tag, build revision, image reference, immutable digest,
and every serving replica's running image identity.
10. Replay the same valid-to-revoked token lifecycle against the identified
runtime and record endpoint-level status, request ID, and replica evidence,
keeping latest and versioned download results separate.
11. Perform structured security and code review before updating the existing
single final pull request.
## Delivery Constraints
- Work only on `fix/auth-revoked-token-validation`.
- Keep PR #511 closed and use it only as historical reference.
- Create exactly one final pull request for GitHub issue #605.
- GitHub-facing text must not contain a Multica issue identifier.
- Do not mark the defect resolved or eligible for closure while the reported
runtime behavior and the identified artifact/runtime replay remain
contradictory or incomplete.
- Do not merge `main`; merging remains the responsibility of an explicitly
authorized human owner.

View file

@ -0,0 +1,187 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity;
import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.HttpHeaders;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import static org.hamcrest.Matchers.aMapWithSize;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliRestrictedReadAuthorizationIntegrationTest {
@Autowired MockMvc mockMvc;
@Autowired ApiTokenService apiTokenService;
@Autowired UserAccountRepository userAccountRepository;
@Autowired NamespaceRepository namespaceRepository;
@Autowired SkillRepository skillRepository;
@Autowired SkillVersionRepository skillVersionRepository;
@Autowired SkillSearchDocumentJpaRepository skillSearchDocumentRepository;
private String namespaceSlug;
private String skillSlug;
private String publicSkillSlug;
private String version;
private String ownerToken;
private String outsiderToken;
@BeforeEach
void setUp() {
String suffix = UUID.randomUUID().toString().replace("-", "");
String ownerId = "private-owner-" + suffix;
String outsiderId = "private-outsider-" + suffix;
namespaceSlug = "private-ns-" + suffix;
skillSlug = Long.toUnsignedString(UUID.randomUUID().getMostSignificantBits());
publicSkillSlug = "public-skill-" + suffix;
version = "1.0.0";
userAccountRepository.save(new UserAccount(
ownerId, "Private Skill Owner", ownerId + "@example.com", ""));
userAccountRepository.save(new UserAccount(
outsiderId, "Private Skill Outsider", outsiderId + "@example.com", ""));
ownerToken = apiTokenService.createToken(
ownerId, "owner-token-" + suffix, "[\"skill:read\"]").rawToken();
outsiderToken = apiTokenService.createToken(
outsiderId, "outsider-token-" + suffix, "[\"skill:read\"]").rawToken();
Namespace namespace = namespaceRepository.save(
new Namespace(namespaceSlug, "Private Namespace", ownerId));
Skill skill = skillRepository.save(new Skill(
namespace.getId(), skillSlug, ownerId, SkillVisibility.PRIVATE));
SkillVersion published = new SkillVersion(skill.getId(), version, ownerId);
published.setStatus(SkillVersionStatus.PUBLISHED);
published.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z"));
published.setDownloadReady(true);
published = skillVersionRepository.save(published);
skill.setLatestVersionId(published.getId());
skillRepository.save(skill);
skillRepository.flush();
skillVersionRepository.flush();
skillSearchDocumentRepository.saveAndFlush(new SkillSearchDocumentEntity(
skill.getId(),
namespace.getId(),
namespaceSlug,
ownerId,
skillSlug,
"Private skill search fixture",
"private",
skillSlug,
"",
SkillVisibility.PRIVATE.name(),
skill.getStatus().name()));
Skill publicSkill = skillRepository.save(new Skill(
namespace.getId(), publicSkillSlug, ownerId, SkillVisibility.PUBLIC));
SkillVersion publicPublished = new SkillVersion(publicSkill.getId(), version, ownerId);
publicPublished.setStatus(SkillVersionStatus.PUBLISHED);
publicPublished.setPublishedAt(Instant.parse("2026-07-28T00:00:00Z"));
publicPublished.setDownloadReady(true);
publicPublished = skillVersionRepository.save(publicPublished);
publicSkill.setLatestVersionId(publicPublished.getId());
skillRepository.save(publicSkill);
skillRepository.flush();
skillVersionRepository.flush();
skillSearchDocumentRepository.saveAndFlush(new SkillSearchDocumentEntity(
publicSkill.getId(),
namespace.getId(),
namespaceSlug,
ownerId,
skillSlug,
"Public match for " + publicSkillSlug,
"public",
skillSlug,
"",
SkillVisibility.PUBLIC.name(),
publicSkill.getStatus().name()));
}
@Test
void outsiderSearchReturnsMatchingPublicSkillAndOmitsPrivateSkill() throws Exception {
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/search")
.param("q", skillSlug)
.param("limit", "20"),
outsiderToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[*].slug", hasItem(publicSkillSlug)))
.andExpect(jsonPath("$.data.items[*].slug", not(hasItem(skillSlug))));
}
@Test
void outsiderCannotResolvePrivateSkill() throws Exception {
assertForbiddenEnvelope(withBearer(
get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug),
outsiderToken));
}
@Test
void outsiderCannotDownloadLatestPrivateSkill() throws Exception {
assertForbiddenEnvelope(withBearer(
get("/api/cli/v1/skills/{namespace}/{slug}/download", namespaceSlug, skillSlug),
outsiderToken));
}
@Test
void outsiderCannotDownloadVersionedPrivateSkill() throws Exception {
assertForbiddenEnvelope(withBearer(
get("/api/cli/v1/skills/{namespace}/{slug}/versions/{version}/download",
namespaceSlug, skillSlug, version),
outsiderToken));
}
private void assertForbiddenEnvelope(MockHttpServletRequestBuilder request) throws Exception {
mockMvc.perform(request)
.andExpect(status().isForbidden())
.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(403))
.andExpect(jsonPath("$.msg").isString())
.andExpect(jsonPath("$.data").value(nullValue()))
.andExpect(jsonPath("$.timestamp").isString())
.andExpect(jsonPath("$.requestId").isString());
}
@Test
void ownerCanResolvePrivateSkill() throws Exception {
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/{namespace}/{slug}/resolve", namespaceSlug, skillSlug),
ownerToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.slug").value(skillSlug));
}
private MockHttpServletRequestBuilder withBearer(
MockHttpServletRequestBuilder request,
String rawToken) {
return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken);
}
}

View file

@ -0,0 +1,468 @@
package com.iflytek.skillhub.controller.cli;
import com.iflytek.skillhub.auth.entity.ApiToken;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.ApiTokenRepository;
import com.iflytek.skillhub.auth.token.ApiTokenService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
import com.iflytek.skillhub.service.cli.CliSkillAppService;
import jakarta.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import static org.hamcrest.Matchers.aMapWithSize;
import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CliTokenLifecycleSecurityIntegrationTest {
private enum InvalidCredentialState {
REVOKED,
EXPIRED,
UNKNOWN,
EMPTY,
MALFORMED
}
private enum EndpointCase {
WHOAMI,
SEARCH,
RESOLVE,
LATEST_DOWNLOAD,
VERSIONED_DOWNLOAD
}
private enum MixedCredentialState {
SESSION_ONLY,
SESSION_BASIC,
BASIC_ONLY,
SESSION_VALID_BEARER
}
@Autowired MockMvc mockMvc;
@Autowired ApiTokenService apiTokenService;
@Autowired ApiTokenRepository apiTokenRepository;
@Autowired UserAccountRepository userAccountRepository;
@Autowired Clock clock;
@MockBean CliSkillAppService cliSkillAppService;
private String userId;
private String sessionUserId;
@BeforeEach
void setUp() {
userId = "token-matrix-" + UUID.randomUUID();
sessionUserId = "session-matrix-" + UUID.randomUUID();
userAccountRepository.save(new UserAccount(
userId, "Token Matrix", userId + "@example.com", ""));
userAccountRepository.save(new UserAccount(
sessionUserId, "Session Matrix", sessionUserId + "@example.com", ""));
given(cliSkillAppService.search(any(), anyInt(), any(), any()))
.willReturn(new CliSkillAppService.CliSearchResult(List.of(), 0, 20));
given(cliSkillAppService.resolve(anyString(), anyString(), any(), any(), any()))
.willReturn(new CliResolveResponse(
"global", "demo", "1.0.0", 1L, "sha256:empty",
"/api/v1/skills/global/demo/versions/1.0.0/download"));
given(cliSkillAppService.downloadLatest(anyString(), anyString(), any()))
.willAnswer(ignored -> downloadResponse());
given(cliSkillAppService.downloadVersion(anyString(), anyString(), anyString(), any()))
.willAnswer(ignored -> downloadResponse());
}
@Test
void whoamiWithoutAuthorizationReturns401() throws Exception {
mockMvc.perform(get("/api/cli/v1/auth/whoami"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void whoamiWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(get("/api/cli/v1/auth/whoami"), token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.handle").value(userId));
}
@ParameterizedTest(name = "{0} with {1}")
@MethodSource("mixedCredentialMatrix")
void sessionAndAuthorizationSchemeMatrix(
EndpointCase endpoint,
MixedCredentialState credentialState) throws Exception {
clearInvocations(cliSkillAppService);
String expectedUserId = expectedUserId(credentialState);
MockHttpServletRequestBuilder request = withCredentials(requestFor(endpoint), credentialState);
if (endpoint == EndpointCase.WHOAMI) {
if (credentialState == MixedCredentialState.BASIC_ONLY) {
assertUnauthorizedEnvelope(request);
} else {
assertSuccessEnvelope(request)
.andExpect(jsonPath("$.data.handle").value(expectedUserId));
}
verifyNoInteractions(cliSkillAppService);
return;
}
ResultActions result = mockMvc.perform(request).andExpect(status().isOk());
if (endpoint == EndpointCase.LATEST_DOWNLOAD
|| endpoint == EndpointCase.VERSIONED_DOWNLOAD) {
result.andExpect(content().contentType("application/zip"));
} else {
result.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(0));
}
assertProjectedUser(endpoint, expectedUserId);
}
@Test
void whoamiReturnsNullEmailForPersistedUserWithoutEmail() throws Exception {
String noEmailUserId = "token-no-email-" + UUID.randomUUID();
userAccountRepository.save(new UserAccount(noEmailUserId, "No Email User", null, ""));
String rawToken = apiTokenService.createToken(
noEmailUserId, "no-email-" + UUID.randomUUID(), "[\"skill:read\"]").rawToken();
assertSuccessEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken))
.andExpect(jsonPath("$.data", hasKey("email")))
.andExpect(jsonPath("$.data.email").value(nullValue()));
}
@ParameterizedTest(name = "whoami rejects {0}")
@EnumSource(InvalidCredentialState.class)
void whoamiRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(get("/api/cli/v1/auth/whoami"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void searchWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"))
.andExpect(status().isOk());
}
@Test
void searchWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), token))
.andExpect(status().isOk());
}
@ParameterizedTest(name = "search rejects {0}")
@EnumSource(InvalidCredentialState.class)
void searchRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void resolveWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/global/demo/resolve"))
.andExpect(status().isOk());
}
@Test
void resolveWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/resolve"), token))
.andExpect(status().isOk());
}
@ParameterizedTest(name = "resolve rejects {0}")
@EnumSource(InvalidCredentialState.class)
void resolveRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(
get("/api/cli/v1/skills/global/demo/resolve"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void latestDownloadWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/global/demo/download"))
.andExpect(status().isOk());
}
@Test
void latestDownloadWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(get("/api/cli/v1/skills/global/demo/download"), token))
.andExpect(status().isOk())
.andExpect(content().contentType("application/zip"));
}
@ParameterizedTest(name = "latest download rejects {0}")
@EnumSource(InvalidCredentialState.class)
void latestDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(
get("/api/cli/v1/skills/global/demo/download"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void versionedDownloadWithoutAuthorizationReturns200() throws Exception {
mockMvc.perform(get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"))
.andExpect(status().isOk());
}
@Test
void versionedDownloadWithValidPersistedTokenReturns200() throws Exception {
String token = createActiveToken();
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), token))
.andExpect(status().isOk())
.andExpect(content().contentType("application/zip"));
}
@ParameterizedTest(name = "versioned download rejects {0}")
@EnumSource(InvalidCredentialState.class)
void versionedDownloadRejectsInvalidBearer(InvalidCredentialState state) throws Exception {
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withInvalidBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), state));
verifyNoInteractions(cliSkillAppService);
}
@Test
void sameRawTokenIsRejectedByAllEndpointsAfterValidUseAndRevocation() throws Exception {
ApiTokenService.TokenCreateResult token = createToken();
String rawToken = token.rawToken();
assertSuccessEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken))
.andExpect(jsonPath("$.data.handle").value(userId));
assertSuccessEnvelope(withBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"),
rawToken));
assertSuccessEnvelope(withBearer(
get("/api/cli/v1/skills/global/demo/resolve"), rawToken));
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/global/demo/download"), rawToken))
.andExpect(status().isOk())
.andExpect(content().contentType("application/zip"));
mockMvc.perform(withBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), rawToken))
.andExpect(status().isOk())
.andExpect(content().contentType("application/zip"));
apiTokenService.revokeToken(token.entity().getId(), userId);
clearInvocations(cliSkillAppService);
assertUnauthorizedEnvelope(withBearer(get("/api/cli/v1/auth/whoami"), rawToken));
assertUnauthorizedEnvelope(withBearer(
get("/api/cli/v1/skills/search").param("q", "demo").param("limit", "20"),
rawToken));
assertUnauthorizedEnvelope(withBearer(
get("/api/cli/v1/skills/global/demo/resolve"), rawToken));
assertUnauthorizedEnvelope(withBearer(
get("/api/cli/v1/skills/global/demo/download"), rawToken));
assertUnauthorizedEnvelope(withBearer(
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download"), rawToken));
verifyNoInteractions(cliSkillAppService);
}
private ResultActions assertSuccessEnvelope(MockHttpServletRequestBuilder request) throws Exception {
return mockMvc.perform(request)
.andExpect(status().isOk())
.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").isString())
.andExpect(jsonPath("$.data").exists())
.andExpect(jsonPath("$.timestamp").isString())
.andExpect(jsonPath("$.requestId").isString());
}
private void assertUnauthorizedEnvelope(MockHttpServletRequestBuilder request) throws Exception {
mockMvc.perform(request)
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$", aMapWithSize(5)))
.andExpect(jsonPath("$.code").value(401))
.andExpect(jsonPath("$.msg").isString())
.andExpect(jsonPath("$.data").value(nullValue()))
.andExpect(jsonPath("$.timestamp").isString())
.andExpect(jsonPath("$.requestId").isString());
}
private MockHttpServletRequestBuilder withInvalidBearer(
MockHttpServletRequestBuilder request,
InvalidCredentialState state) {
return request
.header(HttpHeaders.AUTHORIZATION, authorizationHeader(state))
.session(session());
}
private static Stream<Arguments> mixedCredentialMatrix() {
return Stream.of(EndpointCase.values())
.flatMap(endpoint -> Stream.of(MixedCredentialState.values())
.map(state -> Arguments.of(endpoint, state)));
}
private MockHttpServletRequestBuilder requestFor(EndpointCase endpoint) {
return switch (endpoint) {
case WHOAMI -> get("/api/cli/v1/auth/whoami");
case SEARCH -> get("/api/cli/v1/skills/search")
.param("q", "demo")
.param("limit", "20");
case RESOLVE -> get("/api/cli/v1/skills/global/demo/resolve");
case LATEST_DOWNLOAD -> get("/api/cli/v1/skills/global/demo/download");
case VERSIONED_DOWNLOAD ->
get("/api/cli/v1/skills/global/demo/versions/1.0.0/download");
};
}
private MockHttpServletRequestBuilder withCredentials(
MockHttpServletRequestBuilder request,
MixedCredentialState state) {
return switch (state) {
case SESSION_ONLY -> request.session(session());
case SESSION_BASIC -> request.session(session())
.header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0");
case BASIC_ONLY -> request.header(HttpHeaders.AUTHORIZATION, "Basic dGVzdDp0ZXN0");
case SESSION_VALID_BEARER -> withBearer(request.session(session()), createActiveToken());
};
}
private String expectedUserId(MixedCredentialState state) {
return switch (state) {
case SESSION_ONLY, SESSION_BASIC -> sessionUserId;
case BASIC_ONLY -> null;
case SESSION_VALID_BEARER -> userId;
};
}
private void assertProjectedUser(EndpointCase endpoint, String expectedUserId) {
if (endpoint == EndpointCase.SEARCH) {
ArgumentCaptor<String> userCaptor = ArgumentCaptor.forClass(String.class);
verify(cliSkillAppService).search(any(), anyInt(), userCaptor.capture(), any());
assertEquals(expectedUserId, userCaptor.getValue());
return;
}
if (endpoint == EndpointCase.RESOLVE) {
ArgumentCaptor<String> userCaptor = ArgumentCaptor.forClass(String.class);
verify(cliSkillAppService).resolve(anyString(), anyString(), any(), userCaptor.capture(), any());
assertEquals(expectedUserId, userCaptor.getValue());
return;
}
ArgumentCaptor<HttpServletRequest> requestCaptor = ArgumentCaptor.forClass(HttpServletRequest.class);
if (endpoint == EndpointCase.LATEST_DOWNLOAD) {
verify(cliSkillAppService).downloadLatest(anyString(), anyString(), requestCaptor.capture());
} else {
verify(cliSkillAppService).downloadVersion(
anyString(), anyString(), anyString(), requestCaptor.capture());
}
assertEquals(expectedUserId, requestCaptor.getValue().getAttribute("userId"));
}
private MockHttpServletRequestBuilder withBearer(
MockHttpServletRequestBuilder request,
String rawToken) {
return request.header(HttpHeaders.AUTHORIZATION, "Bearer " + rawToken);
}
private String authorizationHeader(InvalidCredentialState state) {
return switch (state) {
case REVOKED -> {
ApiTokenService.TokenCreateResult result = createToken();
apiTokenService.revokeToken(result.entity().getId(), userId);
yield "Bearer " + result.rawToken();
}
case EXPIRED -> {
ApiTokenService.TokenCreateResult result = createToken();
ApiToken token = result.entity();
token.setExpiresAt(Instant.now(clock).minusSeconds(1));
apiTokenRepository.saveAndFlush(token);
yield "Bearer " + result.rawToken();
}
case UNKNOWN -> "Bearer sk_unknown_" + UUID.randomUUID();
case EMPTY -> "Bearer ";
case MALFORMED -> "Bearer";
};
}
private String createActiveToken() {
return createToken().rawToken();
}
private ApiTokenService.TokenCreateResult createToken() {
return apiTokenService.createToken(
userId, "matrix-" + UUID.randomUUID(), "[\"skill:read\"]");
}
private MockHttpSession session() {
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(sessionAuthentication());
MockHttpSession session = new MockHttpSession();
session.setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
securityContext);
return session;
}
private UsernamePasswordAuthenticationToken sessionAuthentication() {
PlatformPrincipal principal = new PlatformPrincipal(
sessionUserId,
"Session User",
sessionUserId + "@example.com",
"",
"session",
Set.of("USER"));
return new UsernamePasswordAuthenticationToken(principal, null, List.of());
}
private ResponseEntity<InputStreamResource> downloadResponse() {
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/zip"))
.body(new InputStreamResource(
new ByteArrayInputStream("zip".getBytes(java.nio.charset.StandardCharsets.UTF_8))));
}
}