fix(api): update UID compatibility
Use the original UID format consistently across API, username, and store validation so UUID-based callers continue to work. Regenerate API documentation and add regression coverage for UUID memo IDs.
This commit is contained in:
@@ -3,9 +3,5 @@ package base
|
||||
import "regexp"
|
||||
|
||||
var (
|
||||
// ResourceIDMatcher matches new user-provided resource IDs per AIP-122.
|
||||
ResourceIDMatcher = regexp.MustCompile(`^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
||||
// UIDMatcher also accepts the previous 36-character format so existing
|
||||
// generated and persisted UIDs remain writable by the store layer.
|
||||
UIDMatcher = regexp.MustCompile(`^(?:[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?|[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?)$`)
|
||||
UIDMatcher = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$`)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -24,8 +23,8 @@ func TestUIDMatcher(t *testing.T) {
|
||||
{"abc123", true},
|
||||
{"abc123-", false},
|
||||
{"123e4567-e89b-12d3-a456-426614174000", true}, // UUID v4 from IDP
|
||||
{"a123456789012345678901234567890123456", true}, // New AIP-compatible IDs can exceed the legacy limit.
|
||||
{"A123456789012345678901234567890123456", false}, // Legacy uppercase IDs remain capped at 36 characters.
|
||||
{"a12345678901234567890123456789012345", true}, // 36 characters.
|
||||
{"a123456789012345678901234567890123456", false}, // 37 characters.
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -37,27 +36,3 @@ func TestUIDMatcher(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceIDMatcher(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"a", true},
|
||||
{"abc-123", true},
|
||||
{"a" + strings.Repeat("b", 62), true},
|
||||
{"a" + strings.Repeat("b", 63), false},
|
||||
{"1abc", false},
|
||||
{"Abc", false},
|
||||
{"abc-", false},
|
||||
{"abc_def", false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
if got := ResourceIDMatcher.MatchString(test.input); got != test.expected {
|
||||
t.Errorf("ResourceIDMatcher.MatchString(%q) = %v, want %v", test.input, got, test.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ message CreateAttachmentRequest {
|
||||
|
||||
// Optional. The attachment ID to use for this attachment.
|
||||
// If empty, a unique ID will be generated.
|
||||
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
string attachment_id = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ message CreateIdentityProviderRequest {
|
||||
|
||||
// Optional. The ID to use for the identity provider, which will become the final component of the resource name.
|
||||
// If not provided, the system will generate one.
|
||||
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
string identity_provider_id = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ message Memo {
|
||||
};
|
||||
|
||||
// The resource name of the memo.
|
||||
// Format: memos/{memo}, memo is the user defined id or uuid.
|
||||
// Format: memos/{memo}, where memo is the user-defined UID.
|
||||
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
|
||||
|
||||
// The state of the memo.
|
||||
@@ -296,7 +296,7 @@ message CreateMemoRequest {
|
||||
|
||||
// Optional. The memo ID to use for this memo.
|
||||
// If empty, a unique ID will be generated.
|
||||
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
string memo_id = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
|
||||
@@ -312,8 +312,8 @@ message CreateUserRequest {
|
||||
];
|
||||
|
||||
// Optional. The resource ID to use for this user. If set, it must equal
|
||||
// user.username and follow the User resource ID format.
|
||||
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
// user.username, follow the UID format, and not consist entirely of digits.
|
||||
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
string user_id = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. If set, validate the request but don't actually create the user.
|
||||
|
||||
@@ -327,7 +327,7 @@ type CreateAttachmentRequest struct {
|
||||
Attachment *Attachment `protobuf:"bytes,1,opt,name=attachment,proto3" json:"attachment,omitempty"`
|
||||
// Optional. The attachment ID to use for this attachment.
|
||||
// If empty, a unique ID will be generated.
|
||||
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
AttachmentId string `protobuf:"bytes,2,opt,name=attachment_id,json=attachmentId,proto3" json:"attachment_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
|
||||
@@ -512,7 +512,7 @@ type CreateIdentityProviderRequest struct {
|
||||
IdentityProvider *IdentityProvider `protobuf:"bytes,1,opt,name=identity_provider,json=identityProvider,proto3" json:"identity_provider,omitempty"`
|
||||
// Optional. The ID to use for the identity provider, which will become the final component of the resource name.
|
||||
// If not provided, the system will generate one.
|
||||
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
IdentityProviderId string `protobuf:"bytes,2,opt,name=identity_provider_id,json=identityProviderId,proto3" json:"identity_provider_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
|
||||
@@ -219,7 +219,7 @@ func (x *Reaction) GetCreateTime() *timestamppb.Timestamp {
|
||||
type Memo struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// The resource name of the memo.
|
||||
// Format: memos/{memo}, memo is the user defined id or uuid.
|
||||
// Format: memos/{memo}, where memo is the user-defined UID.
|
||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||
// The state of the memo.
|
||||
State State `protobuf:"varint,2,opt,name=state,proto3,enum=memos.api.v1.State" json:"state,omitempty"`
|
||||
@@ -472,7 +472,7 @@ type CreateMemoRequest struct {
|
||||
Memo *Memo `protobuf:"bytes,1,opt,name=memo,proto3" json:"memo,omitempty"`
|
||||
// Optional. The memo ID to use for this memo.
|
||||
// If empty, a unique ID will be generated.
|
||||
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
MemoId string `protobuf:"bytes,2,opt,name=memo_id,json=memoId,proto3" json:"memo_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
|
||||
@@ -651,8 +651,8 @@ type CreateUserRequest struct {
|
||||
// Required. The user to create.
|
||||
User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
|
||||
// Optional. The resource ID to use for this user. If set, it must equal
|
||||
// user.username and follow the User resource ID format.
|
||||
// Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
// user.username, follow the UID format, and not consist entirely of digits.
|
||||
// Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
// Optional. If set, validate the request but don't actually create the user.
|
||||
ValidateOnly bool `protobuf:"varint,3,opt,name=validate_only,json=validateOnly,proto3" json:"validate_only,omitempty"`
|
||||
|
||||
@@ -95,7 +95,7 @@ paths:
|
||||
description: |-
|
||||
Optional. The attachment ID to use for this attachment.
|
||||
If empty, a unique ID will be generated.
|
||||
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
@@ -350,7 +350,7 @@ paths:
|
||||
description: |-
|
||||
Optional. The ID to use for the identity provider, which will become the final component of the resource name.
|
||||
If not provided, the system will generate one.
|
||||
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
@@ -705,7 +705,7 @@ paths:
|
||||
description: |-
|
||||
Optional. The memo ID to use for this memo.
|
||||
If empty, a unique ID will be generated.
|
||||
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
@@ -1355,8 +1355,8 @@ paths:
|
||||
in: query
|
||||
description: |-
|
||||
Optional. The resource ID to use for this user. If set, it must equal
|
||||
user.username and follow the User resource ID format.
|
||||
Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
user.username, follow the UID format, and not consist entirely of digits.
|
||||
Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
schema:
|
||||
type: string
|
||||
- name: validateOnly
|
||||
@@ -3230,7 +3230,7 @@ components:
|
||||
type: string
|
||||
description: |-
|
||||
The resource name of the memo.
|
||||
Format: memos/{memo}, memo is the user defined id or uuid.
|
||||
Format: memos/{memo}, where memo is the user-defined UID.
|
||||
state:
|
||||
enum:
|
||||
- STATE_UNSPECIFIED
|
||||
|
||||
@@ -136,14 +136,14 @@ func ExtractIdentityProviderUIDFromName(name string) (string, error) {
|
||||
|
||||
// ValidateAndGenerateUID validates a user-provided UID or generates a new one.
|
||||
// If provided is empty, a new shortuuid is generated.
|
||||
// If provided is non-empty, it is validated as a user-provided resource ID.
|
||||
// If provided is non-empty, it is validated against base.UIDMatcher.
|
||||
func ValidateAndGenerateUID(provided string) (string, error) {
|
||||
uid := strings.TrimSpace(provided)
|
||||
if uid == "" {
|
||||
return shortuuid.New(), nil
|
||||
}
|
||||
if !base.ResourceIDMatcher.MatchString(uid) {
|
||||
return "", status.Errorf(codes.InvalidArgument, "invalid resource ID: must be 1-63 characters, start with a lowercase letter, contain only lowercase letters, digits, or hyphens, and end with a letter or digit")
|
||||
if !base.UIDMatcher.MatchString(uid) {
|
||||
return "", status.Errorf(codes.InvalidArgument, "invalid UID: must be 1-36 characters, contain only letters, digits, or hyphens, and start and end with a letter or digit")
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ func TestValidateAndGenerateUIDValidatesUserProvidedResourceIDs(t *testing.T) {
|
||||
wantError bool
|
||||
}{
|
||||
{name: "lowercase", provided: "memo-1"},
|
||||
{name: "maximum length", provided: "a" + strings.Repeat("b", 62)},
|
||||
{name: "digit first", provided: "1-memo", wantError: true},
|
||||
{name: "uppercase", provided: "Memo", wantError: true},
|
||||
{name: "too long", provided: "a" + strings.Repeat("b", 63), wantError: true},
|
||||
{name: "UUID", provided: "21ec98aa-9a8f-458c-a2a3-c7dc69b6f591"},
|
||||
{name: "maximum length", provided: "a" + strings.Repeat("b", 35)},
|
||||
{name: "digit first", provided: "1-memo"},
|
||||
{name: "uppercase", provided: "Memo"},
|
||||
{name: "too long", provided: "a" + strings.Repeat("b", 36), wantError: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
|
||||
// deriveSSOUsername produces the local username for a new SSO-created user.
|
||||
//
|
||||
// The current policy prefixes a UUID with a letter so the generated value
|
||||
// follows the same AIP-compatible format as user-selected usernames.
|
||||
// The current policy is to use a standard UUID string directly. This keeps the
|
||||
// username independent of IdP profile fields and avoids availability probes or
|
||||
// retry loops around concurrent first-time logins.
|
||||
func deriveSSOUsername() (string, error) {
|
||||
username := "user-" + util.GenUUID()
|
||||
username := util.GenUUID()
|
||||
if err := validateWritableUsername(username); err != nil {
|
||||
return "", errors.Wrap(err, "generated username did not satisfy username constraints")
|
||||
return "", errors.Wrap(err, "generated UUID did not satisfy username constraints")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -10,8 +9,8 @@ func TestDeriveSSOUsername(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("deriveSSOUsername() returned error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(username, "user-") {
|
||||
t.Fatalf("deriveSSOUsername() = %q, want user- prefix", username)
|
||||
if len(username) != 36 {
|
||||
t.Fatalf("deriveSSOUsername() = %q, want a 36-character UUID", username)
|
||||
}
|
||||
if err := validateWritableUsername(username); err != nil {
|
||||
t.Fatalf("deriveSSOUsername() produced invalid username %q: %v", username, err)
|
||||
|
||||
@@ -17,6 +17,27 @@ import (
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func TestCreateMemoAcceptsUUID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
user, err := ts.CreateRegularUser(ctx, "test-user")
|
||||
require.NoError(t, err)
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
const memoID = "21ec98aa-9a8f-458c-a2a3-c7dc69b6f591"
|
||||
memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "Created with a UUID",
|
||||
Visibility: apiv1.Visibility_PRIVATE,
|
||||
},
|
||||
MemoId: memoID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "memos/"+memoID, memo.Name)
|
||||
}
|
||||
|
||||
func TestListMemos(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -27,12 +27,24 @@ func parseUsernameFromName(name string) (string, error) {
|
||||
}
|
||||
|
||||
func validateWritableUsername(username string) error {
|
||||
if !base.ResourceIDMatcher.MatchString(username) {
|
||||
return errors.New("invalid username: must be 1-63 characters, start with a lowercase letter, contain only lowercase letters, digits, or hyphens, and end with a letter or digit")
|
||||
if username == "" || isNumericUsername(username) || !base.UIDMatcher.MatchString(username) {
|
||||
return errors.Errorf("invalid username %q", username)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isNumericUsername(username string) bool {
|
||||
if username == "" {
|
||||
return false
|
||||
}
|
||||
for _, char := range username {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveUserByName resolves a username-based user resource name to a store user.
|
||||
func ResolveUserByName(ctx context.Context, stores *store.Store, name string) (*store.User, error) {
|
||||
username, err := parseUsernameFromName(name)
|
||||
|
||||
@@ -16,9 +16,8 @@ func TestValidateWritableUsername(t *testing.T) {
|
||||
username: "alice",
|
||||
},
|
||||
{
|
||||
name: "mixed case",
|
||||
username: "Alice",
|
||||
wantError: true,
|
||||
name: "mixed case",
|
||||
username: "Alice",
|
||||
},
|
||||
{
|
||||
name: "hyphenated",
|
||||
@@ -30,17 +29,16 @@ func TestValidateWritableUsername(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "maximum length",
|
||||
username: "a" + strings.Repeat("b", 62),
|
||||
username: "a" + strings.Repeat("b", 35),
|
||||
},
|
||||
{
|
||||
name: "too long",
|
||||
username: "a" + strings.Repeat("b", 63),
|
||||
username: "a" + strings.Repeat("b", 36),
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "digit first",
|
||||
username: "1alice",
|
||||
wantError: true,
|
||||
name: "digit first",
|
||||
username: "1alice",
|
||||
},
|
||||
{
|
||||
name: "hyphen last",
|
||||
|
||||
@@ -146,7 +146,7 @@ export type CreateAttachmentRequest = Message<"memos.api.v1.CreateAttachmentRequ
|
||||
/**
|
||||
* Optional. The attachment ID to use for this attachment.
|
||||
* If empty, a unique ID will be generated.
|
||||
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
* Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
*
|
||||
* @generated from field: string attachment_id = 2;
|
||||
*/
|
||||
|
||||
@@ -257,7 +257,7 @@ export type CreateIdentityProviderRequest = Message<"memos.api.v1.CreateIdentity
|
||||
/**
|
||||
* Optional. The ID to use for the identity provider, which will become the final component of the resource name.
|
||||
* If not provided, the system will generate one.
|
||||
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
* Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
*
|
||||
* @generated from field: string identity_provider_id = 2;
|
||||
*/
|
||||
|
||||
@@ -79,7 +79,7 @@ export const ReactionSchema: GenMessage<Reaction> = /*@__PURE__*/
|
||||
export type Memo = Message<"memos.api.v1.Memo"> & {
|
||||
/**
|
||||
* The resource name of the memo.
|
||||
* Format: memos/{memo}, memo is the user defined id or uuid.
|
||||
* Format: memos/{memo}, where memo is the user-defined UID.
|
||||
*
|
||||
* @generated from field: string name = 1;
|
||||
*/
|
||||
@@ -292,7 +292,7 @@ export type CreateMemoRequest = Message<"memos.api.v1.CreateMemoRequest"> & {
|
||||
/**
|
||||
* Optional. The memo ID to use for this memo.
|
||||
* If empty, a unique ID will be generated.
|
||||
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
* Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
*
|
||||
* @generated from field: string memo_id = 2;
|
||||
*/
|
||||
|
||||
@@ -293,8 +293,8 @@ export type CreateUserRequest = Message<"memos.api.v1.CreateUserRequest"> & {
|
||||
|
||||
/**
|
||||
* Optional. The resource ID to use for this user. If set, it must equal
|
||||
* user.username and follow the User resource ID format.
|
||||
* Format: ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
* user.username, follow the UID format, and not consist entirely of digits.
|
||||
* Format: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$
|
||||
*
|
||||
* @generated from field: string user_id = 2;
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user