Skip to content

Commit 0b65d3a

Browse files
authored
Merge pull request #52 from platform-mesh/organization-managment-k8s-validation
Organization managment k8s validation
2 parents 9bb07b4 + 53fefec commit 0b65d3a

File tree

9 files changed

+230
-25
lines changed

9 files changed

+230
-25
lines changed

projects/wc/src/app/components/generic-ui/list-view/create-resource-modal/create-resource-modal.component.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@
5454
[required]="field.required"
5555
[valueState]="getValueState(fieldProperty)"
5656
[disabled]="isEditMode() && isCreateFieldOnly(field)"
57-
></ui5-input>
57+
>
58+
@if (form.controls[fieldProperty].errors?.k8sNameInvalid) {
59+
<div slot="valueStateMessage">
60+
<a href="{{ k8sMessages.RFC_1035.href }}" target="_blank">{{ k8sMessages.RFC_1035.message }}</a>
61+
</div>
62+
}
63+
</ui5-input>
5864
}
5965
</div>
6066
}

projects/wc/src/app/components/generic-ui/list-view/create-resource-modal/create-resource-modal.component.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { k8sMessages } from '../../../../consts/k8s-messages';
2+
import { k8sNameValidator } from '../../../../validators/k8s-name-validator';
13
import { DynamicSelectComponent } from '../../../dynamic-select/dynamic-select.component';
2-
import { CreateOnlyResourceFieldNames } from './create-resource-modal.enums';
4+
import { ResourceFieldNames } from './create-resource-modal.enums';
35
import {
46
Component,
57
OnInit,
@@ -65,6 +67,8 @@ export class CreateResourceModalComponent implements OnInit {
6567

6668
private originalResource = signal<Resource | null>(null);
6769

70+
protected readonly k8sMessages = k8sMessages;
71+
6872
ngOnInit(): void {
6973
this.form = this.fb.group(this.createControls());
7074
}
@@ -131,26 +135,39 @@ export class CreateResourceModalComponent implements OnInit {
131135

132136
isCreateFieldOnly(field: FieldDefinition): boolean {
133137
return (
134-
field.property === CreateOnlyResourceFieldNames.MetadataName ||
135-
field.property === CreateOnlyResourceFieldNames.SpecType ||
136-
field.property === CreateOnlyResourceFieldNames.MetadataNamespace
138+
field.property === ResourceFieldNames.MetadataName ||
139+
field.property === ResourceFieldNames.SpecType ||
140+
field.property === ResourceFieldNames.MetadataNamespace
137141
);
138142
}
139143

140144
private createControls(resource?: Resource) {
141145
return this.fields().reduce(
142146
(obj, fieldDefinition) => {
143-
const validator = fieldDefinition.required ? Validators.required : null;
147+
const validators = this.getValidator(fieldDefinition);
144148
const fieldName = this.sanitizePropertyName(fieldDefinition.property);
145149
const fieldValue =
146150
resource && typeof fieldDefinition.property === 'string'
147151
? getValueByPath(resource, fieldDefinition.property)
148152
: '';
149-
obj[fieldName] = new FormControl(fieldValue, validator);
153+
obj[fieldName] = new FormControl(fieldValue, validators);
150154

151155
return obj;
152156
},
153157
{} as Record<string, FormControl>,
154158
);
155159
}
160+
161+
private getValidator(fieldDefinition: FieldDefinition) {
162+
const validators = [];
163+
if (fieldDefinition.required) {
164+
validators.push(Validators.required);
165+
}
166+
167+
if (fieldDefinition.property === ResourceFieldNames.MetadataName) {
168+
validators.push(k8sNameValidator);
169+
}
170+
171+
return validators;
172+
}
156173
}

projects/wc/src/app/components/generic-ui/list-view/create-resource-modal/create-resource-modal.enums.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export enum CreateOnlyResourceFieldNames {
1+
export enum ResourceFieldNames {
22
MetadataName = 'metadata.name',
33
SpecType = 'spec.type',
44
MetadataNamespace = 'metadata.namespace',

projects/wc/src/app/components/organization-management/organization-management.component.html

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,17 @@
3333
}}</ui5-label
3434
><br />
3535
<div class="organization-management-input">
36-
<ui5-input
37-
id="input-onboard"
38-
placeholder="{{ texts.onboardOrganization.placeholder }}"
39-
[(ngModel)]="newOrganization"
40-
></ui5-input>
41-
<ui5-button design="Emphasized" (ui5Click)="onboardOrganization()">{{
36+
<ui5-input
37+
id="input-onboard"
38+
placeholder="{{ texts.onboardOrganization.placeholder }}"
39+
[formControl]="newOrganization"
40+
[valueState]="getValueState(newOrganization)"
41+
>
42+
<div slot="valueStateMessage">
43+
<a href="{{ k8sMessages.RFC_1035.href }}" target="_blank">{{ k8sMessages.RFC_1035.message }}</a>
44+
</div>
45+
</ui5-input>
46+
<ui5-button [disabled]="newOrganization.invalid" design="Emphasized" (ui5Click)="onboardOrganization()">{{
4247
texts.onboardOrganization.button
4348
}}</ui5-button>
4449
</div>

projects/wc/src/app/components/organization-management/organization-management.component.spec.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,23 +134,23 @@ describe('OrganizationManagementComponent', () => {
134134
reset: jest.fn(),
135135
};
136136
resourceServiceMock.create.mockReturnValue(of(mockResponse));
137-
component.newOrganization = 'newOrg';
137+
component.newOrganization.setValue('newOrg');
138138
component.organizations.set(['existingOrg']);
139139

140140
component.onboardOrganization();
141141

142142
expect(resourceServiceMock.create).toHaveBeenCalled();
143143
expect(component.organizations()).toEqual(['newOrg', 'existingOrg']);
144144
expect(component.organizationToSwitch()).toBe('newOrg');
145-
expect(component.newOrganization).toBe('');
145+
expect(component.newOrganization.value).toBe('');
146146
expect(luigiClientMock.uxManager().showAlert).toHaveBeenCalled();
147147
});
148148

149149
it('should handle organization creation error', () => {
150150
resourceServiceMock.create.mockReturnValue(
151151
throwError(() => new Error('Creation failed')),
152152
);
153-
component.newOrganization = 'newOrg';
153+
component.newOrganization.setValue('newOrg');
154154

155155
component.onboardOrganization();
156156

@@ -239,4 +239,44 @@ describe('OrganizationManagementComponent', () => {
239239

240240
expect(window.location.href).toBe('https://validorg.test.com');
241241
});
242+
243+
it('should return non-local message for organization creation when not in local setup', () => {
244+
// Mock window.location.hostname to simulate non-local setup
245+
const originalHostname = window.location.hostname;
246+
Object.defineProperty(window.location, 'hostname', {
247+
value: 'production.example.com',
248+
writable: true,
249+
});
250+
251+
const message = component['getMessageForOrganizationCreation']('testOrg');
252+
expect(message).toBe(
253+
'A new organization has been created. Select it from the list to switch.',
254+
);
255+
256+
// Restore original hostname
257+
Object.defineProperty(window.location, 'hostname', {
258+
value: originalHostname,
259+
writable: true,
260+
});
261+
});
262+
263+
it('should return Negative state for invalid and touched form control', () => {
264+
const formControl = {
265+
invalid: true,
266+
touched: true,
267+
} as any;
268+
269+
const result = component.getValueState(formControl);
270+
expect(result).toBe('Negative');
271+
});
272+
273+
it('should return None state for valid form control', () => {
274+
const formControl = {
275+
invalid: false,
276+
touched: true,
277+
} as any;
278+
279+
const result = component.getValueState(formControl);
280+
expect(result).toBe('None');
281+
});
242282
});

projects/wc/src/app/components/organization-management/organization-management.component.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { k8sMessages } from '../../consts/k8s-messages';
2+
import { k8sNameValidator } from '../../validators/k8s-name-validator';
13
import {
24
ChangeDetectionStrategy,
35
Component,
@@ -9,7 +11,12 @@ import {
911
linkedSignal,
1012
signal,
1113
} from '@angular/core';
12-
import { FormsModule } from '@angular/forms';
14+
import {
15+
FormControl,
16+
FormsModule,
17+
ReactiveFormsModule,
18+
Validators,
19+
} from '@angular/forms';
1320
import { LuigiClient } from '@luigi-project/client/luigi-element';
1421
import {
1522
EnvConfigService,
@@ -43,6 +50,7 @@ import {
4350
OptionComponent,
4451
SelectComponent,
4552
FormsModule,
53+
ReactiveFormsModule,
4654
],
4755
templateUrl: './organization-management.component.html',
4856
styleUrl: './organization-management.component.scss',
@@ -53,13 +61,19 @@ export class OrganizationManagementComponent implements OnInit {
5361
private i18nService = inject(I18nService);
5462
private resourceService = inject(ResourceService);
5563
private envConfigService = inject(EnvConfigService);
64+
5665
context = input<ResourceNodeContext>();
5766
LuigiClient = input<LuigiClient>();
5867

5968
texts: any = {};
6069
organizations = signal<string[]>([]);
6170
organizationToSwitch = linkedSignal(() => this.organizations()[0] ?? '');
62-
newOrganization: string;
71+
newOrganization = new FormControl('', {
72+
validators: [Validators.required, k8sNameValidator],
73+
nonNullable: true,
74+
});
75+
76+
protected readonly k8sMessages = k8sMessages;
6377

6478
constructor() {
6579
effect(() => {
@@ -101,7 +115,7 @@ export class OrganizationManagementComponent implements OnInit {
101115
onboardOrganization() {
102116
const resource: Resource = {
103117
spec: { type: 'org' },
104-
metadata: { name: this.newOrganization },
118+
metadata: { name: this.newOrganization.value },
105119
};
106120
const resourceDefinition: ResourceDefinition = {
107121
group: 'core.platform-mesh.io',
@@ -117,11 +131,11 @@ export class OrganizationManagementComponent implements OnInit {
117131
next: (result) => {
118132
console.debug('Resource created', result);
119133
this.organizations.set([
120-
this.newOrganization,
134+
this.newOrganization.value,
121135
...this.organizations(),
122136
]);
123-
this.organizationToSwitch.set(this.newOrganization);
124-
this.newOrganization = '';
137+
this.organizationToSwitch.set(this.newOrganization.value);
138+
this.newOrganization.reset();
125139
this.LuigiClient()
126140
.uxManager()
127141
.showAlert({
@@ -201,8 +215,8 @@ export class OrganizationManagementComponent implements OnInit {
201215

202216
if (!sanitizedOrg) {
203217
this.LuigiClient().uxManager().showAlert({
204-
text: 'Organization name is not valid for subdomain usage, accrording to RFC 1034/1123.',
205-
type: 'error',
218+
text: k8sMessages.RFC_1034_1123.message,
219+
type: k8sMessages.RFC_1034_1123.type,
206220
});
207221
return;
208222
}
@@ -211,4 +225,9 @@ export class OrganizationManagementComponent implements OnInit {
211225
const port = window.location.port ? `:${window.location.port}` : '';
212226
window.location.href = `${protocol}//${fullSubdomain}${port}`;
213227
}
228+
229+
getValueState(formControl: FormControl) {
230+
const control = formControl;
231+
return control.invalid && control.touched ? 'Negative' : 'None';
232+
}
214233
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const k8sMessages = {
2+
RFC_1035: {
3+
href: 'https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names',
4+
message: 'Invalid resource name accrording to RFC 1035',
5+
type: 'error',
6+
},
7+
RFC_1034_1123: {
8+
href: 'https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names',
9+
message:
10+
'Organization name is not valid for subdomain usage, accrording to RFC 1034/1123.',
11+
type: 'error',
12+
},
13+
} as const;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { k8sNameValidator } from './k8s-name-validator';
2+
import { AbstractControl } from '@angular/forms';
3+
4+
describe('k8sNameValidator', () => {
5+
const createControl = (value: any): AbstractControl =>
6+
({
7+
value,
8+
}) as AbstractControl;
9+
10+
it('should return null when control value is null', () => {
11+
const control = createControl(null);
12+
const result = k8sNameValidator(control);
13+
expect(result).toBeNull();
14+
});
15+
16+
it('should return null when control value is undefined', () => {
17+
const control = createControl(undefined);
18+
const result = k8sNameValidator(control);
19+
expect(result).toBeNull();
20+
});
21+
22+
it('should return null when control value is empty string', () => {
23+
const control = createControl('');
24+
const result = k8sNameValidator(control);
25+
expect(result).toBeNull();
26+
});
27+
28+
it('should return null for valid k8s name (single character)', () => {
29+
const control = createControl('a');
30+
const result = k8sNameValidator(control);
31+
expect(result).toBeNull();
32+
});
33+
34+
it('should return null for valid k8s name (with numbers and hyphens)', () => {
35+
const control = createControl('my-app-123');
36+
const result = k8sNameValidator(control);
37+
expect(result).toBeNull();
38+
});
39+
40+
it('should return null for valid k8s name (maximum length)', () => {
41+
const control = createControl('a'.repeat(63));
42+
const result = k8sNameValidator(control);
43+
expect(result).toBeNull();
44+
});
45+
46+
it('should return error for invalid k8s name starting with number', () => {
47+
const control = createControl('123invalid');
48+
const result = k8sNameValidator(control);
49+
expect(result).toEqual({ k8sNameInvalid: true });
50+
});
51+
52+
it('should return error for invalid k8s name starting with uppercase', () => {
53+
const control = createControl('Invalid');
54+
const result = k8sNameValidator(control);
55+
expect(result).toEqual({ k8sNameInvalid: true });
56+
});
57+
58+
it('should return error for invalid k8s name ending with hyphen', () => {
59+
const control = createControl('invalid-');
60+
const result = k8sNameValidator(control);
61+
expect(result).toEqual({ k8sNameInvalid: true });
62+
});
63+
64+
it('should return error for invalid k8s name with uppercase letters', () => {
65+
const control = createControl('Invalid-Name');
66+
const result = k8sNameValidator(control);
67+
expect(result).toEqual({ k8sNameInvalid: true });
68+
});
69+
70+
it('should return error for invalid k8s name exceeding maximum length', () => {
71+
const control = createControl('a'.repeat(64));
72+
const result = k8sNameValidator(control);
73+
expect(result).toEqual({ k8sNameInvalid: true });
74+
});
75+
76+
it('should return error for invalid k8s name with special characters', () => {
77+
const control = createControl('invalid@name');
78+
const result = k8sNameValidator(control);
79+
expect(result).toEqual({ k8sNameInvalid: true });
80+
});
81+
82+
it('should handle non-string values by converting to string', () => {
83+
const control = createControl(123);
84+
const result = k8sNameValidator(control);
85+
expect(result).toEqual({ k8sNameInvalid: true });
86+
});
87+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { AbstractControl } from '@angular/forms';
2+
3+
export function k8sNameValidator(control: AbstractControl) {
4+
if (!control.value) {
5+
return null;
6+
}
7+
8+
const value = control.value.toString();
9+
10+
// RFC 1035 validation: max 63 chars, lowercase alphanumeric or '-', starts with letter, ends with alphanumeric
11+
const k8sNameRegex = /^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/;
12+
13+
if (!k8sNameRegex.test(value)) {
14+
return { k8sNameInvalid: true };
15+
}
16+
17+
return null;
18+
}

0 commit comments

Comments
 (0)