Files
pad/internal/server/token_scope_levels_test.go
T
xarmian b901ec629c feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)

Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.

Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)

Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)

Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.

describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ

* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)

Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.

The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-17 06:13:14 -04:00

49 lines
1.2 KiB
Go

package server
import (
"encoding/json"
"net/http"
"os"
"testing"
)
// TestTokenScopeLevelsFixture holds tokenScopeAllows to the level each row
// of testdata/token_scope_levels.json claims (TASK-3866). The web's token
// list labels keys from the same file (web/src/lib/settings/tokenScopes.test.ts),
// so a change to the server's check that the label does not follow turns
// one of the two red.
func TestTokenScopeLevelsFixture(t *testing.T) {
raw, err := os.ReadFile("testdata/token_scope_levels.json")
if err != nil {
t.Fatal(err)
}
var fixture struct {
Rows []struct {
Scopes string `json:"scopes"`
Level string `json:"level"`
} `json:"rows"`
}
if err := json.Unmarshal(raw, &fixture); err != nil {
t.Fatal(err)
}
if len(fixture.Rows) == 0 {
t.Fatal("fixture has no rows")
}
for _, row := range fixture.Rows {
read := tokenScopeAllows(row.Scopes, http.MethodGet, "/api/v1/workspaces")
write := tokenScopeAllows(row.Scopes, http.MethodPatch, "/api/v1/workspaces")
got := "none"
switch {
case read && write:
got = "write"
case read:
got = "read"
case write:
got = "write-without-read"
}
if got != row.Level {
t.Errorf("scopes %q: server grants %s, fixture says %s", row.Scopes, got, row.Level)
}
}
}