Skip to content

Commit 8e4cf65

Browse files
sunnylqmclaude
andcommitted
feat(target): verify an explicit --appId matches the command's platform
The server accepts a bundle or native package for any app the account owns and never learns which platform it was built for, so `bundle --platform ios --appId <android app> --name v3` used to publish an iOS bundle into the Android app and bind it to Android packages. resolveAppId now looks the app up (GET /app/:id) whenever an explicit appId meets a known platform and fails before any expensive work when they disagree; a foreign or missing id fails there too instead of after the build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JCaS35vZG4DCtmM24MYaVR
1 parent 7e89a29 commit 8e4cf65

5 files changed

Lines changed: 108 additions & 5 deletions

File tree

src/app.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,22 @@ export async function getSelectedApp(
7777
};
7878
}
7979

80+
/**
81+
* Fail fast when an explicit `--appId` names an app of another platform.
82+
* The server accepts a bundle for any app the account owns and never sees
83+
* the platform it was built for, so this is the only place the mistake can
84+
* be caught before it reaches devices. A missing or foreign app fails here
85+
* too (403/404) instead of after the expensive work.
86+
*/
87+
async function assertAppPlatform(appId: string, platform: Platform) {
88+
const app = (await get(`/app/${appId}`)) as { platform?: Platform };
89+
if (app.platform && app.platform !== platform) {
90+
throw new Error(
91+
t('appPlatformMismatch', { appId, appPlatform: app.platform, platform }),
92+
);
93+
}
94+
}
95+
8096
/**
8197
* Resolve the app an operation targets: an explicit `--appId` wins, otherwise
8298
* the app selected for the platform in `--config` (default: update.json).
@@ -89,7 +105,11 @@ export async function resolveAppId(
89105
assertPlatform(options.platform);
90106
}
91107
if (options.appId) {
92-
return String(options.appId);
108+
const appId = String(options.appId);
109+
if (options.platform) {
110+
await assertAppPlatform(appId, options.platform);
111+
}
112+
return appId;
93113
}
94114
const platform = await getPlatform(options.platform || undefined);
95115
return (await getSelectedApp(platform, options.config)).appId;

src/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export default {
2929
'App Key mismatch! Current IPA: {{appKeyInPkg}}, current {{- source}}: {{appKey}}',
3030
appName: 'App Name',
3131
appNameQuestion: 'App Name:',
32+
appPlatformMismatch:
33+
'App {{appId}} is a {{appPlatform}} app, but this command targets {{platform}}',
3234
appNotSelected:
3335
'App not selected. run `cresc selectApp --platform {{platform}}` first!',
3436
appUploadSuccess:

src/locales/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export default {
2727
'appKey不匹配!当前ipa: {{appKeyInPkg}}, 当前{{- source}}: {{appKey}}',
2828
appName: '应用名称',
2929
appNameQuestion: '应用名称:',
30+
appPlatformMismatch:
31+
'应用 {{appId}} 是 {{appPlatform}} 平台的应用,但当前命令的平台是 {{platform}}',
3032
appNotSelected:
3133
'尚未选择应用。请先运行 `pushy selectApp --platform {{platform}}` 来选择应用',
3234
appUploadSuccess:

tests/target-context.test.ts

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,19 @@ describe('resolveAppId', () => {
5959
readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(
6060
new Error('config should not be read'),
6161
);
62+
const getSpy = spyOn(api, 'get').mockRejectedValue(
63+
new Error('no platform given, so no lookup'),
64+
);
6265

63-
await expect(
64-
resolveAppId({ appId: '777', config: 'configs/prod.json' }),
65-
).resolves.toBe('777');
66-
expect(readFileSpy).not.toHaveBeenCalled();
66+
try {
67+
await expect(
68+
resolveAppId({ appId: '777', config: 'configs/prod.json' }),
69+
).resolves.toBe('777');
70+
expect(readFileSpy).not.toHaveBeenCalled();
71+
expect(getSpy).not.toHaveBeenCalled();
72+
} finally {
73+
getSpy.mockRestore();
74+
}
6775
});
6876

6977
test('still validates the platform next to an explicit appId', async () => {
@@ -72,6 +80,51 @@ describe('resolveAppId', () => {
7280
).rejects.toThrow();
7381
});
7482

83+
test('an explicit appId of the requested platform is accepted', async () => {
84+
const getSpy = spyOn(api, 'get').mockResolvedValue({
85+
id: 777,
86+
platform: 'ios',
87+
});
88+
89+
try {
90+
await expect(
91+
resolveAppId({ appId: '777', platform: 'ios' }),
92+
).resolves.toBe('777');
93+
expect(getSpy).toHaveBeenCalledWith('/app/777');
94+
} finally {
95+
getSpy.mockRestore();
96+
}
97+
});
98+
99+
test('an explicit appId of another platform is rejected', async () => {
100+
const getSpy = spyOn(api, 'get').mockResolvedValue({
101+
id: 42,
102+
platform: 'android',
103+
});
104+
105+
try {
106+
await expect(
107+
resolveAppId({ appId: '42', platform: 'ios' }),
108+
).rejects.toThrow(/42.*android.*ios/);
109+
} finally {
110+
getSpy.mockRestore();
111+
}
112+
});
113+
114+
test('a foreign or missing explicit appId fails before any work', async () => {
115+
const getSpy = spyOn(api, 'get').mockRejectedValue(
116+
new Error('403 Forbidden'),
117+
);
118+
119+
try {
120+
await expect(
121+
resolveAppId({ appId: '42', platform: 'ios' }),
122+
).rejects.toThrow('403');
123+
} finally {
124+
getSpy.mockRestore();
125+
}
126+
});
127+
75128
test('a missing config is reported as app-not-selected', async () => {
76129
readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoent());
77130

@@ -193,6 +246,9 @@ describe('bundle target context', () => {
193246

194247
test('an explicit appId skips the config and reaches publish', async () => {
195248
readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoent());
249+
restore.push(
250+
spyOn(api, 'get').mockResolvedValue({ id: 777, platform: 'ios' }),
251+
);
196252

197253
await bundleCommands.bundle({
198254
options: { platform: 'ios', appId: '777', name: 'v1' },
@@ -209,6 +265,22 @@ describe('bundle target context', () => {
209265
});
210266
});
211267

268+
test('a named bundle for an app of another platform fails before any work', async () => {
269+
restore.push(
270+
spyOn(api, 'get').mockResolvedValue({ id: 42, platform: 'android' }),
271+
);
272+
273+
await expect(
274+
bundleCommands.bundle({
275+
options: { platform: 'ios', appId: '42', name: 'v3' },
276+
}),
277+
).rejects.toThrow(/android/);
278+
279+
expect(addGitIgnoreSpy).not.toHaveBeenCalled();
280+
expect(runBundleSpy).not.toHaveBeenCalled();
281+
expect(publishSpy).not.toHaveBeenCalled();
282+
});
283+
212284
test('a named bundle without a selected app fails before any work', async () => {
213285
readFileSpy = spyOn(fs.promises, 'readFile').mockRejectedValue(enoent());
214286

tests/versions.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,15 @@ describe('versionCommands.publish', () => {
117117
let questionSpy: ReturnType<typeof spyOn>;
118118
let getCommitInfoSpy: ReturnType<typeof spyOn>;
119119
let updateSpy: ReturnType<typeof spyOn>;
120+
let appGetSpy: ReturnType<typeof spyOn>;
120121

121122
beforeEach(() => {
122123
consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
123124
getPlatformSpy = spyOn(app, 'getPlatform').mockResolvedValue('android');
125+
appGetSpy = spyOn(api, 'get').mockResolvedValue({
126+
id: 777,
127+
platform: 'android',
128+
});
124129
getSelectedAppSpy = spyOn(app, 'getSelectedApp').mockResolvedValue({
125130
appId: '100',
126131
appKey: 'key',
@@ -147,6 +152,7 @@ describe('versionCommands.publish', () => {
147152
questionSpy.mockRestore();
148153
getCommitInfoSpy.mockRestore();
149154
updateSpy.mockRestore();
155+
appGetSpy.mockRestore();
150156
});
151157

152158
test('can bind after publish when called without object receiver', async () => {
@@ -181,6 +187,7 @@ describe('versionCommands.publish', () => {
181187
});
182188

183189
expect(getSelectedAppSpy).not.toHaveBeenCalled();
190+
expect(appGetSpy).toHaveBeenCalledWith('/app/777');
184191
expect(uploadFileSpy).toHaveBeenCalledWith('bundle.ppk', undefined, '777');
185192
expect(postSpy).toHaveBeenCalledWith(
186193
'/app/777/version/create',

0 commit comments

Comments
 (0)