167 lines
5.0 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|