Files
verdande/internal/httpapi/tokens_test.go
T
Kristian WindandClaude Opus 5 68d82a1cb6 feat(api): personal API tokens, and let people change their own profile
docs/api.md has told people to make a token under Settings since it was
written. The store has held CreateAPIToken, ListAPITokens and
DeleteAPIToken since the first migration. There were no routes: nothing
between the two, and no way to make one.

GET, POST and DELETE under /tokens, all behind a new requireSession
middleware. A bearer token is refused there with 403 even though it is
accepted everywhere else — otherwise a leaked token is permanent, because
whoever has it issues a second one and revoking the first changes
nothing. The plaintext comes back from POST and never again; only the
hash is stored, so the interface has to say so at that moment rather than
offering a reveal that cannot work.

PATCH /auth/me alongside it. Name, timezone and language could not be
changed anywhere — not through the API and not through the interface —
and an account created from an invite inherits a default timezone. That
is not cosmetic: every date in the app is resolved in it, so somebody in
another country had every due date land an hour out with no way to say
so. The zone is validated against the running system's database rather
than a list kept here, which is the same database the dates are resolved
with. The email address stays read-only: it identifies the account and is
what invites were sent to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:56:33 +02:00

167 lines
5.0 KiB
Go

package httpapi
import (
"net/http"
"strings"
"testing"
)
func TestAPITokenLifecycle(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "GET", "/api/v1/tokens", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list: status %d, body %v", resp.StatusCode, body)
}
if tokens, _ := body["tokens"].([]any); len(tokens) != 0 {
t.Errorf("a fresh account already has tokens: %v", tokens)
}
resp, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{
"name": "min server", "expires_in_days": 30,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: status %d, body %v", resp.StatusCode, created)
}
plaintext, _ := created["token"].(string)
if !strings.HasPrefix(plaintext, "vrd_") {
t.Fatalf("token %q does not carry the scanner-visible prefix", plaintext)
}
if created["expires_at"] == nil {
t.Error("an expiry was asked for and none came back")
}
prefix, _ := created["prefix"].(string)
if !strings.HasPrefix(plaintext, prefix) {
t.Errorf("prefix %q is not the start of the token", prefix)
}
// The token authenticates as its owner.
req, err := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+plaintext)
me, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("bearer request: %v", err)
}
defer me.Body.Close()
if me.StatusCode != http.StatusOK {
t.Errorf("the new token does not authenticate: status %d", me.StatusCode)
}
// Listing shows it, without the secret.
_, body = ts.do(t, "GET", "/api/v1/tokens", nil)
tokens, _ := body["tokens"].([]any)
if len(tokens) != 1 {
t.Fatalf("want one token listed, got %v", tokens)
}
listed := tokens[0].(map[string]any)
if _, leaked := listed["token"]; leaked {
t.Error("the listing carries the token itself")
}
if listed["name"] != "min server" {
t.Errorf("name = %v", listed["name"])
}
id, _ := created["id"].(string)
resp, _ = ts.do(t, "DELETE", "/api/v1/tokens/"+id, nil)
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: status %d", resp.StatusCode)
}
// And is dead the moment it is deleted.
req, _ = http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
me, err = (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("bearer request: %v", err)
}
defer me.Body.Close()
if me.StatusCode != http.StatusUnauthorized {
t.Errorf("a deleted token still works: status %d", me.StatusCode)
}
}
// A token must not be able to mint another one. Otherwise a leaked token is
// permanent: revoking it leaves behind the one it issued.
func TestAPITokenCannotMintAnother(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{"name": "første"})
plaintext, _ := created["token"].(string)
for _, tc := range []struct{ method, path string }{
{"GET", "/api/v1/tokens"},
{"POST", "/api/v1/tokens"},
{"DELETE", "/api/v1/tokens/" + created["id"].(string)},
} {
req, err := http.NewRequest(tc.method, ts.URL+tc.path, strings.NewReader(`{"name":"anden"}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+plaintext)
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s with a bearer token: status %d, want 403",
tc.method, tc.path, resp.StatusCode)
}
}
}
func TestAPITokenValidation(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, tc := range []struct {
name string
body map[string]any
field string
}{
{"no name", map[string]any{"name": " "}, "name"},
{"negative lifetime", map[string]any{"name": "x", "expires_in_days": -1}, "expires_in_days"},
{"absurd lifetime", map[string]any{"name": "x", "expires_in_days": 4000}, "expires_in_days"},
} {
t.Run(tc.name, func(t *testing.T) {
resp, body := ts.do(t, "POST", "/api/v1/tokens", tc.body)
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d, want 422", resp.StatusCode)
}
fields, _ := body["fields"].(map[string]any)
if fields[tc.field] == nil {
t.Errorf("no error on %q: %v", tc.field, body)
}
})
}
}
// Somebody else's token id must delete nothing, and must not confirm it exists.
func TestAPITokenDeleteIsScopedToOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{"name": "min"})
id := created["id"].(string)
other := ts.newUser(t, "anden@example.dk", "Anden")
resp, _ := other.do(t, "DELETE", "/api/v1/tokens/"+id, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404 for a token belonging to somebody else", resp.StatusCode)
}
// Still there for its owner.
_, body := ts.do(t, "GET", "/api/v1/tokens", nil)
if tokens, _ := body["tokens"].([]any); len(tokens) != 1 {
t.Errorf("the owner's token was affected: %v", tokens)
}
}